diff --git a/.github/evidence.yml b/.github/evidence.yml new file mode 100644 index 0000000000..a4bc7bb9b9 --- /dev/null +++ b/.github/evidence.yml @@ -0,0 +1,8 @@ +# Pull requests that change the phone app, the TV app, or the Android code they share +# need evidence before the Evidence check passes. The check reads this file at a pull +# request's base commit. Maintainers override it with the evidence-required and +# evidence-not-needed labels. See "Show visible changes" in CONTRIBUTING.md. +require: + - androidApp/src/androidMain/** + - androidTvApp/src/androidMain/** + - android-shared/src/androidMain/** diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c6fd5eb38b..99b1d15c25 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,8 +2,8 @@ name: Release # One release pipeline, playing the role of silo-apple's TestFlight # (release.yml) and sideload (sideload-ipa.yml) workflows combined: a version -# tag or a manual dispatch publishes both App Bundles to Google Play, then -# builds the signed sideload APKs and attaches them to a GitHub release for +# tag or a manual dispatch prepares both App Bundles for Google Play and +# signed sideload APKs in parallel, then attaches the APKs to a GitHub release for # the same version. Dispatch runs mint the v tag when creating the # GitHub release — no manual tagging is necessary (apple's sideload pattern). # @@ -80,6 +80,17 @@ jobs: play_publish: ${{ steps.ver.outputs.play_publish }} tag: ${{ steps.ver.outputs.tag }} steps: + - name: Check out sources + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Check build supply chain + run: | + ./scripts/test-release-workflow.sh + ./scripts/test-check-build-supply-chain.sh + ./scripts/check-build-supply-chain.sh + - id: ver shell: bash env: @@ -184,6 +195,7 @@ jobs: # owned the APK release path. unit-tests: name: Unit tests + needs: [setup] runs-on: ubuntu-latest steps: @@ -192,12 +204,6 @@ jobs: with: persist-credentials: false - - name: Check build supply chain - run: | - ./scripts/test-release-workflow.sh - ./scripts/test-check-build-supply-chain.sh - ./scripts/check-build-supply-chain.sh - - name: Set up JDK 21 uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 with: @@ -207,6 +213,16 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 + # Robolectric downloads its Android runtimes outside Gradle's cache. + # Share the normal CI key so releases reuse those runtime downloads. + - name: Cache Robolectric Android runtimes + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.m2/repository/org/robolectric + key: robolectric-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml', '**/build.gradle.kts') }} + restore-keys: | + robolectric-${{ runner.os }}- + - name: Run unit tests shell: bash run: | @@ -226,14 +242,18 @@ jobs: **/build/reports/tests/** if-no-files-found: ignore - # Publish both App Bundles to Google Play via the fastlane android beta - # lane. No status function in the if -> implicit success() gate on needs, - # so failed setup/tests skip this job. - play: - name: Publish to Google Play - needs: [setup, unit-tests] + # Prepare final signed bundles while tests run. Publication consumes these + # exact bytes only after both preparation and tests succeed. + play-bundles: + name: Prepare Google Play bundles + needs: [setup] if: ${{ needs.setup.outputs.play_publish == 'true' }} runs-on: ubuntu-latest + outputs: + artifact_id: ${{ steps.bundles.outputs.artifact-id }} + artifact_name: ${{ steps.identity.outputs.artifact_name }} + producer_attempt: ${{ steps.identity.outputs.producer_attempt }} + manifest_sha256: ${{ steps.identity.outputs.manifest_sha256 }} steps: - name: Check out sources @@ -254,33 +274,98 @@ jobs: with: bundler-cache: true - # The Fastfile re-validates the version and derives the versionCode - # base from it before building. - - name: Build & upload to Google Play - run: bundle exec fastlane android beta + - name: Prepare signed Google Play bundles + run: bundle exec fastlane android prepare_bundles env: PRAIRIE_VERSION_NAME: ${{ needs.setup.outputs.version_name }} PRAIRIE_BUILD_NUMBER: ${{ needs.setup.outputs.build_number }} PLAY_TRACK: ${{ inputs.track || 'beta' }} - PLAY_RELEASE_NOTES: ${{ vars.PLAY_RELEASE_NOTES }} - PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} PRAIRIE_RELEASE_KEYSTORE_B64: ${{ secrets.PRAIRIE_RELEASE_KEYSTORE_B64 }} PRAIRIE_RELEASE_KEYSTORE_PASSWORD: ${{ secrets.PRAIRIE_RELEASE_KEYSTORE_PASSWORD }} PRAIRIE_RELEASE_KEY_PASSWORD: ${{ secrets.PRAIRIE_RELEASE_KEY_PASSWORD }} PRAIRIE_RELEASE_KEY_ALIAS: ${{ secrets.PRAIRIE_RELEASE_KEY_ALIAS }} PRAIRIE_GOOGLE_SERVICES_JSON_B64: ${{ secrets.PRAIRIE_GOOGLE_SERVICES_JSON_B64 }} - # Signed sideload APKs (moved here from android-build.yml). Runs when the - # Play publish succeeded or was skipped (prerelease tags) — never when it - # failed, so the GitHub release can't outrun the Play track. + - name: Identify bundle artifact + id: identity + shell: bash + run: | + set -euo pipefail + manifest_sha256="$(sha256sum build/play-bundles-manifest.json | cut -d ' ' -f1)" + { + echo "artifact_name=android-play-bundles-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + echo "producer_attempt=${GITHUB_RUN_ATTEMPT}" + echo "manifest_sha256=${manifest_sha256}" + } >> "${GITHUB_OUTPUT}" + + - name: Upload prepared Play bundles + id: bundles + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + with: + name: ${{ steps.identity.outputs.artifact_name }} + path: | + androidApp/build/outputs/bundle/release/androidApp-release.aab + androidTvApp/build/outputs/bundle/release/androidTvApp-release.aab + build/play-bundles-manifest.json + if-no-files-found: error + compression-level: 0 + + play: + name: Publish to Google Play + needs: [setup, unit-tests, play-bundles] + if: ${{ needs.setup.outputs.play_publish == 'true' }} + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + steps: + - name: Check out sources + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1 + with: + bundler-cache: true + + - name: Verify bundle artifact provenance + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PLAY_BUNDLE_ARTIFACT_ID: ${{ needs.play-bundles.outputs.artifact_id }} + PLAY_BUNDLE_ARTIFACT_NAME: ${{ needs.play-bundles.outputs.artifact_name }} + PRAIRIE_PLAY_BUNDLE_ATTEMPT: ${{ needs.play-bundles.outputs.producer_attempt }} + PRAIRIE_PLAY_BUNDLE_MANIFEST_SHA256: ${{ needs.play-bundles.outputs.manifest_sha256 }} + run: | + set -euo pipefail + [[ "${GITHUB_REPOSITORY}" == 'Prairie-Server/prairie-android' ]] + [[ "${PLAY_BUNDLE_ARTIFACT_ID}" =~ ^[1-9][0-9]*$ ]] + [[ "${PRAIRIE_PLAY_BUNDLE_MANIFEST_SHA256}" =~ ^[0-9a-f]{64}$ ]] + gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${PLAY_BUNDLE_ARTIFACT_ID}" | + ruby fastlane/play_bundle_manifest.rb verify-artifact + + - name: Download prepared Play bundles + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + with: + artifact-ids: ${{ needs.play-bundles.outputs.artifact_id }} + path: . + + - name: Upload verified bundles to Google Play + run: bundle exec fastlane android upload_bundles + env: + PRAIRIE_VERSION_NAME: ${{ needs.setup.outputs.version_name }} + PRAIRIE_BUILD_NUMBER: ${{ needs.setup.outputs.build_number }} + PLAY_TRACK: ${{ inputs.track || 'beta' }} + PRAIRIE_PLAY_BUNDLE_ATTEMPT: ${{ needs.play-bundles.outputs.producer_attempt }} + PRAIRIE_PLAY_BUNDLE_MANIFEST_SHA256: ${{ needs.play-bundles.outputs.manifest_sha256 }} + PLAY_RELEASE_NOTES: ${{ vars.PLAY_RELEASE_NOTES }} + PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} + + # Prepare signed sideload APKs alongside Play after setup and tests succeed. + # publish-release waits for both paths before exposing the APKs. apks: name: ${{ matrix.name }} - needs: [setup, unit-tests, play] - if: >- - ${{ !cancelled() && - needs.setup.result == 'success' && - needs.unit-tests.result == 'success' && - (needs.play.result == 'success' || needs.play.result == 'skipped') }} + needs: [setup, unit-tests] runs-on: ubuntu-latest strategy: fail-fast: false @@ -385,14 +470,6 @@ jobs: apk_name="$(basename "${apk}")" variant="${apk_name#${{ matrix.module }}-}" cp "${apk}" "${artifact_dir}/${{ matrix.release-prefix }}-${version_label}-${variant}" - - if [[ "${variant}" == "universal-release.apk" ]]; then - cp "${apk}" "${artifact_dir}/${{ matrix.release-prefix }}-latest-${variant}" - # The Downloader by AFTVnews short codes redirect to the old - # debug-named asset URL. Keep publishing the release build under - # that filename until the aftv.news codes are re-registered. - cp "${apk}" "${artifact_dir}/${{ matrix.release-prefix }}-latest-universal-debug.apk" - fi done - name: Upload release APKs @@ -409,11 +486,14 @@ jobs: # re-trigger workflows, so this cannot recurse. publish-release: name: Publish GitHub Release - needs: [setup, apks] + needs: [setup, unit-tests, play-bundles, play, apks] if: >- ${{ !cancelled() && needs.setup.result == 'success' && - needs.apks.result == 'success' }} + needs.unit-tests.result == 'success' && + needs.apks.result == 'success' && + ((needs.setup.outputs.play_publish == 'true' && needs.play-bundles.result == 'success' && needs.play.result == 'success') || + (needs.setup.outputs.play_publish == 'false' && needs.play-bundles.result == 'skipped' && needs.play.result == 'skipped')) }} runs-on: ubuntu-latest permissions: contents: write @@ -431,11 +511,26 @@ jobs: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} RELEASE_TAG: ${{ needs.setup.outputs.tag }} + RELEASE_VERSION: ${{ needs.setup.outputs.version }} run: | set -euo pipefail shopt -s globstar nullglob - release_title="Silo Android ${RELEASE_TAG}" + # Transfer each APK once, then recreate the universal aliases here. + # Downloader by AFTVnews still redirects to the debug-named aliases. + for prefix in prairie-android prairie-android-tv; do + universal_apks=(release-artifacts/**/"${prefix}-${RELEASE_VERSION}-universal-release.apk") + if (( ${#universal_apks[@]} != 1 )) || [[ ! -s "${universal_apks[0]}" ]]; then + echo "::error::Expected one nonempty universal APK for ${prefix}." + exit 1 + fi + universal_apk="${universal_apks[0]}" + artifact_dir="$(dirname "${universal_apk}")" + cp "${universal_apk}" "${artifact_dir}/${prefix}-latest-universal-release.apk" + cp "${universal_apk}" "${artifact_dir}/${prefix}-latest-universal-debug.apk" + done + + release_title="Prairie Android ${RELEASE_TAG}" android_latest_url="https://github.com/${GH_REPO}/releases/latest/download/prairie-android-latest-universal-release.apk" android_tv_latest_url="https://github.com/${GH_REPO}/releases/latest/download/prairie-android-tv-latest-universal-release.apk" android_downloader_url="http://aftv.news/1051382" diff --git a/.github/workflows/trusted-linux-ci.yml b/.github/workflows/trusted-linux-ci.yml new file mode 100644 index 0000000000..1efc4e2f7d --- /dev/null +++ b/.github/workflows/trusted-linux-ci.yml @@ -0,0 +1,385 @@ +name: Trusted Linux CI + +# Runner-group policy allows this file only at refs/heads/main. +# The host controller checks live membership and creates a runner for this run. +on: + workflow_call: + inputs: + self_hosted: + type: boolean + required: false + default: false + build_ffmpeg_aar: + type: boolean + required: false + default: false + build_cache: + type: boolean + required: false + default: true + parallel: + type: boolean + required: false + default: false + configuration_cache: + type: boolean + required: false + default: false + +permissions: + contents: read + +env: + PRAIRIE_CI_CACHE_FLAG: ${{ inputs.build_cache && '--build-cache' || '--no-build-cache' }} + PRAIRIE_CI_PARALLEL_FLAG: ${{ inputs.parallel && '--parallel' || '--no-parallel' }} + PRAIRIE_CI_CONFIGURATION_FLAG: ${{ inputs.configuration_cache && '--configuration-cache' || '--no-configuration-cache' }} + # Pass a literal string to action inputs and record the same policy decision. + # Internal PR writes stay scoped to their merge ref; forks remain read-only. + PRAIRIE_CI_CACHE_READ_ONLY: ${{ inputs.build_cache && (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association))) && 'false' || 'true' }} + +jobs: + changes: + name: Select CI work + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + schema: ${{ steps.route.outputs.schema }} + mode: ${{ steps.route.outputs.mode }} + unit_tests: ${{ steps.route.outputs.unit_tests }} + lint: ${{ steps.route.outputs.lint }} + release_readiness: ${{ steps.route.outputs.release_readiness }} + ffmpeg_aar: ${{ steps.route.outputs.ffmpeg_aar }} + + steps: + - name: Check out sources + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + fetch-depth: 1 + + - name: Check build supply chain and CI scripts + run: | + ./scripts/test-check-build-supply-chain.sh + ./scripts/check-build-supply-chain.sh + ./scripts/test-release-workflow.sh + python3 scripts/test-ci-routing.py -v + python3 scripts/test-ci-result.py -v + + - name: Select validation jobs + id: route + run: python3 scripts/ci-routing.py + env: + PRAIRIE_CI_FFMPEG_ONLY: ${{ github.event_name == 'workflow_dispatch' && inputs.build_ffmpeg_aar && 'true' || 'false' }} + + ffmpeg-aar: + name: Media3 FFmpeg AAR + needs: changes + if: needs.changes.outputs.ffmpeg_aar == 'true' + runs-on: ${{ inputs.self_hosted && (github.repository == 'Prairie-Server/prairie-android' && ((github.event_name == 'pull_request' && contains(fromJSON(vars.PRAIRIE_CI_MEMBERS || '[]'), github.event.pull_request.user.login)) || (github.event_name != 'pull_request' && github.ref == 'refs/heads/main'))) && fromJSON(format('["self-hosted","linux","x64","prairie-linux","prairie-run-{0}-{1}","prairie-job-ffmpeg-aar"]', github.run_id, github.run_attempt)) || 'ubuntu-latest' }} + timeout-minutes: 45 + + steps: + - name: Check out sources + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Set up JDK 21 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 + with: + distribution: temurin + java-version: "21" + + - name: Install pinned native toolchain + shell: bash + run: | + set -euo pipefail + "${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" --install \ + "ndk;26.3.11579264" \ + "cmake;3.22.1" + + - name: Build FFmpeg decoder extension + shell: bash + run: | + set -euo pipefail + export ANDROID_NDK_HOME="${ANDROID_HOME}/ndk/26.3.11579264" + ./scripts/build-ffmpeg-aar.sh + + - name: Verify packaged ABIs and renderer + shell: bash + run: | + set -euo pipefail + aar="android-shared/libs/media3-decoder-ffmpeg-1.11.0.aar" + unzip -l "${aar}" | grep -F "jni/arm64-v8a/libffmpegJNI.so" + unzip -l "${aar}" | grep -F "jni/armeabi-v7a/libffmpegJNI.so" + unzip -l "${aar}" | grep -F "jni/x86_64/libffmpegJNI.so" + unzip -p "${aar}" classes.jar > /tmp/media3-ffmpeg-classes.jar + unzip -l /tmp/media3-ffmpeg-classes.jar | \ + grep -F "androidx/media3/decoder/ffmpeg/FfmpegAudioRenderer.class" + readelf="${ANDROID_HOME}/ndk/26.3.11579264/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-readelf" + for abi in arm64-v8a armeabi-v7a x86_64; do + so="/tmp/libffmpegJNI-${abi}.so" + unzip -p "${aar}" "jni/${abi}/libffmpegJNI.so" > "${so}" + "${readelf}" -lW "${so}" | python3 -c ' + import sys + alignments = [ + int(line.split()[-1], 16) + for line in sys.stdin + if line.lstrip().startswith("LOAD ") + ] + if not alignments or min(alignments) < 0x4000: + raise SystemExit(f"ELF LOAD alignment is below 16 KB: {alignments}") + ' + done + sha256sum "${aar}" + + - name: Upload FFmpeg AAR + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + with: + name: media3-decoder-ffmpeg-1.11.0 + path: android-shared/libs/media3-decoder-ffmpeg-1.11.0.aar + if-no-files-found: error + + release-readiness: + name: Release readiness + env: + PRAIRIE_CI_PARALLEL_FLAG: --no-parallel + needs: changes + if: needs.changes.outputs.release_readiness == 'true' + runs-on: ${{ inputs.self_hosted && (github.repository == 'Prairie-Server/prairie-android' && ((github.event_name == 'pull_request' && contains(fromJSON(vars.PRAIRIE_CI_MEMBERS || '[]'), github.event.pull_request.user.login)) || (github.event_name != 'pull_request' && github.ref == 'refs/heads/main'))) && fromJSON(format('["self-hosted","linux","x64","prairie-linux","prairie-run-{0}-{1}","prairie-job-release-readiness"]', github.run_id, github.run_attempt)) || 'ubuntu-latest' }} + timeout-minutes: 45 + + steps: + - name: Check out sources + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + # Match release.yml: the pinned Ruby and frozen lockfile must work + # before a release reaches the Google Play job. + - name: Set up Ruby and install release dependencies + uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1 + with: + bundler-cache: true + + - name: Load the release lane and Play client + run: | + bundle exec fastlane lanes + bundle exec ruby -e "require 'supply'" + env: + FASTLANE_SKIP_UPDATE_CHECK: "true" + FASTLANE_OPT_OUT_USAGE: "true" + + - name: Set up JDK 21 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 + with: + distribution: temurin + java-version: "21" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 + with: + # Writes are scoped to the default branch, manual runs requiring write + # access, or internal PRs from an explicit trusted association. + # Forks retain read-only access and cannot seed another ref's cache. + cache-read-only: ${{ env.PRAIRIE_CI_CACHE_READ_ONLY }} + + - name: Record build inputs + run: ./scripts/ci-build-metadata.py + + # Exercise both Play artifacts, including R8 and bundle packaging. + # Signing and publishing remain in release.yml. + - name: Build phone and TV release bundles + run: | + ./gradlew \ + -Dorg.gradle.jvmargs="-Xmx4g -Dfile.encoding=UTF-8" \ + -Pandroid.r8.maxWorkers=1 \ + :androidApp:bundleRelease :androidTvApp:bundleRelease \ + "${PRAIRIE_CI_CACHE_FLAG}" "${PRAIRIE_CI_PARALLEL_FLAG}" "${PRAIRIE_CI_CONFIGURATION_FLAG}" \ + --max-workers=2 + test -s androidApp/build/outputs/bundle/release/androidApp-release.aab + test -s androidTvApp/build/outputs/bundle/release/androidTvApp-release.aab + + unit-tests: + name: Unit tests + env: + PRAIRIE_CI_PARALLEL_FLAG: --parallel + needs: changes + if: needs.changes.outputs.unit_tests == 'true' + runs-on: ${{ inputs.self_hosted && (github.repository == 'Prairie-Server/prairie-android' && ((github.event_name == 'pull_request' && contains(fromJSON(vars.PRAIRIE_CI_MEMBERS || '[]'), github.event.pull_request.user.login)) || (github.event_name != 'pull_request' && github.ref == 'refs/heads/main'))) && fromJSON(format('["self-hosted","linux","x64","prairie-linux","prairie-run-{0}-{1}","prairie-job-unit-tests"]', github.run_id, github.run_attempt)) || 'ubuntu-latest' }} + + steps: + - name: Check out sources + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Set up JDK 21 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 + with: + distribution: temurin + java-version: "21" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 + with: + # Writes are scoped to the default branch, manual runs requiring write + # access, or internal PRs from an explicit trusted association. + # Forks retain read-only access and cannot seed another ref's cache. + cache-read-only: ${{ env.PRAIRIE_CI_CACHE_READ_ONLY }} + + - name: Record build inputs + run: ./scripts/ci-build-metadata.py + + # Robolectric does NOT resolve its Android runtimes through Gradle. At test + # execution time it fetches android-all-instrumented straight from Maven + # Central into ~/.m2, which setup-gradle's cache does not cover — so every + # run re-downloaded 85-204 MB per SDK level. On 2026-08-11 Maven Central + # answered 403 and took out 31 unrelated tests in android-shared, and the + # download time is a large part of this job. + # + # Which runtimes get fetched depends on the Robolectric version AND on the + # SDK each test resolves to, so the key hashes the version catalogue and + # the module build files (min/target SDK) together. A new @Config(sdk=NN) + # in a test is deliberately NOT in the key: hashing test sources would + # bust this cache on nearly every pull request, which costs more than it + # saves. The residual gap is bounded — that one runtime is re-fetched each + # run until something else moves the key — and the restore-key prefix + # still seeds from the previous cache rather than starting cold. + # + # A run-id-suffixed key would close the gap by saving every run, but this + # cache is hundreds of MB; a new entry per run would evict the Gradle + # cache against the repository's 10 GB budget. + - name: Cache Robolectric Android runtimes + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.m2/repository/org/robolectric + key: robolectric-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml', '**/build.gradle.kts') }} + restore-keys: | + robolectric-${{ runner.os }}- + + - name: Run unit tests + shell: bash + run: | + set -euo pipefail + + # CI exercises debug variants; release.yml retains the host tests for + # distinct release behavior and BuildConfig values. + ./gradlew \ + -Dorg.gradle.jvmargs="-Xmx4g -Dfile.encoding=UTF-8" \ + testDebugUnitTest \ + "${PRAIRIE_CI_CACHE_FLAG}" "${PRAIRIE_CI_PARALLEL_FLAG}" "${PRAIRIE_CI_CONFIGURATION_FLAG}" \ + --max-workers=2 + + python3 - <<'PY' + import json + from pathlib import Path + import xml.etree.ElementTree as ET + reports = sorted(Path('.').glob('*/build/test-results/testDebugUnitTest/TEST-*.xml')) + totals = dict.fromkeys(('tests', 'failures', 'errors', 'skipped'), 0) + if not reports: + raise SystemExit('No JUnit reports were produced') + for report in reports: + suite = ET.parse(report).getroot() + if suite.tag != 'testsuite': + raise SystemExit(f'Invalid JUnit suite: {report}') + cases = suite.findall('testcase') + actual = {'tests': len(cases), **{ + key: sum(case.find(tag) is not None for case in cases) + for key, tag in (('failures', 'failure'), ('errors', 'error'), ('skipped', 'skipped')) + }} + for key in totals: + count = int(suite.attrib[key]) + if count != actual[key]: + raise SystemExit(f'Inconsistent JUnit {key}: {report}') + totals[key] += count + if totals['tests'] == 0 or totals['failures'] or totals['errors']: + raise SystemExit(f'JUnit validation failed: {totals}') + print('PRAIRIE_CI_TEST_TOTALS ' + json.dumps({**totals, 'reports': len(reports)}, sort_keys=True)) + PY + + - name: Upload test reports + if: failure() + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + with: + name: unit-test-reports + path: | + **/build/reports/tests/** + if-no-files-found: ignore + + lint: + name: Lint + needs: changes + if: needs.changes.outputs.lint == 'true' + runs-on: ${{ inputs.self_hosted && (github.repository == 'Prairie-Server/prairie-android' && ((github.event_name == 'pull_request' && contains(fromJSON(vars.PRAIRIE_CI_MEMBERS || '[]'), github.event.pull_request.user.login)) || (github.event_name != 'pull_request' && github.ref == 'refs/heads/main'))) && fromJSON(format('["self-hosted","linux","x64","prairie-linux","prairie-run-{0}-{1}","prairie-job-lint"]', github.run_id, github.run_attempt)) || 'ubuntu-latest' }} + + steps: + - name: Check out sources + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Set up JDK 21 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 + with: + distribution: temurin + java-version: "21" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 + with: + # Writes are scoped to the default branch, manual runs requiring write + # access, or internal PRs from an explicit trusted association. + # Forks retain read-only access and cannot seed another ref's cache. + cache-read-only: ${{ env.PRAIRIE_CI_CACHE_READ_ONLY }} + + - name: Record build inputs + run: ./scripts/ci-build-metadata.py + + - name: Run lint + shell: bash + run: | + set -euo pipefail + + # NewApi and InlinedApi are fatal here. Two startup crashes reached + # users because nothing checked API levels: a Spatializer call gated + # at 31 for a class introduced at 32, and an unguarded API 28 call. + # Existing findings are held in per-module lint-baseline.xml, so only + # NEW violations fail. If this job fails, fix the call — do not + # regenerate the baseline to make it pass. + # + # Release readiness builds both release bundles, which run fatal + # release lint. The final Android CI gate requires that job too. + ./gradlew \ + -Dorg.gradle.jvmargs="-Xmx4g -Dfile.encoding=UTF-8" \ + :android-shared:lintDebug :androidApp:lintDebug :androidTvApp:lintDebug \ + "${PRAIRIE_CI_CACHE_FLAG}" "${PRAIRIE_CI_PARALLEL_FLAG}" "${PRAIRIE_CI_CONFIGURATION_FLAG}" \ + --max-workers=2 + + - name: Upload lint reports + if: failure() + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + with: + name: lint-reports + path: | + **/build/reports/lint-results-*.html + **/build/reports/lint-results-*.xml + if-no-files-found: ignore + + android-ci: + name: Android CI + if: always() + needs: [changes, unit-tests, lint, release-readiness, ffmpeg-aar] + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Check out sources + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Require every selected validation job to succeed + run: python3 scripts/ci-result.py + env: + PRAIRIE_CI_NEEDS_JSON: ${{ toJSON(needs) }} diff --git a/AGENTS.md b/AGENTS.md index ce518f9a32..5058ba6753 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,10 +8,11 @@ This repository contains only the Prairie Android clients. Shared Kotlin logic l - Ebooks/Reading are phone-only. Do not expose ebooks or Reading on Android TV. - Android mobile navigation is Home, Libraries, For You, Calendar, and Downloads only when the active profile has downloads. Video, Audio, and Reading are library modes reached through Libraries, not bottom-nav tabs. -- Android TV navigation is Home, available media-type tabs from server libraries, For You (with its Watchlist/Favorites dropdown, mirroring tvOS `.recommendations`), and Calendar, plus search and profile actions. Reading/ebooks are excluded. +- Android TV navigation is Home, available media-type tabs from server libraries, For You (with its Watchlist/Favorites dropdown, mirroring tvOS `.recommendations`), Calendar, and Requests while the server enables it (mirroring tvOS `.requests`), plus search and profile actions. Reading/ebooks are excluded. - Live TV (Prairie-only) is live on phone and TV, gated by `LiveTvFeatureStore` (the server has channels), and reached from the profile menu (phone `ProfileMenu` via `LocalLiveTvMenuAction`; TV profile dropdown). It is not a bottom-nav or top-menu tab. - Upstream Silo syncs have silently dropped Prairie-only code. `scripts/check-prairie-invariants.sh` (CI job "Prairie invariants") anchors it; see `docs/upstream-sync.md`. When restoring dropped code, add an anchor. -- Requests is live on phone and TV, server-gated by `requests_enabled` (profile menu + search entry points, matching Apple). Admin surfaces are not exposed in the Android clients — no STATS dashboard, and none of the richer admin screens (users/sessions/logs/scans) — and neither is Watch Together. Session management (seeing where you are signed in and signing other devices out) is not exposed either; device pairing stays. This is a deliberate divergence from Apple, which does surface the STATS dashboard. Do not add any of it back to menus without an explicit product decision. +- Requests is live on phone and TV, server-gated by `requests_enabled` (phone: profile menu + search; TV: a top-bar tab + search, matching Apple). Admins who can moderate requests approve, decline, and retry them in the Requests screens, as on Apple; limits and auto-approval stay in web admin. Other admin surfaces are not exposed in the Android clients — no STATS dashboard, and none of the richer admin screens (users/sessions/logs/scans). Session management (seeing where you are signed in and signing other devices out) is not exposed either; device pairing stays. This is a deliberate divergence from Apple, which does surface the STATS dashboard. Do not add any of it back to menus without an explicit product decision. +- Watch Party (synchronized playback) is experimental on phone and TV. Its entry points (profile menu, the detail page's party action, and phone `prairie://watch-party` invitations) appear only while Settings → Experimental → Watch Party is on, a device-local toggle that defaults on in debug builds and off in release builds, and it works only when the server advertises the room and playback capabilities. Keep it behind the toggle until the qualification matrix in `docs/watch-party-implementation-plan.md` passes. ## Skills & review agents @@ -68,10 +69,22 @@ follow-up work. - Keep one concern per pull request. If an honest description needs the word "also," split the work. -- Include before-and-after images for UI changes. Include a short video when - motion or timing matters. -- Upload pull request evidence to GitHub. Never commit PR-only assets such as - `.github/pr-assets/`. +- Every pull request that changes what a user sees must include evidence, as + [Show visible changes](CONTRIBUTING.md#show-visible-changes) defines. That + covers UI and UX changes and changes to which items appear or what they show, + such as search results, home sections, recommendations, sorting, filtering, + metadata, or artwork. Use before-and-after captures of the same screen with the + same data, and a short recording when motion, timing, or focus matters. Write + `Evidence: none, no user-visible change` only when that is true. +- Attach evidence on GitHub under the PR body's Evidence heading, or publish it + with `npx @prairie-server/evidence publish --pr ` and put its + `Evidence:` link there. Only Prairie maintainers and the pull request's author can + open a published page. GitHub has no API for attaching images to a pull + request, so an agent either publishes with the CLI or gives the developer the + captures to attach. Check media for private information before it goes on + GitHub. When publishing exits 4, ask the developer to run + `npx @prairie-server/evidence login`; never approve that login or read the saved + key. Never commit PR-only assets such as `.github/pr-assets/`. - When babysitting a pull request, poll checks and review comments created after the last push. Verify bot findings against the source, fix real issues, and dismiss false positives with a written reason. Remain quiet when nothing diff --git a/FEATURES.md b/FEATURES.md index 6fa6d99af6..328911abc0 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -5,11 +5,12 @@ A detailed inventory of what the Android **phone** and **TV** clients do today. - ✅ implemented - 🟡 partial / basic ("bones-level") — works but slated for improvement - 🚧 planned (design/plan exists, not built) +- 🧪 experimental — built, behind Settings → Experimental - ➖ not present on this platform (by design or not yet built) File pointers are repository-relative. -> **Important exposure note:** Requests is live on both Android surfaces, gated by the server's `requests_enabled` flag (`/api/v1/requests/status`), and reached from the profile menu and search — matching the Apple clients. Admin surfaces (including the STATS dashboard) and Watch Together remain inaccessible. +> **Important exposure note:** Requests is live on both Android surfaces, gated by the server's `requests_enabled` flag (`/api/v2/requests/status`), and reached from the profile menu and search on phone and from a top-bar tab and search on TV — matching the Apple clients. Admins who can moderate approve, decline, and retry requests there. The richer admin screens (users/sessions/logs/scans) remain inaccessible. Watch Party is experimental, behind Settings → Experimental → Watch Party. --- @@ -33,10 +34,12 @@ File pointers are repository-relative. | HDMI EDID-driven display mode | ➖ | ✅ | `HdrDisplayController` | | Subtitle selection + styling (font/bg/position) | ✅ | ✅ | Media3 `SubtitleManager`; server plans render, convert, or burn-in fidelity | | Subtitle sync offset (±10s) / audio sync (±5s) | ✅ | ✅ | Per-profile | +| Server subtitle sync to audio (stored and sidecar subtitles) | ✅ | ✅ | Server-gated; per-track status, progress card, reset; retimed tracks reload in place; phone offline copies refresh. `SubtitleSyncController` | | Subtitle provider search + download | ✅ | ✅ | | | AI subtitle transcription / translation (quota-tracked) | ✅ | ✅ | TV: `TvAiTranslateDialog` | | AI description translation (on-view, server-gated) | ✅ | ✅ | `DescriptionTranslationController`; gated by `/api/v1/metadata/ai/status`; metadata-language setting in Settings | | Intro auto-skip (+ manual skip banner) | ✅ | ✅ | | +| Shuffle (library, series, season, collection) | ✅ | ✅ | Server-picked random movies and episodes, gated by `/api/v2/shuffles/capabilities`. Picks play from the start; the up-next screen offers Pick Another and Stop shuffling. TV has no mixed-library screen, so mixed libraries shuffle on phone only. [docs](docs/shuffle-api-v2.md) | | Chapters | ✅ | ✅ | Server-extracted; TV scrubber markers | | Sleep timer | ✅ | ✅ | Configurable default | | Playback speed | ✅ | ✅ | | @@ -46,15 +49,18 @@ File pointers are repository-relative. | Landscape-on-play (auto-rotate aware) | 🚧 | ➖ | Implemented then reverted; pending re-apply | | Picture-in-Picture | ✅ | ✅ | `PrairiePictureInPictureCoordinator`; enters on home-press during playback | -## Watch Together (not exposed) +## Watch Party (experimental) + +Shown only while Settings → Experimental → Watch Party is on (default on in debug builds, off in release builds) and the server advertises Watch Party. | Feature | Phone | TV | Notes | |---|:---:|:---:|---| -| Create / join / leave room | 🚧 | 🚧 | Code/design artifacts exist, but users cannot access this flow | -| Clock sync (NTP-style) + drift correction | 🚧 | 🚧 | Shared infrastructure exists but is not a live feature | -| Host vs guest transport gating | 🚧 | 🚧 | Not reachable from production navigation | -| Room snapshots / member list / suggestions / voting | 🚧 | 🚧 | Not reachable from production navigation | -| Graceful reconnect + host-closed auto-exit | 🚧 | 🚧 | Not reachable from production navigation | +| Hub: host, join by code, Return to Party, Rejoin | 🧪 | 🧪 | Profile menu; detail page party action | +| Lobby: staged title, Host Picks or voting, lobby Ready, suggestions | 🧪 | 🧪 | Only the room's phase opens the player | +| Invitations | 🧪 | 🧪 | Phone: share/copy link, `prairie://watch-party`; TV: code and QR | +| Synced playback (server clock, rate convergence, catch-up) | 🧪 | 🧪 | Shared `RoomPlaybackBinding` | +| Party panel on Back: Leave, Return everyone to lobby, End | 🧪 | 🧪 | Host-only actions per role | +| Reconnect, socket rotation, same-profile replacement | 🧪 | 🧪 | Room socket owned by `RoomSession` | ## Offline & Downloads @@ -82,7 +88,7 @@ File pointers are repository-relative. | Release calendar | ✅ | ✅ | Top-level mobile tab and TV top-menu tab | | Live home refresh (events websocket) | ✅ | ✅ | `HomeRealtimeCoordinator`: user_state/catalog channels, 2s debounce; TV also refreshes on resume | | System "Watch Next" row integration | ➖ | ✅ | `WatchNextRepository` (tvprovider) | -| Requests | ✅ | ✅ | Server-gated by `requests_enabled`; profile menu + search entry points | +| Requests | ✅ | ✅ | Server-gated by `requests_enabled`; phone: profile menu + search; TV: top-bar tab + search; admin approve/decline/retry | ## Reading (ebooks) @@ -123,12 +129,13 @@ File pointers are repository-relative. |---|:---:|:---:|---| | Household profiles (multiple per account) | ✅ | ✅ | `ProfileRepository` | | PIN-protected & child profiles, content-rating limits | ✅ | ✅ | | +| Profile Selection at launch (Automatic, Every Time, After 1 Hour, After 12 Hours) | ✅ | ✅ | Phone: Settings → Preferences; TV: Settings → General. Device-local; shared rules in `android-shared/.../profiles/`. TV also hides Watch Next | | Per-profile language / subtitle / playback prefs | ✅ | ✅ | | | Library access restrictions per profile | ✅ | ✅ | | | Favorites & watchlist | ✅ | ✅ | TV: from Settings | | Ratings | ✅ | ✅ | | | Watch history | ✅ | ✅ | | -| Content requests (browse/search TMDB, status tracking) | 🚧 | 🚧 | Not currently accessible in either Android app | +| Content requests (browse/search TMDB, status tracking) | ✅ | ✅ | Four-step status track, My Requests, admin approvals; see Requests above | | Release calendar | ✅ | ✅ | | | Notifications inbox (paginated, realtime updates, mark-read) | ✅ | ✅ | REST + WebSocket | @@ -153,6 +160,6 @@ File pointers are repository-relative. **TV** is a 10-foot, D-pad client focused on browsing and playback, including audiobooks, calendar, the subtitle suite, person detail, and system Watch Next integration. It intentionally omits ebooks/reading and downloads management. -**Not currently exposed on either Android surface:** Admin (including the STATS dashboard and users/sessions/logs/scans) and Watch Together. +**Not currently exposed on either Android surface:** the admin stats dashboard and full admin management (users/sessions/logs/scans). **Experimental:** Watch Party, behind Settings → Experimental. Request moderation (approve, decline, retry) is the one admin action the Requests screens offer. Both apps share the same networking, auth, repositories, most ViewModels, and the entire Media3 playback/capability stack. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index d01e6f0530..3ccae6b0af 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -18,6 +18,14 @@ Prairie uses [`ass-media` 0.5.1](https://github.com/peerless2012/libass-android) The Dolby Vision bridge AAR is built from the `dolby_vision` crate in [`quietvoid/dovi_tool` 2.3.1](https://github.com/quietvoid/dovi_tool/tree/b25558062e4a56973482ec70133bd7b891320e48/dolby_vision), distributed under the MIT license. The upstream source commit, archive and lockfiles, the OSV-clean build lock, toolchains, native outputs, and packaged AAR are pinned in `android-shared/src/native/dovi/provenance.json`. The build lock records remediations for RUSTSEC-2026-0190, RUSTSEC-2026-0105, and RUSTSEC-2026-0204. +## TMDB Logo + +`android-shared/src/androidMain/res/drawable/tmdb_logo.xml` is TMDB's approved "alt short" logo from , converted to a VectorDrawable without changing its shape or colours. It is a trademark of TMDB and is not covered by this repository's license. Prairie shows it next to TMDB scores; keep it unmodified and less prominent than Prairie's own branding. + +## Fonts + +The phone and TV apps bundle [Inter](https://github.com/rsms/inter) (`res/font/inter_*`, including Inter Tight) and [Outfit](https://github.com/Outfitio/Outfit-Fonts) (`res/font/outfit_*.ttf`). Both are distributed under the SIL Open Font License 1.1. + ## Gradle Wrapper The Gradle wrapper scripts retain their upstream Apache-2.0 license. diff --git a/android-shared/build.gradle.kts b/android-shared/build.gradle.kts index 2308b78320..068d7e0ec3 100644 --- a/android-shared/build.gradle.kts +++ b/android-shared/build.gradle.kts @@ -33,6 +33,8 @@ kotlin { implementation(compose.ui) implementation(compose.foundation) implementation(compose.runtime) + // DefaultArtwork's marks; both apps already ship this library. + implementation(compose.materialIconsExtended) // Image loading implementation(libs.coil.compose) diff --git a/android-shared/gradle.lockfile b/android-shared/gradle.lockfile index bbc9558157..74f4b16a8f 100644 --- a/android-shared/gradle.lockfile +++ b/android-shared/gradle.lockfile @@ -24,6 +24,10 @@ androidx.compose.foundation:foundation-android:1.7.6=androidDebugAndroidTestComp androidx.compose.foundation:foundation-layout-android:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.compose.foundation:foundation-layout:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.compose.foundation:foundation:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.material:material-icons-core-android:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.material:material-icons-core:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.material:material-icons-extended-android:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.material:material-icons-extended:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.compose.runtime:runtime-android:1.9.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.compose.runtime:runtime-annotation-android:1.9.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.compose.runtime:runtime-annotation:1.9.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath @@ -384,6 +388,8 @@ org.jetbrains.compose.annotation-internal:annotation:1.8.0=androidDebugAndroidTe org.jetbrains.compose.collection-internal:collection:1.7.3=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath org.jetbrains.compose.foundation:foundation-layout:1.7.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath org.jetbrains.compose.foundation:foundation:1.7.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.compose.material:material-icons-core:1.7.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.compose.material:material-icons-extended:1.7.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath org.jetbrains.compose.runtime:runtime-saveable:1.7.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath org.jetbrains.compose.runtime:runtime:1.7.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath org.jetbrains.compose.ui:ui-geometry:1.7.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath diff --git a/android-shared/schemas/org.prairieserver.prairie.common.data.db.PrairieDatabase/14.json b/android-shared/schemas/org.prairieserver.prairie.common.data.db.PrairieDatabase/14.json new file mode 100644 index 0000000000..db0539046e --- /dev/null +++ b/android-shared/schemas/org.prairieserver.prairie.common.data.db.PrairieDatabase/14.json @@ -0,0 +1,1004 @@ +{ + "formatVersion": 1, + "database": { + "version": 14, + "identityHash": "96fd5a7fa0b67f01e9dd5635ed13546a", + "entities": [ + { + "tableName": "user_item_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `fileId` INTEGER NOT NULL, `positionSeconds` REAL NOT NULL, `durationSeconds` REAL, `audioFingerprint` TEXT, `subtitleFingerprint` TEXT, `cfi` TEXT, `readProgress` REAL, `clientUpdatedAtMs` INTEGER NOT NULL, `serverUpdatedAtMs` INTEGER, PRIMARY KEY(`serverId`, `profileId`, `contentId`, `fileId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileId", + "columnName": "fileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "positionSeconds", + "columnName": "positionSeconds", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "REAL" + }, + { + "fieldPath": "audioFingerprint", + "columnName": "audioFingerprint", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitleFingerprint", + "columnName": "subtitleFingerprint", + "affinity": "TEXT" + }, + { + "fieldPath": "cfi", + "columnName": "cfi", + "affinity": "TEXT" + }, + { + "fieldPath": "readProgress", + "columnName": "readProgress", + "affinity": "REAL" + }, + { + "fieldPath": "clientUpdatedAtMs", + "columnName": "clientUpdatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverUpdatedAtMs", + "columnName": "serverUpdatedAtMs", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "contentId", + "fileId" + ] + }, + "indices": [ + { + "name": "index_user_item_state_serverId_profileId_contentId", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "contentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_user_item_state_serverId_profileId_contentId` ON `${TABLE_NAME}` (`serverId`, `profileId`, `contentId`)" + }, + { + "name": "index_user_item_state_serverId_profileId_clientUpdatedAtMs", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "clientUpdatedAtMs" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_user_item_state_serverId_profileId_clientUpdatedAtMs` ON `${TABLE_NAME}` (`serverId`, `profileId`, `clientUpdatedAtMs`)" + } + ] + }, + { + "tableName": "content_item_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `watched` INTEGER, `ratingValue` INTEGER, `favorite` INTEGER, `clientUpdatedAtMs` INTEGER NOT NULL, `serverUpdatedAtMs` INTEGER, PRIMARY KEY(`serverId`, `profileId`, `contentId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "watched", + "columnName": "watched", + "affinity": "INTEGER" + }, + { + "fieldPath": "ratingValue", + "columnName": "ratingValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER" + }, + { + "fieldPath": "clientUpdatedAtMs", + "columnName": "clientUpdatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverUpdatedAtMs", + "columnName": "serverUpdatedAtMs", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "contentId" + ] + } + }, + { + "tableName": "dirty_operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `opKind` TEXT NOT NULL, `serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `targetContentId` TEXT NOT NULL, `targetFileId` INTEGER, `coalesceKey` TEXT NOT NULL, `idempotencyKey` TEXT NOT NULL, `opVersion` INTEGER NOT NULL, `payloadJson` TEXT NOT NULL, `state` TEXT NOT NULL, `createdAtMs` INTEGER NOT NULL, `attemptCount` INTEGER NOT NULL, `lastAttemptAtMs` INTEGER, `nextAttemptAtMs` INTEGER NOT NULL, `lastError` TEXT, `membershipAuthority` TEXT, `membershipClaim` TEXT, `membershipOwner` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "opKind", + "columnName": "opKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetContentId", + "columnName": "targetContentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetFileId", + "columnName": "targetFileId", + "affinity": "INTEGER" + }, + { + "fieldPath": "coalesceKey", + "columnName": "coalesceKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "idempotencyKey", + "columnName": "idempotencyKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "opVersion", + "columnName": "opVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payloadJson", + "columnName": "payloadJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtMs", + "columnName": "createdAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attemptCount", + "columnName": "attemptCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAttemptAtMs", + "columnName": "lastAttemptAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "nextAttemptAtMs", + "columnName": "nextAttemptAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + }, + { + "fieldPath": "membershipAuthority", + "columnName": "membershipAuthority", + "affinity": "TEXT" + }, + { + "fieldPath": "membershipClaim", + "columnName": "membershipClaim", + "affinity": "TEXT" + }, + { + "fieldPath": "membershipOwner", + "columnName": "membershipOwner", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_dirty_operations_idempotencyKey", + "unique": true, + "columnNames": [ + "idempotencyKey" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_dirty_operations_idempotencyKey` ON `${TABLE_NAME}` (`idempotencyKey`)" + }, + { + "name": "index_dirty_operations_coalesceKey", + "unique": false, + "columnNames": [ + "coalesceKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dirty_operations_coalesceKey` ON `${TABLE_NAME}` (`coalesceKey`)" + }, + { + "name": "index_dirty_operations_nextAttemptAtMs_id", + "unique": false, + "columnNames": [ + "nextAttemptAtMs", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dirty_operations_nextAttemptAtMs_id` ON `${TABLE_NAME}` (`nextAttemptAtMs`, `id`)" + } + ] + }, + { + "tableName": "downloads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `mediaFileId` INTEGER NOT NULL, `recordId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `title` TEXT NOT NULL, `subtitle` TEXT, `posterUrl` TEXT, `posterThumbhash` TEXT, `year` INTEGER, `seriesTitle` TEXT, `seriesContentId` TEXT, `seasonNumber` INTEGER, `episodeNumber` INTEGER, `fileName` TEXT, `container` TEXT, `localUri` TEXT, `mediaType` TEXT NOT NULL, `overview` TEXT, `author` TEXT, `narrator` TEXT, `durationSeconds` REAL, `chaptersJson` TEXT, `status` TEXT NOT NULL, `kind` TEXT NOT NULL, `fileSize` INTEGER NOT NULL, `bytesSent` INTEGER NOT NULL, `createdAt` TEXT NOT NULL, `completedAt` TEXT, `updatedAtMs` INTEGER NOT NULL, `resumeValidator` TEXT, `quality` TEXT, `effectiveQuality` TEXT, `revision` INTEGER, `offlineTracksJson` TEXT, `offlinePosterPath` TEXT, `offlineSeriesPosterPath` TEXT, `seriesPosterThumbhash` TEXT, PRIMARY KEY(`serverId`, `profileId`, `mediaFileId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaFileId", + "columnName": "mediaFileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recordId", + "columnName": "recordId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subtitle", + "columnName": "subtitle", + "affinity": "TEXT" + }, + { + "fieldPath": "posterUrl", + "columnName": "posterUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "posterThumbhash", + "columnName": "posterThumbhash", + "affinity": "TEXT" + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER" + }, + { + "fieldPath": "seriesTitle", + "columnName": "seriesTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "seriesContentId", + "columnName": "seriesContentId", + "affinity": "TEXT" + }, + { + "fieldPath": "seasonNumber", + "columnName": "seasonNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "episodeNumber", + "columnName": "episodeNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileName", + "columnName": "fileName", + "affinity": "TEXT" + }, + { + "fieldPath": "container", + "columnName": "container", + "affinity": "TEXT" + }, + { + "fieldPath": "localUri", + "columnName": "localUri", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaType", + "columnName": "mediaType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "overview", + "columnName": "overview", + "affinity": "TEXT" + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT" + }, + { + "fieldPath": "narrator", + "columnName": "narrator", + "affinity": "TEXT" + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "REAL" + }, + { + "fieldPath": "chaptersJson", + "columnName": "chaptersJson", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileSize", + "columnName": "fileSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bytesSent", + "columnName": "bytesSent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "completedAt", + "columnName": "completedAt", + "affinity": "TEXT" + }, + { + "fieldPath": "updatedAtMs", + "columnName": "updatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resumeValidator", + "columnName": "resumeValidator", + "affinity": "TEXT" + }, + { + "fieldPath": "quality", + "columnName": "quality", + "affinity": "TEXT" + }, + { + "fieldPath": "effectiveQuality", + "columnName": "effectiveQuality", + "affinity": "TEXT" + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER" + }, + { + "fieldPath": "offlineTracksJson", + "columnName": "offlineTracksJson", + "affinity": "TEXT" + }, + { + "fieldPath": "offlinePosterPath", + "columnName": "offlinePosterPath", + "affinity": "TEXT" + }, + { + "fieldPath": "offlineSeriesPosterPath", + "columnName": "offlineSeriesPosterPath", + "affinity": "TEXT" + }, + { + "fieldPath": "seriesPosterThumbhash", + "columnName": "seriesPosterThumbhash", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "mediaFileId" + ] + }, + "indices": [ + { + "name": "index_downloads_serverId_profileId_status", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_downloads_serverId_profileId_status` ON `${TABLE_NAME}` (`serverId`, `profileId`, `status`)" + }, + { + "name": "index_downloads_serverId_profileId_contentId", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "contentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_downloads_serverId_profileId_contentId` ON `${TABLE_NAME}` (`serverId`, `profileId`, `contentId`)" + }, + { + "name": "index_downloads_recordId", + "unique": true, + "columnNames": [ + "recordId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_downloads_recordId` ON `${TABLE_NAME}` (`recordId`)" + } + ] + }, + { + "tableName": "legacy_imports", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sourceKind` TEXT NOT NULL, `sourcePath` TEXT NOT NULL, `sourceHash` TEXT NOT NULL, `sourceMtimeMs` INTEGER NOT NULL, `importedAtMs` INTEGER NOT NULL, `importVersion` INTEGER NOT NULL, `status` TEXT NOT NULL, `error` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceKind", + "columnName": "sourceKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourcePath", + "columnName": "sourcePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceHash", + "columnName": "sourceHash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceMtimeMs", + "columnName": "sourceMtimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "importedAtMs", + "columnName": "importedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "importVersion", + "columnName": "importVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_legacy_imports_sourceKind_sourcePath", + "unique": true, + "columnNames": [ + "sourceKind", + "sourcePath" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_legacy_imports_sourceKind_sourcePath` ON `${TABLE_NAME}` (`sourceKind`, `sourcePath`)" + } + ] + }, + { + "tableName": "home_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `sectionsJson` TEXT NOT NULL, `cachedAtMs` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `profileId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sectionsJson", + "columnName": "sectionsJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cachedAtMs", + "columnName": "cachedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId" + ] + } + }, + { + "tableName": "catalog_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `cacheKey` TEXT NOT NULL, `json` TEXT NOT NULL, `cachedAtMs` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `profileId`, `cacheKey`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cacheKey", + "columnName": "cacheKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cachedAtMs", + "columnName": "cachedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "cacheKey" + ] + } + }, + { + "tableName": "download_deletions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `recordId` TEXT NOT NULL, `mediaFileId` INTEGER, `enqueuedAtMs` INTEGER NOT NULL, `loginId` TEXT, `origin` TEXT, `deviceId` TEXT, PRIMARY KEY(`serverId`, `profileId`, `recordId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recordId", + "columnName": "recordId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaFileId", + "columnName": "mediaFileId", + "affinity": "INTEGER" + }, + { + "fieldPath": "enqueuedAtMs", + "columnName": "enqueuedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loginId", + "columnName": "loginId", + "affinity": "TEXT" + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT" + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "recordId" + ] + } + }, + { + "tableName": "download_subscriptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `targetType` TEXT NOT NULL, `targetId` TEXT NOT NULL, `displayTitle` TEXT NOT NULL, `mediaKind` TEXT NOT NULL, `quality` TEXT NOT NULL, `wifiOnly` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `includeExisting` INTEGER NOT NULL, `keepUnwatchedLimit` INTEGER NOT NULL, `deleteWatchedAfterDays` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `lastEvaluatedAt` INTEGER, `lastError` TEXT, PRIMARY KEY(`serverId`, `profileId`, `id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetType", + "columnName": "targetType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetId", + "columnName": "targetId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayTitle", + "columnName": "displayTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaKind", + "columnName": "mediaKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quality", + "columnName": "quality", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifiOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "includeExisting", + "columnName": "includeExisting", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepUnwatchedLimit", + "columnName": "keepUnwatchedLimit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleteWatchedAfterDays", + "columnName": "deleteWatchedAfterDays", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastEvaluatedAt", + "columnName": "lastEvaluatedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "id" + ] + }, + "indices": [ + { + "name": "index_download_subscriptions_serverId_profileId_enabled", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "enabled" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_download_subscriptions_serverId_profileId_enabled` ON `${TABLE_NAME}` (`serverId`, `profileId`, `enabled`)" + }, + { + "name": "index_download_subscriptions_serverId_profileId_targetType_targetId", + "unique": true, + "columnNames": [ + "serverId", + "profileId", + "targetType", + "targetId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_download_subscriptions_serverId_profileId_targetType_targetId` ON `${TABLE_NAME}` (`serverId`, `profileId`, `targetType`, `targetId`)" + } + ] + }, + { + "tableName": "membership_projection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authority` TEXT NOT NULL, `itemId` TEXT NOT NULL, `kind` TEXT NOT NULL, `commandId` INTEGER NOT NULL, `present` INTEGER NOT NULL, `disposition` TEXT, PRIMARY KEY(`authority`, `itemId`, `kind`))", + "fields": [ + { + "fieldPath": "authority", + "columnName": "authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "commandId", + "columnName": "commandId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "present", + "columnName": "present", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "authority", + "itemId", + "kind" + ] + } + }, + { + "tableName": "legacy_membership_quarantine", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`commandId` INTEGER NOT NULL, `originalState` TEXT NOT NULL, PRIMARY KEY(`commandId`))", + "fields": [ + { + "fieldPath": "commandId", + "columnName": "commandId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originalState", + "columnName": "originalState", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "commandId" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '96fd5a7fa0b67f01e9dd5635ed13546a')" + ] + } +} \ No newline at end of file diff --git a/android-shared/schemas/org.prairieserver.prairie.common.data.db.PrairieDatabase/15.json b/android-shared/schemas/org.prairieserver.prairie.common.data.db.PrairieDatabase/15.json new file mode 100644 index 0000000000..c429039caf --- /dev/null +++ b/android-shared/schemas/org.prairieserver.prairie.common.data.db.PrairieDatabase/15.json @@ -0,0 +1,1024 @@ +{ + "formatVersion": 1, + "database": { + "version": 15, + "identityHash": "88ff4db6ea0cfe226006354b1474462c", + "entities": [ + { + "tableName": "user_item_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `fileId` INTEGER NOT NULL, `positionSeconds` REAL NOT NULL, `durationSeconds` REAL, `audioFingerprint` TEXT, `subtitleFingerprint` TEXT, `cfi` TEXT, `readProgress` REAL, `clientUpdatedAtMs` INTEGER NOT NULL, `serverUpdatedAtMs` INTEGER, `positionUpdatedAtMs` INTEGER, PRIMARY KEY(`serverId`, `profileId`, `contentId`, `fileId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileId", + "columnName": "fileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "positionSeconds", + "columnName": "positionSeconds", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "REAL" + }, + { + "fieldPath": "audioFingerprint", + "columnName": "audioFingerprint", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitleFingerprint", + "columnName": "subtitleFingerprint", + "affinity": "TEXT" + }, + { + "fieldPath": "cfi", + "columnName": "cfi", + "affinity": "TEXT" + }, + { + "fieldPath": "readProgress", + "columnName": "readProgress", + "affinity": "REAL" + }, + { + "fieldPath": "clientUpdatedAtMs", + "columnName": "clientUpdatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverUpdatedAtMs", + "columnName": "serverUpdatedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "positionUpdatedAtMs", + "columnName": "positionUpdatedAtMs", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "contentId", + "fileId" + ] + }, + "indices": [ + { + "name": "index_user_item_state_serverId_profileId_contentId", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "contentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_user_item_state_serverId_profileId_contentId` ON `${TABLE_NAME}` (`serverId`, `profileId`, `contentId`)" + }, + { + "name": "index_user_item_state_serverId_profileId_clientUpdatedAtMs", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "clientUpdatedAtMs" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_user_item_state_serverId_profileId_clientUpdatedAtMs` ON `${TABLE_NAME}` (`serverId`, `profileId`, `clientUpdatedAtMs`)" + } + ] + }, + { + "tableName": "content_item_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `watched` INTEGER, `ratingValue` INTEGER, `favorite` INTEGER, `clientUpdatedAtMs` INTEGER NOT NULL, `serverUpdatedAtMs` INTEGER, `watchedUpdatedAtMs` INTEGER, PRIMARY KEY(`serverId`, `profileId`, `contentId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "watched", + "columnName": "watched", + "affinity": "INTEGER" + }, + { + "fieldPath": "ratingValue", + "columnName": "ratingValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER" + }, + { + "fieldPath": "clientUpdatedAtMs", + "columnName": "clientUpdatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverUpdatedAtMs", + "columnName": "serverUpdatedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "watchedUpdatedAtMs", + "columnName": "watchedUpdatedAtMs", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "contentId" + ] + } + }, + { + "tableName": "dirty_operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `opKind` TEXT NOT NULL, `serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `targetContentId` TEXT NOT NULL, `targetFileId` INTEGER, `coalesceKey` TEXT NOT NULL, `idempotencyKey` TEXT NOT NULL, `opVersion` INTEGER NOT NULL, `payloadJson` TEXT NOT NULL, `state` TEXT NOT NULL, `createdAtMs` INTEGER NOT NULL, `attemptCount` INTEGER NOT NULL, `lastAttemptAtMs` INTEGER, `nextAttemptAtMs` INTEGER NOT NULL, `lastError` TEXT, `membershipAuthority` TEXT, `membershipClaim` TEXT, `membershipOwner` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "opKind", + "columnName": "opKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetContentId", + "columnName": "targetContentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetFileId", + "columnName": "targetFileId", + "affinity": "INTEGER" + }, + { + "fieldPath": "coalesceKey", + "columnName": "coalesceKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "idempotencyKey", + "columnName": "idempotencyKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "opVersion", + "columnName": "opVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payloadJson", + "columnName": "payloadJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtMs", + "columnName": "createdAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attemptCount", + "columnName": "attemptCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAttemptAtMs", + "columnName": "lastAttemptAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "nextAttemptAtMs", + "columnName": "nextAttemptAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + }, + { + "fieldPath": "membershipAuthority", + "columnName": "membershipAuthority", + "affinity": "TEXT" + }, + { + "fieldPath": "membershipClaim", + "columnName": "membershipClaim", + "affinity": "TEXT" + }, + { + "fieldPath": "membershipOwner", + "columnName": "membershipOwner", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_dirty_operations_idempotencyKey", + "unique": true, + "columnNames": [ + "idempotencyKey" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_dirty_operations_idempotencyKey` ON `${TABLE_NAME}` (`idempotencyKey`)" + }, + { + "name": "index_dirty_operations_coalesceKey", + "unique": false, + "columnNames": [ + "coalesceKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dirty_operations_coalesceKey` ON `${TABLE_NAME}` (`coalesceKey`)" + }, + { + "name": "index_dirty_operations_nextAttemptAtMs_id", + "unique": false, + "columnNames": [ + "nextAttemptAtMs", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dirty_operations_nextAttemptAtMs_id` ON `${TABLE_NAME}` (`nextAttemptAtMs`, `id`)" + } + ] + }, + { + "tableName": "downloads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `mediaFileId` INTEGER NOT NULL, `recordId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `title` TEXT NOT NULL, `subtitle` TEXT, `posterUrl` TEXT, `posterThumbhash` TEXT, `year` INTEGER, `seriesTitle` TEXT, `seriesContentId` TEXT, `seasonNumber` INTEGER, `episodeNumber` INTEGER, `fileName` TEXT, `container` TEXT, `localUri` TEXT, `mediaType` TEXT NOT NULL, `overview` TEXT, `author` TEXT, `narrator` TEXT, `durationSeconds` REAL, `chaptersJson` TEXT, `status` TEXT NOT NULL, `kind` TEXT NOT NULL, `fileSize` INTEGER NOT NULL, `bytesSent` INTEGER NOT NULL, `createdAt` TEXT NOT NULL, `completedAt` TEXT, `updatedAtMs` INTEGER NOT NULL, `resumeValidator` TEXT, `quality` TEXT, `effectiveQuality` TEXT, `revision` INTEGER, `offlineTracksJson` TEXT, `offlinePosterPath` TEXT, `offlineSeriesPosterPath` TEXT, `seriesPosterThumbhash` TEXT, `posterIsEpisodeStill` INTEGER, `episodeUserDataJson` TEXT, PRIMARY KEY(`serverId`, `profileId`, `mediaFileId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaFileId", + "columnName": "mediaFileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recordId", + "columnName": "recordId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subtitle", + "columnName": "subtitle", + "affinity": "TEXT" + }, + { + "fieldPath": "posterUrl", + "columnName": "posterUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "posterThumbhash", + "columnName": "posterThumbhash", + "affinity": "TEXT" + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER" + }, + { + "fieldPath": "seriesTitle", + "columnName": "seriesTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "seriesContentId", + "columnName": "seriesContentId", + "affinity": "TEXT" + }, + { + "fieldPath": "seasonNumber", + "columnName": "seasonNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "episodeNumber", + "columnName": "episodeNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileName", + "columnName": "fileName", + "affinity": "TEXT" + }, + { + "fieldPath": "container", + "columnName": "container", + "affinity": "TEXT" + }, + { + "fieldPath": "localUri", + "columnName": "localUri", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaType", + "columnName": "mediaType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "overview", + "columnName": "overview", + "affinity": "TEXT" + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT" + }, + { + "fieldPath": "narrator", + "columnName": "narrator", + "affinity": "TEXT" + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "REAL" + }, + { + "fieldPath": "chaptersJson", + "columnName": "chaptersJson", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileSize", + "columnName": "fileSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bytesSent", + "columnName": "bytesSent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "completedAt", + "columnName": "completedAt", + "affinity": "TEXT" + }, + { + "fieldPath": "updatedAtMs", + "columnName": "updatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resumeValidator", + "columnName": "resumeValidator", + "affinity": "TEXT" + }, + { + "fieldPath": "quality", + "columnName": "quality", + "affinity": "TEXT" + }, + { + "fieldPath": "effectiveQuality", + "columnName": "effectiveQuality", + "affinity": "TEXT" + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER" + }, + { + "fieldPath": "offlineTracksJson", + "columnName": "offlineTracksJson", + "affinity": "TEXT" + }, + { + "fieldPath": "offlinePosterPath", + "columnName": "offlinePosterPath", + "affinity": "TEXT" + }, + { + "fieldPath": "offlineSeriesPosterPath", + "columnName": "offlineSeriesPosterPath", + "affinity": "TEXT" + }, + { + "fieldPath": "seriesPosterThumbhash", + "columnName": "seriesPosterThumbhash", + "affinity": "TEXT" + }, + { + "fieldPath": "posterIsEpisodeStill", + "columnName": "posterIsEpisodeStill", + "affinity": "INTEGER" + }, + { + "fieldPath": "episodeUserDataJson", + "columnName": "episodeUserDataJson", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "mediaFileId" + ] + }, + "indices": [ + { + "name": "index_downloads_serverId_profileId_status", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_downloads_serverId_profileId_status` ON `${TABLE_NAME}` (`serverId`, `profileId`, `status`)" + }, + { + "name": "index_downloads_serverId_profileId_contentId", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "contentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_downloads_serverId_profileId_contentId` ON `${TABLE_NAME}` (`serverId`, `profileId`, `contentId`)" + }, + { + "name": "index_downloads_recordId", + "unique": true, + "columnNames": [ + "recordId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_downloads_recordId` ON `${TABLE_NAME}` (`recordId`)" + } + ] + }, + { + "tableName": "legacy_imports", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sourceKind` TEXT NOT NULL, `sourcePath` TEXT NOT NULL, `sourceHash` TEXT NOT NULL, `sourceMtimeMs` INTEGER NOT NULL, `importedAtMs` INTEGER NOT NULL, `importVersion` INTEGER NOT NULL, `status` TEXT NOT NULL, `error` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceKind", + "columnName": "sourceKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourcePath", + "columnName": "sourcePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceHash", + "columnName": "sourceHash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceMtimeMs", + "columnName": "sourceMtimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "importedAtMs", + "columnName": "importedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "importVersion", + "columnName": "importVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_legacy_imports_sourceKind_sourcePath", + "unique": true, + "columnNames": [ + "sourceKind", + "sourcePath" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_legacy_imports_sourceKind_sourcePath` ON `${TABLE_NAME}` (`sourceKind`, `sourcePath`)" + } + ] + }, + { + "tableName": "home_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `sectionsJson` TEXT NOT NULL, `cachedAtMs` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `profileId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sectionsJson", + "columnName": "sectionsJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cachedAtMs", + "columnName": "cachedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId" + ] + } + }, + { + "tableName": "catalog_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `cacheKey` TEXT NOT NULL, `json` TEXT NOT NULL, `cachedAtMs` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `profileId`, `cacheKey`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cacheKey", + "columnName": "cacheKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cachedAtMs", + "columnName": "cachedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "cacheKey" + ] + } + }, + { + "tableName": "download_deletions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `recordId` TEXT NOT NULL, `mediaFileId` INTEGER, `enqueuedAtMs` INTEGER NOT NULL, `loginId` TEXT, `origin` TEXT, `deviceId` TEXT, PRIMARY KEY(`serverId`, `profileId`, `recordId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recordId", + "columnName": "recordId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaFileId", + "columnName": "mediaFileId", + "affinity": "INTEGER" + }, + { + "fieldPath": "enqueuedAtMs", + "columnName": "enqueuedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loginId", + "columnName": "loginId", + "affinity": "TEXT" + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT" + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "recordId" + ] + } + }, + { + "tableName": "download_subscriptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `targetType` TEXT NOT NULL, `targetId` TEXT NOT NULL, `displayTitle` TEXT NOT NULL, `mediaKind` TEXT NOT NULL, `quality` TEXT NOT NULL, `wifiOnly` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `includeExisting` INTEGER NOT NULL, `keepUnwatchedLimit` INTEGER NOT NULL, `deleteWatchedAfterDays` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `lastEvaluatedAt` INTEGER, `lastError` TEXT, PRIMARY KEY(`serverId`, `profileId`, `id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetType", + "columnName": "targetType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetId", + "columnName": "targetId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayTitle", + "columnName": "displayTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaKind", + "columnName": "mediaKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quality", + "columnName": "quality", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifiOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "includeExisting", + "columnName": "includeExisting", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepUnwatchedLimit", + "columnName": "keepUnwatchedLimit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleteWatchedAfterDays", + "columnName": "deleteWatchedAfterDays", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastEvaluatedAt", + "columnName": "lastEvaluatedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "id" + ] + }, + "indices": [ + { + "name": "index_download_subscriptions_serverId_profileId_enabled", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "enabled" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_download_subscriptions_serverId_profileId_enabled` ON `${TABLE_NAME}` (`serverId`, `profileId`, `enabled`)" + }, + { + "name": "index_download_subscriptions_serverId_profileId_targetType_targetId", + "unique": true, + "columnNames": [ + "serverId", + "profileId", + "targetType", + "targetId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_download_subscriptions_serverId_profileId_targetType_targetId` ON `${TABLE_NAME}` (`serverId`, `profileId`, `targetType`, `targetId`)" + } + ] + }, + { + "tableName": "membership_projection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authority` TEXT NOT NULL, `itemId` TEXT NOT NULL, `kind` TEXT NOT NULL, `commandId` INTEGER NOT NULL, `present` INTEGER NOT NULL, `disposition` TEXT, PRIMARY KEY(`authority`, `itemId`, `kind`))", + "fields": [ + { + "fieldPath": "authority", + "columnName": "authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "commandId", + "columnName": "commandId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "present", + "columnName": "present", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "authority", + "itemId", + "kind" + ] + } + }, + { + "tableName": "legacy_membership_quarantine", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`commandId` INTEGER NOT NULL, `originalState` TEXT NOT NULL, PRIMARY KEY(`commandId`))", + "fields": [ + { + "fieldPath": "commandId", + "columnName": "commandId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originalState", + "columnName": "originalState", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "commandId" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '88ff4db6ea0cfe226006354b1474462c')" + ] + } +} \ No newline at end of file diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/cards/EpisodeSpoilerLocals.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/cards/EpisodeSpoilerLocals.kt new file mode 100644 index 0000000000..4d8169e2f2 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/cards/EpisodeSpoilerLocals.kt @@ -0,0 +1,39 @@ +package org.prairieserver.prairie.common.cards + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.staticCompositionLocalOf +import org.prairieserver.prairie.common.settings.EpisodeSpoilerStore +import org.prairieserver.prairie.model.settings.EpisodeSpoilerPrefs + +/** + * The profile's spoiler protection for unwatched episodes, published once + * near each app shell. Surfaces pair it with + * [org.prairieserver.prairie.model.settings.EpisodeSpoilers.isUnwatched] to decide + * per episode. Outside a [ProvideEpisodeSpoilerPrefs] scope nothing is hidden. + */ +val LocalEpisodeSpoilerPrefs: ProvidableCompositionLocal = + staticCompositionLocalOf { EpisodeSpoilerPrefs.NONE } + +/** + * Hydrates the [store] for [sessionKey] (the active profile id) and publishes + * its resolved prefs via [LocalEpisodeSpoilerPrefs]. Keyed on the session for + * the same reason as [ProvideCardPresentation]: the provider is first + * composed on the unauthenticated Login screen. + */ +@Composable +fun ProvideEpisodeSpoilerPrefs( + store: EpisodeSpoilerStore, + sessionKey: Any? = null, + content: @Composable () -> Unit, +) { + LaunchedEffect(store, sessionKey) { + if (sessionKey != null) store.hydrateIfNeeded() + } + val state by store.state.collectAsState() + CompositionLocalProvider(LocalEpisodeSpoilerPrefs provides state.prefs, content = content) +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/cast/PrairieCastNsdBrowser.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/cast/PrairieCastNsdBrowser.kt index 22f3bde430..ecd39cfe7d 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/cast/PrairieCastNsdBrowser.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/cast/PrairieCastNsdBrowser.kt @@ -170,7 +170,8 @@ class PrairieCastNsdBrowser(context: Context) { val name = attributes.string("name") ?: serviceName val mdnsName = serviceName val deviceId = attributes.string("deviceId") ?: attributes.string("id") ?: "$host:$port" - val version = attributes.string("v")?.toIntOrNull() ?: PrairieCastProtocol.version + // Every v2 receiver advertises `v`; one that doesn't predates it (iOS reads it the same way). + val version = attributes.string("v")?.toIntOrNull() ?: 1 return PrairieCastTarget( serviceName = mdnsName, deviceId = deviceId, diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/PrairieDatabase.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/PrairieDatabase.kt index beac58280f..7a104b1054 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/PrairieDatabase.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/PrairieDatabase.kt @@ -56,7 +56,7 @@ import org.prairieserver.prairie.common.data.db.entity.UserItemStateEntity MembershipProjectionEntity::class, LegacyMembershipQuarantineEntity::class, ], - version = 13, + version = 15, exportSchema = true, autoMigrations = [ AutoMigration(from = 1, to = 2), @@ -70,6 +70,8 @@ import org.prairieserver.prairie.common.data.db.entity.UserItemStateEntity AutoMigration(from = 10, to = 11), AutoMigration(from = 11, to = 12), AutoMigration(from = 12, to = 13), + AutoMigration(from = 13, to = 14), + AutoMigration(from = 14, to = 15), ], ) abstract class PrairieDatabase : RoomDatabase() { diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/dao/CatalogCacheDao.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/dao/CatalogCacheDao.kt index 71ca1b2870..6f556027ac 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/dao/CatalogCacheDao.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/dao/CatalogCacheDao.kt @@ -9,6 +9,12 @@ import org.prairieserver.prairie.common.data.db.entity.CatalogCacheEntity @Dao interface CatalogCacheDao { + @Query(""" + DELETE FROM catalog_cache WHERE serverId = :serverId AND profileId = :profileId + AND (cacheKey LIKE 'item-detail:%' OR cacheKey LIKE 'seasons:%' OR cacheKey LIKE 'episodes:%') + """) + suspend fun deleteDetailMetadata(serverId: String, profileId: String) + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsert(row: CatalogCacheEntity) diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/dao/ContentItemStateDao.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/dao/ContentItemStateDao.kt index 2da671b064..5b85418558 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/dao/ContentItemStateDao.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/dao/ContentItemStateDao.kt @@ -38,7 +38,7 @@ interface ContentItemStateDao { // Revert a single optimistic field to "unknown" after its outbox op is dropped // (terminal server rejection) — the overlay then defers to server state. - @Query("UPDATE content_item_state SET watched = NULL WHERE serverId = :serverId AND profileId = :profileId AND contentId = :contentId") + @Query("UPDATE content_item_state SET watched = NULL, watchedUpdatedAtMs = NULL WHERE serverId = :serverId AND profileId = :profileId AND contentId = :contentId") suspend fun clearWatched(serverId: String, profileId: String, contentId: String) @Query("UPDATE content_item_state SET favorite = NULL WHERE serverId = :serverId AND profileId = :profileId AND contentId = :contentId") diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/dao/DirtyOperationDao.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/dao/DirtyOperationDao.kt index 33e8c45117..f220fc00c8 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/dao/DirtyOperationDao.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/dao/DirtyOperationDao.kt @@ -25,6 +25,16 @@ interface DirtyOperationDao { "AND targetContentId = :contentId AND opKind IN ('SET_WATCHED', 'SET_RATING', 'SET_POSITION', 'PERSONAL_V2')") suspend fun unresolvedPersonalCount(serverId: String, profileId: String, contentId: String): Int + @Query("SELECT contentId FROM content_item_state WHERE serverId = :serverId AND profileId = :profileId " + + "AND contentId IN (:contentIds) AND watchedUpdatedAtMs >= :sinceMs " + + "UNION SELECT contentId FROM user_item_state WHERE serverId = :serverId AND profileId = :profileId " + + "AND contentId IN (:contentIds) AND positionUpdatedAtMs >= :sinceMs " + + "UNION SELECT targetContentId FROM dirty_operations WHERE serverId = :serverId AND profileId = :profileId " + + "AND targetContentId IN (:contentIds) AND opKind IN ('SET_WATCHED', 'SET_POSITION')") + suspend fun contentIdsWithPendingOrNewerUserState( + serverId: String, profileId: String, contentIds: List, sinceMs: Long, + ): List + /** A newer desired-state write supersedes any v2 personal row the previous attempt left behind. */ @Query("DELETE FROM dirty_operations WHERE opKind = 'PERSONAL_V2' AND serverId = :serverId " + "AND profileId = :profileId AND targetContentId = :contentId") diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/dao/DownloadDao.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/dao/DownloadDao.kt index f8a64ea7a0..d888e4684c 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/dao/DownloadDao.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/dao/DownloadDao.kt @@ -4,8 +4,18 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import kotlinx.coroutines.flow.Flow import org.prairieserver.prairie.common.data.db.entity.DownloadEntity +/** The artwork columns of one downloads row, for [DownloadDao.observeSavedArtwork]. */ +data class DownloadArtworkRow( + val recordId: String, + val posterThumbhash: String?, + val offlinePosterPath: String?, + val offlineSeriesPosterPath: String?, + val seriesPosterThumbhash: String?, +) + /** * Downloads projection access. Scoped reads seed the Downloads tab and the * offline player without a server round-trip; status scans drive @@ -69,6 +79,18 @@ interface DownloadDao { @Query("SELECT * FROM downloads ORDER BY updatedAtMs DESC") suspend fun getAllAcrossScopes(): List + /** + * Rows that carry saved artwork, across scopes. Room re-queries on every + * downloads write (progress and status included), so collectors should + * apply `distinctUntilChanged` and react only when artwork actually changes. + */ + @Query( + "SELECT recordId, posterThumbhash, offlinePosterPath, offlineSeriesPosterPath, seriesPosterThumbhash " + + "FROM downloads WHERE posterThumbhash IS NOT NULL OR offlinePosterPath IS NOT NULL " + + "OR offlineSeriesPosterPath IS NOT NULL OR seriesPosterThumbhash IS NOT NULL ORDER BY recordId", + ) + fun observeSavedArtwork(): Flow> + /** First download (any scope) for a content id — offline player contentId→file lookup. */ @Query("SELECT * FROM downloads WHERE contentId = :contentId LIMIT 1") suspend fun findByContentId(contentId: String): DownloadEntity? diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/dao/UserItemStateDao.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/dao/UserItemStateDao.kt index a2e4391e4a..f9d95c829e 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/dao/UserItemStateDao.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/dao/UserItemStateDao.kt @@ -32,7 +32,7 @@ interface UserItemStateDao { /** Clears resume state without discarding per-file playback or reading preferences. */ @Query( - "UPDATE user_item_state SET positionSeconds = 0, clientUpdatedAtMs = :updatedAtMs " + + "UPDATE user_item_state SET positionSeconds = 0, clientUpdatedAtMs = :updatedAtMs, positionUpdatedAtMs = :updatedAtMs " + "WHERE serverId = :serverId AND profileId = :profileId AND contentId = :contentId " + "AND positionSeconds <> 0", ) @@ -43,17 +43,23 @@ interface UserItemStateDao { updatedAtMs: Long, ) - /** A delayed watched acknowledgement must not clear a newer local resume sample. */ - @Query("UPDATE user_item_state SET positionSeconds = 0, clientUpdatedAtMs = :updatedAtMs " + + /** + * A delayed watched acknowledgement must not clear a newer local resume sample. + * Rows written before positionUpdatedAtMs existed fall back to clientUpdatedAtMs. + */ + @Query("UPDATE user_item_state SET positionSeconds = 0, clientUpdatedAtMs = :updatedAtMs, positionUpdatedAtMs = :updatedAtMs " + "WHERE serverId = :serverId AND profileId = :profileId AND contentId = :contentId " + - "AND clientUpdatedAtMs < :admittedAtMs") + "AND COALESCE(positionUpdatedAtMs, clientUpdatedAtMs) < :admittedAtMs") suspend fun clearPlaybackProgressBefore(serverId: String, profileId: String, contentId: String, admittedAtMs: Long, updatedAtMs: Long) - /** Restore a reset resume row only while it is still the untouched reset. */ + /** + * Restore a reset resume row only while it is still the untouched reset. + * A reset made before positionUpdatedAtMs existed is matched by clientUpdatedAtMs. + */ @Query( - "UPDATE user_item_state SET positionSeconds = :positionSeconds, clientUpdatedAtMs = :previousUpdatedAtMs " + + "UPDATE user_item_state SET positionSeconds = :positionSeconds, clientUpdatedAtMs = CASE WHEN clientUpdatedAtMs = :clearedAtMs THEN :previousUpdatedAtMs ELSE clientUpdatedAtMs END, positionUpdatedAtMs = :previousPositionUpdatedAtMs " + "WHERE serverId = :serverId AND profileId = :profileId AND contentId = :contentId " + - "AND fileId = :fileId AND positionSeconds = 0 AND clientUpdatedAtMs = :clearedAtMs", + "AND fileId = :fileId AND positionSeconds = 0 AND COALESCE(positionUpdatedAtMs, clientUpdatedAtMs) = :clearedAtMs", ) suspend fun restorePlaybackProgressIfUnchanged( serverId: String, @@ -62,6 +68,7 @@ interface UserItemStateDao { fileId: Int, positionSeconds: Double, previousUpdatedAtMs: Long, + previousPositionUpdatedAtMs: Long?, clearedAtMs: Long, ) diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/entity/ContentItemStateEntity.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/entity/ContentItemStateEntity.kt index 2d2fd34630..13c6f7ad12 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/entity/ContentItemStateEntity.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/entity/ContentItemStateEntity.kt @@ -30,4 +30,6 @@ data class ContentItemStateEntity( val favorite: Boolean?, val clientUpdatedAtMs: Long, val serverUpdatedAtMs: Long?, + /** Timestamp of the watch/progress mutation, excluding ratings and track choices. */ + val watchedUpdatedAtMs: Long? = null, ) diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/entity/DownloadEntity.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/entity/DownloadEntity.kt index 6086005dae..a55c79a176 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/entity/DownloadEntity.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/entity/DownloadEntity.kt @@ -86,4 +86,13 @@ data class DownloadEntity( * downloaded file's audio tracks and the local subtitle sidecars. Null for * downloads completed before offline track data was captured. */ val offlineTracksJson: String? = null, + /** Local path of the saved item poster (episode image for episodes); null + * for downloads completed before artwork was saved, or when none was. */ + val offlinePosterPath: String? = null, + /** Local path of the saved parent series poster (episode downloads only). */ + val offlineSeriesPosterPath: String? = null, + /** ThumbHash of the parent series poster (episode downloads only). */ + val seriesPosterThumbhash: String? = null, + val posterIsEpisodeStill: Boolean? = null, + val episodeUserDataJson: String? = null, ) diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/entity/UserItemStateEntity.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/entity/UserItemStateEntity.kt index ba34f61c17..6452fb61e5 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/entity/UserItemStateEntity.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/entity/UserItemStateEntity.kt @@ -47,4 +47,6 @@ data class UserItemStateEntity( val readProgress: Double?, val clientUpdatedAtMs: Long, val serverUpdatedAtMs: Long?, + /** Timestamp of the watch/progress mutation, excluding ratings and track choices. */ + val positionUpdatedAtMs: Long? = null, ) diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomCatalogCacheRepository.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomCatalogCacheRepository.kt index 06172079c5..cf3e2c256a 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomCatalogCacheRepository.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomCatalogCacheRepository.kt @@ -1,5 +1,7 @@ package org.prairieserver.prairie.common.data.repository +import org.prairieserver.prairie.repository.port.CatalogCacheScope + import org.prairieserver.prairie.common.data.db.PrairieDatabase import org.prairieserver.prairie.common.data.db.entity.CatalogCacheEntity import org.prairieserver.prairie.model.catalog.CatalogResponse @@ -31,6 +33,15 @@ class RoomCatalogCacheRepository( private val dao = db.catalogCacheDao() private val json = Json { ignoreUnknownKeys = true } + override suspend fun invalidateDetailMetadata(scope: CatalogCacheScope?) { + val owner = scope ?: run { + val snapshot = snapshotProvider() ?: return + val profile = snapshot.profileId ?: return + CatalogCacheScope(snapshot.serverId, profile) + } + dao.deleteDetailMetadata(owner.serverId, owner.profileId) + } + override suspend fun cacheLibraries(libraries: List) = cacheLibraries(libraries, currentWriteLease()) diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomHomeCacheRepository.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomHomeCacheRepository.kt index 50d9bf19a7..c8e16d62ca 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomHomeCacheRepository.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomHomeCacheRepository.kt @@ -10,6 +10,9 @@ import org.prairieserver.prairie.network.IdentityTransitionBarrier import org.prairieserver.prairie.repository.port.HomeCachePort import org.prairieserver.prairie.repository.port.HomeCacheSnapshot import org.prairieserver.prairie.repository.port.HomeCacheWriteLease +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json /** @@ -19,23 +22,42 @@ import kotlinx.serialization.json.Json * Scope comes from the active [AuthScopeSnapshot]; with no active server/profile * there's nothing to cache or serve (returns null). Corrupt/forward-incompatible * JSON decodes to null rather than crashing the home screen. + * + * The blob is encoded and decoded on [jsonDispatcher]: callers read the cache + * from the main thread, and with 100-item Home rows the blob is about 1 MB, + * which takes hundreds of milliseconds to decode on a low-end TV. */ class RoomHomeCacheRepository( private val db: PrairieDatabase, private val snapshotProvider: suspend () -> AuthScopeSnapshot?, private val identityTransitions: IdentityTransitionBarrier = DefaultIdentityTransitionBarrier(), private val now: () -> Long = { System.currentTimeMillis() }, + private val jsonDispatcher: CoroutineDispatcher = Dispatchers.Default, ) : HomeCachePort { private val dao = db.homeCacheDao() private val scopedDao = db.catalogCacheDao() private val json = Json { ignoreUnknownKeys = true } + private suspend fun encode(sections: List): String = + withContext(jsonDispatcher) { json.encodeToString(sections) } + + private suspend fun decode(body: String): List? = withContext(jsonDispatcher) { + // Corrupt or forward-incompatible JSON (a SerializationException, which is an + // IllegalArgumentException) is a cache miss; cancellation still propagates. + try { + json.decodeFromString>(body) + } catch (e: IllegalArgumentException) { + null + } + } + override suspend fun cacheHomeV2(sections: List, owner: AuthScopeSnapshot, stillCurrent: () -> Boolean) { val profile = owner.profileId ?: return + // Encoding suspends, so it runs before the ownership check, not between it and the write. + val body = encode(sections) if (owner != snapshotProvider()) return val key = scopedKey(owner) - val body = json.encodeToString(sections) db.withTransaction { if (!stillCurrent()) return@withTransaction if (body.encodeToByteArray().size > MAX_CACHE_BYTES) scopedDao.delete(owner.serverId, profile, key) @@ -45,10 +67,10 @@ class RoomHomeCacheRepository( override suspend fun cacheHomeV2IfAbsent(sections: List, owner: AuthScopeSnapshot, stillCurrent: () -> Boolean) { val profile = owner.profileId ?: return + val body = encode(sections) + if (body.encodeToByteArray().size > MAX_CACHE_BYTES) return if (owner != snapshotProvider()) return val key = scopedKey(owner) - val body = json.encodeToString(sections) - if (body.encodeToByteArray().size > MAX_CACHE_BYTES) return db.withTransaction { if (!stillCurrent() || scopedDao.get(owner.serverId, profile, key) != null) return@withTransaction scopedDao.upsert(org.prairieserver.prairie.common.data.db.entity.CatalogCacheEntity(owner.serverId, profile, key, body, now())) @@ -58,9 +80,10 @@ class RoomHomeCacheRepository( override suspend fun getCachedHomeV2(owner: AuthScopeSnapshot): HomeCacheSnapshot? { val profile = owner.profileId ?: return null if (owner != snapshotProvider()) return null - val row = runCatching { scopedDao.get(owner.serverId, profile, scopedKey(owner)) }.getOrNull() + val row = runCatching { scopedDao.get(owner.serverId, profile, scopedKey(owner)) }.getOrNull() ?: return null + val sections = decode(row.json) if (owner != snapshotProvider()) return null - return row?.let { runCatching { HomeCacheSnapshot(json.decodeFromString>(it.json), it.cachedAtMs) }.getOrNull() } + return sections?.let { HomeCacheSnapshot(it, row.cachedAtMs) } } private fun scopedKey(owner: AuthScopeSnapshot): String = owner.identityCacheKey("home-sections-v2") @@ -77,10 +100,10 @@ class RoomHomeCacheRepository( lease: HomeCacheWriteLease, ) { if (lease.identityGeneration != identityTransitions.generation.value) return + val sectionsJson = encode(sections) val snapshot = snapshotProvider() ?: return val profileId = snapshot.profileId ?: return if (lease.identityGeneration != identityTransitions.generation.value) return - val sectionsJson = json.encodeToString(sections) // A Room row must fit SQLite's ~2MB CursorWindow or the *read* throws // SQLiteBlobTooBigException. Large reorganized home layouts can exceed // that, so don't cache an unreadable row — drop any prior row (which may @@ -100,6 +123,7 @@ class RoomHomeCacheRepository( } override suspend fun getCachedHome(): HomeCacheSnapshot? { + val identityGeneration = identityTransitions.generation.value val snapshot = snapshotProvider() ?: return null val profileId = snapshot.profileId ?: return null // An oversized legacy row (written before the cap) throws @@ -110,9 +134,9 @@ class RoomHomeCacheRepository( runCatching { dao.delete(snapshot.serverId, profileId) } null } ?: return null - val sections = runCatching { - json.decodeFromString>(row.sectionsJson) - }.getOrNull() ?: return null + val sections = decode(row.sectionsJson) ?: return null + // The read and the decode suspend: serve nothing if the identity moved meanwhile. + if (snapshot != snapshotProvider() || identityGeneration != identityTransitions.generation.value) return null return HomeCacheSnapshot(sections = sections, cachedAtMs = row.cachedAtMs) } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomUserItemStateRepository.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomUserItemStateRepository.kt index fed1f8ff1a..44c6945b98 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomUserItemStateRepository.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomUserItemStateRepository.kt @@ -121,12 +121,13 @@ class RoomUserItemStateRepository( current.watched == saved["previous_watched"]?.jsonPrimitive?.booleanOrNull && current.ratingValue == saved["previous_rating"]?.jsonPrimitive?.intOrNull if (unchanged) { - val previous = current ?: ContentItemStateEntity(row.serverId, row.profileId, row.targetContentId, null, null, null, now(), null) + val nowMs = now() + val previous = current ?: ContentItemStateEntity(row.serverId, row.profileId, row.targetContentId, null, null, null, nowMs, null) val projected = when (val command = handle.command) { - is PersonalWrite.Watched -> previous.copy(watched = command.watched) + is PersonalWrite.Watched -> previous.copy(watched = command.watched, watchedUpdatedAtMs = nowMs) is PersonalWrite.Rating -> previous.copy(ratingValue = command.rating) } - contentDao.upsert(projected.copy(clientUpdatedAtMs = now())) + contentDao.upsert(projected.copy(clientUpdatedAtMs = nowMs)) if (handle.command is PersonalWrite.Watched) userStateDao.clearPlaybackProgressBefore(row.serverId, row.profileId, row.targetContentId, row.createdAtMs, now()) } @@ -246,7 +247,7 @@ class RoomUserItemStateRepository( clientUpdatedAtMs = nowMs, serverUpdatedAtMs = null, ) - userStateDao.upsert(row) + userStateDao.upsert(row.copy(positionUpdatedAtMs = nowMs)) // V2 playback sends sequenced progress under its admitted session. Keep local resume, // but never coalesce a new position into a legacy queue with unknown authority. @@ -293,6 +294,23 @@ class RoomUserItemStateRepository( .latestProgress() } + override suspend fun clearLocalPlaybackProgressBefore( + contentIds: List, + admittedAtMs: Long, + identityGeneration: Long, + ) { + if (contentIds.isEmpty()) return + val snapshot = snapshotProvider() ?: return + if (snapshot.isIdentityGenerationStamped && snapshot.identityGeneration != identityGeneration) return + val profileId = snapshot.profileId ?: return + db.withTransaction { + val clearedAtMs = now() + contentIds.distinct().forEach { contentId -> + userStateDao.clearPlaybackProgressBefore(snapshot.serverId, profileId, contentId, admittedAtMs, clearedAtMs) + } + } + } + override suspend fun localPlaybackProgressForContent(contentIds: List): Map { if (contentIds.isEmpty()) return emptyMap() val snapshot = snapshotProvider() ?: return emptyMap() @@ -534,6 +552,15 @@ class RoomUserItemStateRepository( .associate { it.contentId to LocalContentState(watched = it.watched, favorite = null) } } + override suspend fun contentIdsWithPendingOrNewerUserState(contentIds: List, sinceMs: Long): Set { + val snapshot = snapshotProvider() ?: return emptySet() + val profileId = snapshot.profileId ?: return emptySet() + // The UNION binds the ID list three times; stay below SQLite's older 999-variable limit. + return contentIds.distinct().chunked(MAX_IN_LIST_IDS / 3).flatMap { + outboxDao.contentIdsWithPendingOrNewerUserState(snapshot.serverId, profileId, it, sinceMs) + }.toSet() + } + private suspend fun record( contentId: String, opKind: String, @@ -561,7 +588,8 @@ class RoomUserItemStateRepository( clientUpdatedAtMs = nowMs, serverUpdatedAtMs = null, ) - contentDao.upsert(applyField(existing).copy(clientUpdatedAtMs = nowMs)) + contentDao.upsert(applyField(existing).copy(clientUpdatedAtMs = nowMs, + watchedUpdatedAtMs = if (opKind == OutboxOperation.SET_WATCHED) nowMs else existing.watchedUpdatedAtMs)) var operationPayload = payloadJson if (clearPlaybackProgress) { @@ -581,6 +609,7 @@ class RoomUserItemStateRepository( fileId = row.fileId, positionSeconds = row.positionSeconds, previousClientUpdatedAtMs = row.clientUpdatedAtMs, + previousPositionUpdatedAtMs = row.positionUpdatedAtMs, clearedAtMs = nowMs, ) } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/sync/ContentProjectionRevert.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/sync/ContentProjectionRevert.kt index 0cf4dbf97e..7e398b438c 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/sync/ContentProjectionRevert.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/sync/ContentProjectionRevert.kt @@ -35,6 +35,7 @@ internal suspend fun UserItemStateDao.restorePlaybackProgressForTerminalOp(op: D fileId = progress.fileId, positionSeconds = progress.positionSeconds, previousUpdatedAtMs = progress.previousClientUpdatedAtMs, + previousPositionUpdatedAtMs = progress.previousPositionUpdatedAtMs, clearedAtMs = progress.clearedAtMs, ) } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/sync/OutboxOperation.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/sync/OutboxOperation.kt index 6389492020..4138f37fd5 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/sync/OutboxOperation.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/sync/OutboxOperation.kt @@ -40,6 +40,7 @@ data class OutboxOperation( val positionSeconds: Double, val previousClientUpdatedAtMs: Long, val clearedAtMs: Long, + val previousPositionUpdatedAtMs: Long? = null, ) /** diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/di/PlayerInfraModule.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/di/PlayerInfraModule.kt index 7de324b7ba..06409e7f02 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/di/PlayerInfraModule.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/di/PlayerInfraModule.kt @@ -12,21 +12,28 @@ import org.prairieserver.prairie.common.player.SleepTimerController import org.prairieserver.prairie.common.settings.AndroidPlayerSettingsStore import org.prairieserver.prairie.common.settings.CardPresentationStore import org.prairieserver.prairie.common.settings.DefaultCardPresentationStore +import org.prairieserver.prairie.common.settings.DefaultEpisodeSpoilerStore import org.prairieserver.prairie.common.settings.DefaultLibraryPlaybackPrefsStore import org.prairieserver.prairie.common.settings.DefaultOverlayPrefsStore import org.prairieserver.prairie.common.settings.DefaultSeekIntervalStore import org.prairieserver.prairie.common.settings.DefaultServerSettingsFlusher +import org.prairieserver.prairie.common.settings.EpisodeSpoilerStore import org.prairieserver.prairie.common.settings.LibraryPlaybackPrefsStore import org.prairieserver.prairie.common.settings.OverlayPrefsStore import org.prairieserver.prairie.common.settings.PlayerSettingsStore import org.prairieserver.prairie.common.settings.ServerDrivenConfigRefresher import org.prairieserver.prairie.common.settings.SeekIntervalStore +import org.prairieserver.prairie.common.settings.SettingsContractRevision +import org.prairieserver.prairie.common.settings.DefaultTitleArtStore +import org.prairieserver.prairie.common.settings.TitleArtStore import org.prairieserver.prairie.common.settings.ServerSettingsFlusher import org.prairieserver.prairie.domain.player.IntroAutoSkipController import org.prairieserver.prairie.domain.settings.SeekIntervalController +import org.prairieserver.prairie.domain.settings.TitleArtController import org.prairieserver.prairie.network.DeviceMetadataProvider import org.prairieserver.prairie.network.ServerRegistry import org.prairieserver.prairie.network.TokenManager +import org.prairieserver.prairie.network.api.SettingsApi import org.prairieserver.prairie.repository.LibraryPlaybackPrefsRepository import org.prairieserver.prairie.repository.ProfileRepository import org.prairieserver.prairie.repository.SettingsRepository @@ -89,6 +96,16 @@ val playerInfraModule = module { } } + // The connected server's settings manifest revision. One instance, so the + // flusher's send-time gates and the settings UI agree on what it supports. + single { + val settingsApi = get() + SettingsContractRevision( + fetchCapabilities = { settingsApi.getContractCapabilities() }, + getServerUrl = { get().getServerUrl() }, + ) + } + // Long-lived application-scope flusher: debounced server writes survive // ViewModel teardown. Uses Dispatchers.IO since flushOne does network work. single { @@ -100,6 +117,7 @@ val playerInfraModule = module { // against is still the one requests would reach. getServerUrl = { get().getServerUrl() }, getAuthScope = { get().snapshotCurrentScope() }, + contractRevision = get(), ) } @@ -132,6 +150,7 @@ val playerInfraModule = module { // server so settings scope stays in lockstep with what the // server records as `device_id` for each override. getDeviceId = { get().current()?.id }, + contractRevision = get(), ) } @@ -184,6 +203,45 @@ val playerInfraModule = module { ) } + // Spoiler protection for unwatched episodes (see EpisodeSpoilers.MIN_CONTRACT_REVISION). Both + // keys are profile-scope only, so the store writes the profile row itself + // rather than going through the profile_device flusher. Resets and + // re-resolves on a server or profile change, like SeekIntervalStore: + // Add Server → sign in → pick profile clears nothing on its own. + single { + val identityChanges = get().activeEntry + .map { it?.url to it?.profileId } + .distinctUntilChanged() + .map { Unit } + DefaultEpisodeSpoilerStore( + context = androidContext(), + repository = get(), + scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate), + getActiveProfileId = { get().getActiveProfileId() }, + getServerUrl = { get().getServerUrl() }, + captureAuthority = { get().snapshotCurrentScope() }, + identityChanges = identityChanges, + ) + } + + // "Show title art" (settings revision 16) for this device. Every title + // surface and both settings screens read this one instance, so a change + // reaches an open detail page without a restart. + single { + val registry = get() + val identityChanges = registry.activeEntry + .map { it?.url to it?.profileId } + .distinctUntilChanged() + .map { Unit } + DefaultTitleArtStore( + context = androidContext(), + controller = TitleArtController(get()), + scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate), + getAuthScope = { get().snapshotCurrentScope() }, + identityChanges = identityChanges, + ) + } + single { ServerDrivenConfigRefresher( overlayPrefsStore = get(), @@ -191,6 +249,9 @@ val playerInfraModule = module { libraryPlaybackPrefsStore = get(), playerSettingsStore = get(), seekIntervalStore = get(), + episodeSpoilerStore = get(), + titleArtStore = get(), + hiddenLibrariesStore = get(), hasAuthenticatedProfile = { !get().getActiveProfileId().isNullOrBlank() }, diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/di/WatchPartyModule.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/di/WatchPartyModule.kt new file mode 100644 index 0000000000..af9610557e --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/di/WatchPartyModule.kt @@ -0,0 +1,61 @@ +package org.prairieserver.prairie.common.di + +import android.content.SharedPreferences +import org.prairieserver.prairie.network.DurableLoginAuthorityProvider +import org.prairieserver.prairie.network.TokenManager +import org.prairieserver.prairie.network.api.WatchTogetherApi +import org.prairieserver.prairie.network.apiv2.PlaybackV2Api +import org.prairieserver.prairie.repository.WatchTogetherRepository +import org.prairieserver.prairie.watchtogether.RecentPartyOwner +import org.prairieserver.prairie.watchtogether.RecentWatchParties +import org.prairieserver.prairie.watchtogether.SharedPreferencesRecentWatchPartyStorage +import org.prairieserver.prairie.watchtogether.WatchPartyAvailabilityRepository +import org.prairieserver.prairie.watchtogether.WatchPartyRecentsRecorder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import org.koin.dsl.module + +/** + * Watch Party wiring shared by the phone and TV apps: capability discovery + * and the recent-party store. The room owner itself lives in the shared + * repository module. + */ +val watchPartyModule = module { + single { + val tokens = get() + val playback = get() + val api = get() + WatchPartyAvailabilityRepository( + roomCapabilities = { scope -> api.capabilities(scope) }, + playbackCapabilities = { scope -> playback.capabilities(scope) }, + authScopeProvider = { tokens.snapshotCurrentScope() }, + ) + } + single { + val authorities = get() as? DurableLoginAuthorityProvider + RecentWatchParties( + // The app-level SharedPreferences binding is the encrypted store. + storage = SharedPreferencesRecentWatchPartyStorage(get()), + owner = { + authorities?.snapshotDurableLoginAuthority()?.let { authority -> + val serverId = authority.scope.serverId + val profileId = authority.scope.profileId + if (serverId.isBlank() || profileId.isNullOrBlank()) null + else RecentPartyOwner(serverId, authority.loginId, profileId) + } + }, + ) + } + single(createdAtStart = true) { + val repository = get() + WatchPartyRecentsRecorder( + recents = get(), + room = repository.roomSnapshot, + ended = repository.ended, + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + identityTransitions = get(), + closedByThisDevice = repository.closedByThisDevice, + ) + } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadEnqueuer.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadEnqueuer.kt index ff67efbff7..1e2dc52f30 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadEnqueuer.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadEnqueuer.kt @@ -105,6 +105,7 @@ class DownloadEnqueuer( episodeNumber: Int?, episodeTitle: String?, posterUrl: String? = null, + posterIsEpisodeStill: Boolean? = null, downloadQualityOverride: DownloadQuality? = null, ): ApiResult { val authority = authorities?.snapshotDurableLoginAuthority() @@ -139,6 +140,8 @@ class DownloadEnqueuer( title = seriesTitle, subtitle = listOfNotNull(episodeCode, shownEpisodeTitle).joinToString(" · ").ifEmpty { null }, posterUrl = posterUrl, + posterIsEpisodeStill = posterIsEpisodeStill, + episodeUserData = episodeDetail?.userData, seriesTitle = seriesTitle, seriesContentId = seriesContentId, seasonNumber = seasonNumber, @@ -213,6 +216,8 @@ class DownloadEnqueuer( } ?: "Series episode", posterUrl = posterUrl, posterThumbhash = seriesDetail?.posterThumbhash, + posterIsEpisodeStill = false, + episodeUserData = episodeDetail?.userData, seriesTitle = seriesTitle, seriesContentId = seriesContentId, seasonNumber = ep?.seasonNumber, @@ -274,6 +279,7 @@ class DownloadEnqueuer( episodeNumber = ep.episodeNumber, episodeTitle = ep.title, posterUrl = posterUrl, + posterIsEpisodeStill = false, // Season batch is original-only (server rejects a non-original // batch with 501 bulk_quality_unavailable). Force Original even // though startEpisode itself honors quality for single episodes. @@ -468,6 +474,8 @@ class DownloadEnqueuer( title = detail?.title ?: fallbackTitle, posterUrl = detail?.posterUrl, posterThumbhash = detail?.posterThumbhash, + posterIsEpisodeStill = detail?.posterIsEpisodeStill, + episodeUserData = detail?.userData.takeIf { detail?.type == "episode" }, year = detail?.year?.takeIf { it > 0 }, seriesTitle = detail?.seriesTitle, // Stable TV grouping key — present for episode detail downloads via the diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadMetadataStore.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadMetadataStore.kt index 9db1c5acf1..eb9ad7598c 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadMetadataStore.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadMetadataStore.kt @@ -1,7 +1,10 @@ package org.prairieserver.prairie.common.downloads import androidx.room.withTransaction +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged import org.prairieserver.prairie.common.data.db.PrairieDatabase +import org.prairieserver.prairie.common.data.db.dao.DownloadArtworkRow import org.prairieserver.prairie.model.download.DownloadSidecar /** @@ -27,6 +30,26 @@ class DownloadMetadataStore(private val db: PrairieDatabase) { downloadDao.delete(serverId, profileId, fileId) } + /** + * Writes what [update] makes of the slot's row, in one transaction, only + * while the row still [matches]. A row replaced or deleted after the caller + * last read it is left as it is. [update] may return null to write nothing. + */ + suspend fun updateSidecarIf( + serverId: String, + profileId: String, + fileId: Int, + matches: (DownloadSidecar) -> Boolean, + update: (DownloadSidecar) -> DownloadSidecar?, + ): Boolean = db.withTransaction { + val current = downloadDao.get(serverId, profileId, fileId)?.toSidecar() + ?.takeIf(matches) + ?: return@withTransaction false + val updated = update(current) ?: return@withTransaction false + downloadDao.upsert(updated.toEntity(serverId, profileId)) + true + } + /** A file slot may now contain a replacement download; an old tombstone cannot own it. */ suspend fun completePendingDeletion( serverId: String, @@ -69,4 +92,12 @@ class DownloadMetadataStore(private val db: PrairieDatabase) { suspend fun deleteAllForServer(serverId: String) = downloadDao.deleteAllForServer(serverId) + + /** + * Saved-artwork columns of every download that has any, emitting only when + * they change. The download capture writes artwork after the completed + * status, so the Downloads tab watches this to pick up the saved images. + */ + fun savedArtworkChanges(): Flow> = + downloadDao.observeSavedArtwork().distinctUntilChanged() } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadSidecarMapping.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadSidecarMapping.kt index e314f755ae..1229c40756 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadSidecarMapping.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadSidecarMapping.kt @@ -2,6 +2,7 @@ package org.prairieserver.prairie.common.downloads import org.prairieserver.prairie.common.data.db.entity.DownloadEntity import org.prairieserver.prairie.model.catalog.VersionChapter +import org.prairieserver.prairie.model.catalog.LeafItemUserData import org.prairieserver.prairie.model.download.DownloadRecord import org.prairieserver.prairie.model.download.DownloadSidecar import org.prairieserver.prairie.model.download.OfflineTrackInfo @@ -34,6 +35,8 @@ fun DownloadSidecar.toEntity(serverId: String, profileId: String): DownloadEntit subtitle = subtitle, posterUrl = posterUrl, posterThumbhash = posterThumbhash, + posterIsEpisodeStill = posterIsEpisodeStill, + episodeUserDataJson = episodeUserData?.let { mappingJson.encodeToString(it) }, year = year, seriesTitle = seriesTitle, seriesContentId = seriesContentId, @@ -60,6 +63,9 @@ fun DownloadSidecar.toEntity(serverId: String, profileId: String): DownloadEntit effectiveQuality = record.effectiveQuality, revision = record.revision, offlineTracksJson = offlineTracks?.let { mappingJson.encodeToString(it) }, + offlinePosterPath = offlinePosterPath, + offlineSeriesPosterPath = offlineSeriesPosterPath, + seriesPosterThumbhash = seriesPosterThumbhash, ) fun DownloadEntity.toSidecar(): DownloadSidecar = @@ -84,6 +90,10 @@ fun DownloadEntity.toSidecar(): DownloadSidecar = subtitle = subtitle, posterUrl = posterUrl, posterThumbhash = posterThumbhash, + posterIsEpisodeStill = posterIsEpisodeStill, + episodeUserData = episodeUserDataJson?.let { + runCatching { mappingJson.decodeFromString(it) }.getOrNull() + }, year = year, seriesTitle = seriesTitle, seriesContentId = seriesContentId, @@ -102,5 +112,8 @@ fun DownloadEntity.toSidecar(): DownloadSidecar = offlineTracks = offlineTracksJson?.let { runCatching { mappingJson.decodeFromString(it) }.getOrNull() }, + offlinePosterPath = offlinePosterPath, + offlineSeriesPosterPath = offlineSeriesPosterPath, + seriesPosterThumbhash = seriesPosterThumbhash, updatedAtMs = updatedAtMs, ) diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadSlotLocks.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadSlotLocks.kt new file mode 100644 index 0000000000..1895185ff9 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadSlotLocks.kt @@ -0,0 +1,24 @@ +package org.prairieserver.prairie.common.downloads + +import kotlinx.coroutines.sync.Mutex +import java.util.concurrent.ConcurrentHashMap + +/** + * One lock per download slot (server, profile, file), shared by the whole + * process. The track capture, the subtitle refresh, and deleting a download + * each check the slot's metadata row and then change its subtitle files and + * row in separate steps. Holding the slot's lock across those steps keeps a + * refresh from publishing over a capture in progress or writing a deleted + * download's row back. + * + * Take it before [org.prairieserver.prairie.network.IdentityTransitionBarrier.withCurrentGeneration] + * or a database transaction, never inside either. + */ +object DownloadSlotLocks { + private data class Slot(val serverId: String, val profileId: String, val fileId: Int) + + private val locks = ConcurrentHashMap() + + fun of(serverId: String, profileId: String, fileId: Int): Mutex = + locks.computeIfAbsent(Slot(serverId, profileId, fileId)) { Mutex() } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadStorage.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadStorage.kt index 6e0cf940f0..8de3bf43e1 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadStorage.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadStorage.kt @@ -155,6 +155,14 @@ class DownloadStorage( fun offlineSubtitleDirectory(serverId: String, profileId: String, fileId: Int): File? = containedSafeChild(offlineAssetsRoot, serverId, profileId, fileId.toString(), SUBTITLES_DIR) + /** + * Private directory for the artwork saved with one download. Separate from + * [offlineSubtitleDirectory], which the track capture clears on every run, + * and removed with the download by [delete]. + */ + fun offlineArtworkDirectory(serverId: String, profileId: String, fileId: Int): File? = + containedSafeChild(offlineAssetsRoot, serverId, profileId, fileId.toString(), ARTWORK_DIR) + private val offlineAssetsRoot: File get() = File(baseDir, OFFLINE_ASSETS_DIR) /** Sum of bytes across every downloaded file under this storage. */ @@ -190,6 +198,7 @@ class DownloadStorage( private companion object { const val OFFLINE_ASSETS_DIR = "download-assets" const val SUBTITLES_DIR = "subtitles" + const val ARTWORK_DIR = "artwork" } } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadTileArtwork.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadTileArtwork.kt new file mode 100644 index 0000000000..7212783d83 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadTileArtwork.kt @@ -0,0 +1,55 @@ +package org.prairieserver.prairie.common.downloads + +import org.prairieserver.prairie.model.download.DownloadMediaType +import org.prairieserver.prairie.model.download.DownloadSidecar +import java.io.File + +/** + * Image source and blur-up placeholder for a download's 2:3 tile. + * [isEpisodeStill] is the provenance of [url]: false for a saved poster, the + * catalog's answer for a catalog URL, and null when that is unknown. + */ +data class DownloadTileArtwork(val url: String?, val thumbhash: String?, val isEpisodeStill: Boolean? = null) + +/** + * The 2:3 tile artwork for one download. A movie uses its saved poster, else + * the catalog [DownloadSidecar.posterUrl]. An episode uses the saved series + * poster, else the catalog URL (already the series poster): its own saved + * `poster` is a 16:9 still and never goes in a 2:3 tile. Saved paths are + * checked on disk, so call off the main thread. + */ +fun DownloadSidecar.tileArtwork(isFile: (String) -> Boolean = ::isSavedArtworkFile): DownloadTileArtwork = + listOf(this).tileArtwork(isFile) + +/** + * The tile artwork for a group of downloads (a series or season row): any + * member's saved series poster, then (movies only) any member's saved poster, + * then the first catalog poster URL. + */ +fun List.tileArtwork(isFile: (String) -> Boolean = ::isSavedArtworkFile): DownloadTileArtwork { + val saved = firstNotNullOfOrNull { sidecar -> sidecar.offlineSeriesPosterPath?.takeIf(isFile) } + ?: firstNotNullOfOrNull { sidecar -> + sidecar.offlinePosterPath?.takeIf { !sidecar.isEpisodeDownload() && isFile(it) } + } + val catalog = firstOrNull { it.posterUrl != null } + return DownloadTileArtwork( + url = saved?.let(::localArtworkUrl) ?: catalog?.posterUrl, + thumbhash = firstNotNullOfOrNull { it.seriesPosterThumbhash } ?: firstNotNullOfOrNull { it.posterThumbhash }, + isEpisodeStill = if (saved != null) false else catalog?.posterIsEpisodeStill, + ) +} + +/** + * True for a TV episode download. Rows written before `mediaType` was stored + * count as episodes when they carry a series title, as the Downloads tab does. + */ +fun DownloadSidecar.isEpisodeDownload(): Boolean = + when (DownloadMediaType.fromWire(mediaType)) { + DownloadMediaType.TvShow -> true + DownloadMediaType.Unknown -> !seriesTitle.isNullOrBlank() + else -> false + } + +private fun isSavedArtworkFile(path: String): Boolean = File(path).isFile + +private fun localArtworkUrl(path: String): String = "file://$path" diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorker.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorker.kt index f27eb43270..a56f76fa14 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorker.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorker.kt @@ -42,6 +42,7 @@ import io.ktor.utils.io.jvm.javaio.toInputStream import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import java.io.IOException import java.net.URLDecoder @@ -478,9 +479,10 @@ class DownloadWorker( } /** - * Fetches the offline manifest's audio tracks and subtitle sidecars for a - * published video download and stores them on its metadata row. Best - * effort: every failure leaves the download playable the legacy way. + * Fetches the offline manifest's audio tracks, subtitle sidecars and + * artwork for a published video download and stores them on its metadata + * row. Best effort: every failure leaves the download playable the legacy + * way, and a failed artwork fetch only leaves that image's path empty. */ private suspend fun captureOfflineTracks( downloadId: String, @@ -492,21 +494,43 @@ class DownloadWorker( if (!OfflineTrackAssetFetcher.appliesTo(mediaType)) return try { requireOwner() - val tracks = offlineTrackFetcher.fetch(downloadId, serverId, profileId, fileId) { - transferAuthority?.let { managedDownloadAuth(it.scope) } - } ?: return - ownedWrite { - val existing = metadataStore.readSidecar(serverId, profileId, fileId) - ?.takeIf { it.record.id == downloadId } - if (existing != null) { - metadataStore.writeSidecar( - serverId, profileId, - existing.copy(offlineTracks = tracks, updatedAtMs = System.currentTimeMillis()), - ) + // Held until the new tracks are on the row, so a subtitle refresh + // never publishes over the sidecars this capture writes. + DownloadSlotLocks.of(serverId, profileId, fileId).withLock { + val assets = offlineTrackFetcher.fetch(downloadId, serverId, profileId, fileId) { + transferAuthority?.let { managedDownloadAuth(it.scope) } + } ?: return + val tracks = assets.tracks + val artwork = assets.artwork + ownedWrite { + val existing = metadataStore.readSidecar(serverId, profileId, fileId) + ?.takeIf { it.record.id == downloadId } + if (existing != null) { + metadataStore.writeSidecar( + serverId, profileId, + existing.copy( + offlineTracks = tracks, + offlinePosterPath = artwork.posterPath, + offlineSeriesPosterPath = artwork.seriesPosterPath, + // A movie queued without a thumbhash takes the + // manifest's. An episode's poster_thumbhash hashes + // its 16:9 still, not the series poster its tile shows. + posterThumbhash = existing.posterThumbhash + ?: artwork.posterThumbhash.takeUnless { existing.isEpisodeDownload() }, + seriesPosterThumbhash = artwork.seriesPosterThumbhash ?: existing.seriesPosterThumbhash, + updatedAtMs = System.currentTimeMillis(), + ), + ) + } + true } - true + Log.i( + TAG, + "offline tracks captured id=$downloadId audio=${tracks.audioTracks.size} " + + "subtitles=${tracks.subtitles.size} poster=${artwork.posterPath != null} " + + "seriesPoster=${artwork.seriesPosterPath != null}", + ) } - Log.i(TAG, "offline tracks captured id=$downloadId audio=${tracks.audioTracks.size} subtitles=${tracks.subtitles.size}") } catch (e: CancellationException) { throw e } catch (e: Exception) { diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/OfflineSubtitleRefreshWorker.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/OfflineSubtitleRefreshWorker.kt new file mode 100644 index 0000000000..965b7acd28 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/OfflineSubtitleRefreshWorker.kt @@ -0,0 +1,124 @@ +package org.prairieserver.prairie.common.downloads + +import android.content.Context +import android.util.Log +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import io.ktor.client.HttpClient +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.prairieserver.prairie.model.download.DownloadStatus +import org.prairieserver.prairie.model.download.statusEnum +import org.prairieserver.prairie.network.DurableLoginAuthorityProvider +import org.prairieserver.prairie.network.IdentityTransitionBarrier +import org.prairieserver.prairie.network.apiv2.ApiV2Gate +import org.prairieserver.prairie.network.apiv2.managedDownloadAuth +import java.util.concurrent.TimeUnit + +/** + * Keeps saved subtitle sidecars in step with the server's timing corrections. + * + * A completed video download keeps the subtitle sidecars it fetched when it + * finished. When the server later retimes one of its subtitles, stored or a + * file next to the media (an automatic or manual sync, or a timing set or + * reset), the download's manifest lists that subtitle at a new `revision`; + * this worker re-fetches those sidecars and replaces the saved copies, so the + * next offline playback uses the corrected timing. + * + * It runs periodically while the device has a network, for the downloads of + * the login and profile that are active when it fires, like + * [DownloadSubscriptionWorker]. Every failure is left for the next run. + */ +class OfflineSubtitleRefreshWorker( + appContext: Context, + params: WorkerParameters, + private val metadataStore: DownloadMetadataStore, + storage: DownloadStorage, + httpClient: HttpClient, + private val authorities: DurableLoginAuthorityProvider, + private val transitions: IdentityTransitionBarrier, + private val gate: ApiV2Gate, +) : CoroutineWorker(appContext, params) { + + private val fetcher = OfflineTrackAssetFetcher(httpClient, storage) + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { refresh() } + + private suspend fun refresh(): Result { + // A server this client must not speak v2 to is left alone until the next period. + if (gate.blocked() != null) return Result.success() + val authority = authorities.snapshotDurableLoginAuthority() ?: return Result.success() + val scope = authority.scope + val profileId = scope.profileId ?: return Result.success() + for (sidecar in metadataStore.listSidecars(scope.serverId, profileId)) { + if (sidecar.record.statusEnum() != DownloadStatus.Completed) continue + val tracks = sidecar.offlineTracks ?: continue + val downloadId = sidecar.record.id + val fileId = sidecar.record.mediaFileId + val staged = try { + fetcher.stageSubtitleRefresh(downloadId, tracks) { managedDownloadAuth(scope) } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "subtitle refresh failed id=$downloadId", e) + null + } ?: continue + try { + // Replace the saved files and record their revisions only while + // the owner and the download are the ones the refresh read: a + // replaced or deleted download keeps whatever it has now. The + // slot lock keeps a capture or a delete from landing between + // the check and the writes, and the conditional update keeps a + // replacement download enqueued meanwhile from being overwritten. + DownloadSlotLocks.of(scope.serverId, profileId, fileId).withLock { + transitions.withCurrentGeneration(scope.identityGeneration) { + if (authorities.snapshotDurableLoginAuthority() != authority) return@withCurrentGeneration false + metadataStore.updateSidecarIf( + scope.serverId, + profileId, + fileId, + matches = { it.record.id == downloadId && it.offlineTracks == tracks }, + ) { current -> + staged.publish()?.let { refreshed -> + current.copy(offlineTracks = refreshed, updatedAtMs = System.currentTimeMillis()) + } + } + } + } + } finally { + staged.discard() + } + } + return Result.success() + } + + companion object { + private const val TAG = "OfflineSubtitleRefresh" + private const val PERIODIC_UNIQUE_NAME = "offline-subtitle-refresh-periodic" + private const val PERIODIC_INTERVAL_HOURS = 12L + + fun enqueuePeriodic(context: Context) { + val request = PeriodicWorkRequestBuilder( + PERIODIC_INTERVAL_HOURS, TimeUnit.HOURS, + ) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build(), + ) + .build() + WorkManager.getInstance(context.applicationContext).enqueueUniquePeriodicWork( + PERIODIC_UNIQUE_NAME, + ExistingPeriodicWorkPolicy.KEEP, + request, + ) + } + } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/OfflineTrackAssetFetcher.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/OfflineTrackAssetFetcher.kt index 5d14742d55..b1678f3a87 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/OfflineTrackAssetFetcher.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/OfflineTrackAssetFetcher.kt @@ -7,31 +7,46 @@ import io.ktor.client.plugins.timeout import io.ktor.client.request.HttpRequestBuilder import io.ktor.client.request.get import io.ktor.client.request.prepareGet +import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.bodyAsChannel import io.ktor.client.statement.bodyAsText import io.ktor.http.HttpStatusCode +import io.ktor.http.contentLength import io.ktor.http.encodeURLPathPart import io.ktor.utils.io.jvm.javaio.toInputStream import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import org.prairieserver.prairie.model.download.DownloadMediaType -import org.prairieserver.prairie.model.download.OfflineManifestTracks +import org.prairieserver.prairie.model.download.OFFLINE_ARTWORK_POSTER +import org.prairieserver.prairie.model.download.OFFLINE_ARTWORK_SERIES_POSTER +import org.prairieserver.prairie.model.download.OfflineArtworkFiles +import org.prairieserver.prairie.model.download.OfflineManifestArtwork import org.prairieserver.prairie.model.download.OfflineManifestSubtitle import org.prairieserver.prairie.model.download.OfflineSubtitleFile import org.prairieserver.prairie.model.download.OfflineTrackInfo +import org.prairieserver.prairie.model.download.decodeOfflineManifestArtwork import org.prairieserver.prairie.model.download.decodeOfflineManifestTracks +import org.prairieserver.prairie.model.download.isOfflineArtworkFetchUrl import org.prairieserver.prairie.model.download.isOfflineSubtitleFetchUrl import org.prairieserver.prairie.model.download.offlineSubtitleExtension import org.prairieserver.prairie.model.download.offlineSubtitleFormat +import org.prairieserver.prairie.model.download.subtitlesWithNewRevision import org.prairieserver.prairie.model.download.toOfflineTrackInfo import org.prairieserver.prairie.playback.orNullIfBlank import java.io.File import java.io.IOException +/** What one offline-manifest capture produced for a completed download. */ +internal data class OfflineAssets( + val tracks: OfflineTrackInfo, + val artwork: OfflineArtworkFiles, +) + /** * Captures what offline video playback needs once the media bytes are down: - * the offline manifest's audio tracks (positions inside the delivered file) and - * the subtitle sidecars it lists, each fetched once into private storage. + * the offline manifest's audio tracks (positions inside the delivered file), + * the subtitle sidecars it lists, and the poster (plus the series poster for + * episodes), each fetched once into private storage. * * Everything here is best effort. A missing manifest, an older server, or a * sidecar that fails to fetch never fails the video download; the download @@ -42,14 +57,20 @@ internal class OfflineTrackAssetFetcher( private val storage: DownloadStorage, private val manifestRetryDelayMs: Long = 2_000, ) { + // The server streams artwork itself, so a redirect is never legitimate; + // refusing it keeps the fetch on the URL isOfflineArtworkFetchUrl vetted. + private val artworkHttpClient = httpClient.config { followRedirects = false } + suspend fun fetch( downloadId: String, serverId: String, profileId: String, fileId: Int, configure: HttpRequestBuilder.() -> Unit, - ): OfflineTrackInfo? { - val manifest = fetchManifest(downloadId, configure) ?: return null + ): OfflineAssets? { + // One manifest request feeds both the track and the artwork capture. + val body = fetchManifestBody(downloadId, configure) ?: return null + val manifest = decodeOfflineManifestTracks(body) ?: return null val directory = storage.offlineSubtitleDirectory(serverId, profileId, fileId) // A replaced download (new revision) must not keep the previous @@ -62,7 +83,8 @@ internal class OfflineTrackAssetFetcher( fetchSubtitle(downloadId, ordinal, subtitle, directory, configure) } } - return manifest.toOfflineTrackInfo(saved) + val artwork = fetchArtwork(downloadId, serverId, profileId, fileId, decodeOfflineManifestArtwork(body), configure) + return OfflineAssets(tracks = manifest.toOfflineTrackInfo(saved), artwork = artwork) } /** @@ -70,18 +92,19 @@ internal class OfflineTrackAssetFetcher( * blip or a transient server error is retried briefly here; a missing * manifest (older server) is not. */ - private suspend fun fetchManifest( + private suspend fun fetchManifestBody( downloadId: String, configure: HttpRequestBuilder.() -> Unit, - ): OfflineManifestTracks? { - repeat(MANIFEST_ATTEMPTS) { attempt -> + attempts: Int = MANIFEST_ATTEMPTS, + ): String? { + repeat(attempts) { attempt -> if (attempt > 0) delay(manifestRetryDelayMs * attempt) try { val response = httpClient.get("/api/v2/downloads/${downloadId.encodeURLPathPart()}/manifest") { configure() } val status = response.status - if (status == HttpStatusCode.OK) return decodeOfflineManifestTracks(response.bodyAsText()) + if (status == HttpStatusCode.OK) return response.bodyAsText() Log.i(TAG, "manifest unavailable id=$downloadId status=${status.value}") if (status.value < 500 && status != HttpStatusCode.TooManyRequests) return null } catch (e: CancellationException) { @@ -93,51 +116,114 @@ internal class OfflineTrackAssetFetcher( return null } - private suspend fun fetchSubtitle( + /** + * Saves the manifest's `poster` and, when listed, `series_poster` into the + * download's artwork directory. Each image is independent: a failure leaves + * that path null and never affects the download or the other image. + */ + private suspend fun fetchArtwork( downloadId: String, - ordinal: Int, - subtitle: OfflineManifestSubtitle, + serverId: String, + profileId: String, + fileId: Int, + manifest: OfflineManifestArtwork?, + configure: HttpRequestBuilder.() -> Unit, + ): OfflineArtworkFiles { + val directory = storage.offlineArtworkDirectory(serverId, profileId, fileId) + // Same slot, new revision: drop the previous revision's images first. + directory?.deleteRecursively() + if (manifest == null) return OfflineArtworkFiles() + val urls = manifest.artworkUrls + suspend fun save(kind: String, url: String?): String? = + if (directory == null || url.isNullOrBlank()) null + else fetchArtworkImage(downloadId, kind, url, directory, configure) + return OfflineArtworkFiles( + posterPath = save(OFFLINE_ARTWORK_POSTER, urls.poster), + seriesPosterPath = save(OFFLINE_ARTWORK_SERIES_POSTER, urls.seriesPoster), + posterThumbhash = manifest.posterThumbhash.orNullIfBlank(), + seriesPosterThumbhash = manifest.seriesPosterThumbhash.orNullIfBlank(), + ) + } + + private suspend fun fetchArtworkImage( + downloadId: String, + kind: String, + url: String, directory: File, configure: HttpRequestBuilder.() -> Unit, - ): OfflineSubtitleFile? { - val format = offlineSubtitleFormat(subtitle.format) ?: return null - if (!isOfflineSubtitleFetchUrl(subtitle.fetchUrl)) { - Log.w(TAG, "skipping subtitle with unexpected reference id=$downloadId ordinal=$ordinal") + ): String? { + if (!isOfflineArtworkFetchUrl(url, kind)) { + Log.w(TAG, "skipping artwork with unexpected reference id=$downloadId kind=$kind") return null } - val target = File(directory, "$ordinal.${offlineSubtitleExtension(format)}") - val partial = File(directory, "$ordinal.part") + val target = File(directory, kind) + val partial = File(directory, "$kind.part") return try { if (!directory.isDirectory && !directory.mkdirs()) throw IOException("could not create $directory") - httpClient.prepareGet(subtitle.fetchUrl.trim()) { + artworkHttpClient.prepareGet(url.trim()) { configure() - // Embedded ASS/PGS sidecars are extracted from the source on - // demand, so the first byte can take a while; keep only an idle - // timeout, like the media transfer itself. timeout { - requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS - socketTimeoutMillis = SUBTITLE_IDLE_TIMEOUT_MS + requestTimeoutMillis = ARTWORK_REQUEST_TIMEOUT_MS + socketTimeoutMillis = ARTWORK_IDLE_TIMEOUT_MS } }.execute { response -> if (response.status != HttpStatusCode.OK) { throw IOException("HTTP ${response.status.value}") } - var written = 0L - response.bodyAsChannel().toInputStream().use { input -> - partial.outputStream().use { output -> - val buffer = ByteArray(BUFFER_BYTES) - while (true) { - val read = input.read(buffer) - if (read < 0) break - written += read - if (written > MAX_SUBTITLE_BYTES) throw IOException("subtitle exceeds $MAX_SUBTITLE_BYTES bytes") - output.write(buffer, 0, read) - } - } + val declared = response.contentLength() + if (declared != null && declared > MAX_ARTWORK_BYTES) { + throw IOException("artwork exceeds $MAX_ARTWORK_BYTES bytes") } - if (written == 0L) throw IOException("empty subtitle") + val written = copyCapped(response, partial, MAX_ARTWORK_BYTES, "artwork") + if (written == 0L) throw IOException("empty artwork") } if (!partial.renameTo(target)) throw IOException("could not publish $target") + target.absolutePath + } catch (e: CancellationException) { + partial.delete() + throw e + } catch (e: Exception) { + Log.w(TAG, "artwork fetch failed id=$downloadId kind=$kind", e) + partial.delete() + target.delete() + null + } + } + + /** Streams [response]'s body into [partial], failing once it passes [maxBytes]. */ + private suspend fun copyCapped(response: HttpResponse, partial: File, maxBytes: Long, what: String): Long { + var written = 0L + response.bodyAsChannel().toInputStream().use { input -> + partial.outputStream().use { output -> + val buffer = ByteArray(BUFFER_BYTES) + while (true) { + val read = input.read(buffer) + if (read < 0) break + written += read + if (written > maxBytes) throw IOException("$what exceeds $maxBytes bytes") + output.write(buffer, 0, read) + } + } + } + return written + } + + private suspend fun fetchSubtitle( + downloadId: String, + ordinal: Int, + subtitle: OfflineManifestSubtitle, + directory: File, + configure: HttpRequestBuilder.() -> Unit, + ): OfflineSubtitleFile? { + val format = offlineSubtitleFormat(subtitle.format) ?: return null + if (!isOfflineSubtitleFetchUrl(subtitle.fetchUrl)) { + Log.w(TAG, "skipping subtitle with unexpected reference id=$downloadId ordinal=$ordinal") + return null + } + val target = File(directory, "$ordinal.${offlineSubtitleExtension(format)}") + return try { + if (!directory.isDirectory && !directory.mkdirs()) throw IOException("could not create $directory") + downloadSubtitle(subtitle.fetchUrl.trim(), target, configure) OfflineSubtitleFile( path = target.absolutePath, format = format, @@ -145,18 +231,109 @@ internal class OfflineTrackAssetFetcher( title = subtitle.title.orNullIfBlank(), forced = subtitle.forced, hearingImpaired = subtitle.hearingImpaired, + fetchUrl = subtitle.fetchUrl.trim(), + revision = subtitle.revision, ) } catch (e: CancellationException) { - partial.delete() throw e } catch (e: Exception) { Log.w(TAG, "subtitle fetch failed id=$downloadId ordinal=$ordinal format=$format", e) - partial.delete() target.delete() null } } + /** + * Re-fetches the saved sidecars whose subtitle the server has retimed + * since they were saved (its manifest `revision` changed) into staging + * files beside them. Nothing is replaced until the caller has confirmed + * the download is still the one [tracks] describes and calls + * [StagedSubtitleRefresh.publish]. Returns null when nothing changed or the + * manifest is unavailable. A sidecar that fails to fetch keeps its old file + * and revision, so the next refresh tries it again. + */ + suspend fun stageSubtitleRefresh( + downloadId: String, + tracks: OfflineTrackInfo, + configure: HttpRequestBuilder.() -> Unit, + ): StagedSubtitleRefresh? { + if (tracks.subtitles.none { it.fetchUrl != null }) return null + val manifest = fetchManifestBody(downloadId, configure, attempts = 1) + ?.let(::decodeOfflineManifestTracks) + ?: return null + val staged = mutableListOf() + try { + for ((saved, revision) in tracks.subtitlesWithNewRevision(manifest)) { + stageSidecar(downloadId, saved, revision, configure)?.let(staged::add) + } + } catch (e: CancellationException) { + staged.forEach { it.staging.delete() } + throw e + } + return if (staged.isEmpty()) null else StagedSubtitleRefresh(downloadId, tracks, staged) + } + + /** Fetches one retimed sidecar into a staging file beside [saved], or null when it cannot. */ + private suspend fun stageSidecar( + downloadId: String, + saved: OfflineSubtitleFile, + revision: String, + configure: HttpRequestBuilder.() -> Unit, + ): StagedSubtitleRefresh.Sidecar? { + val url = saved.fetchUrl?.takeIf(::isOfflineSubtitleFetchUrl) ?: return null + val target = File(saved.path) + if (!target.isFile) return null + // The copy this refresh replaces, as it is now: if anything rewrites it + // before publishing, the staged file is dropped. + val sidecar = StagedSubtitleRefresh.Sidecar( + path = saved.path, + revision = revision, + staging = File(target.parentFile, "${target.nameWithoutExtension}.refresh.${target.extension}"), + savedLength = target.length(), + savedModifiedMs = target.lastModified(), + ) + return try { + downloadSubtitle(url, sidecar.staging, configure) + sidecar + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "subtitle refresh failed id=$downloadId", e) + sidecar.staging.delete() + null + } + } + + /** Streams [url] into [target] through a sibling partial file, replacing it only once complete. */ + private suspend fun downloadSubtitle( + url: String, + target: File, + configure: HttpRequestBuilder.() -> Unit, + ) { + val partial = File(target.parentFile, "${target.nameWithoutExtension}.part") + try { + httpClient.prepareGet(url) { + configure() + // Embedded ASS/PGS sidecars are extracted from the source on + // demand, so the first byte can take a while; keep only an idle + // timeout, like the media transfer itself. + timeout { + requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS + socketTimeoutMillis = SUBTITLE_IDLE_TIMEOUT_MS + } + }.execute { response -> + if (response.status != HttpStatusCode.OK) { + throw IOException("HTTP ${response.status.value}") + } + val written = copyCapped(response, partial, MAX_SUBTITLE_BYTES, "subtitle") + if (written == 0L) throw IOException("empty subtitle") + } + if (!partial.renameTo(target)) throw IOException("could not publish $target") + } finally { + partial.delete() + } + } + companion object { private const val TAG = "OfflineTrackAssets" private const val MANIFEST_ATTEMPTS = 3 @@ -166,7 +343,12 @@ internal class OfflineTrackAssetFetcher( /** PGS tracks for a feature run to tens of MB; text is far smaller. */ private const val MAX_SUBTITLE_BYTES = 256L * 1024 * 1024 - /** Only video downloads have tracks to capture. */ + /** A poster is a few hundred KB; anything past this is not a poster. */ + internal const val MAX_ARTWORK_BYTES = 10L * 1024 * 1024 + private const val ARTWORK_REQUEST_TIMEOUT_MS = 60_000L + private const val ARTWORK_IDLE_TIMEOUT_MS = 30_000L + + /** Only video downloads have tracks and artwork to capture. */ fun appliesTo(mediaType: String?): Boolean = when (DownloadMediaType.fromWire(mediaType)) { DownloadMediaType.Movie, DownloadMediaType.TvShow, DownloadMediaType.Unknown -> true @@ -174,3 +356,58 @@ internal class OfflineTrackAssetFetcher( } } } + +/** + * Sidecars fetched again into staging files beside their saved copies, not + * yet in place. [publish] moves them over the saved copies; [discard] removes + * whatever was not published. + */ +internal class StagedSubtitleRefresh( + private val downloadId: String, + private val tracks: OfflineTrackInfo, + private val sidecars: List, +) { + class Sidecar( + val path: String, + val revision: String, + val staging: File, + val savedLength: Long, + val savedModifiedMs: Long, + ) + + /** + * Replaces each saved copy that is still the one this refresh was staged + * against, and returns [tracks] with the published revisions, or null when + * none was published. A copy something rewrote or removed meanwhile (a + * new capture for the same file) keeps its new contents. Call it holding + * the download's [DownloadSlotLocks] lock, after checking its row. + */ + fun publish(): OfflineTrackInfo? { + val published = sidecars.mapNotNull { sidecar -> + val target = File(sidecar.path) + val unchanged = target.isFile && + target.length() == sidecar.savedLength && + target.lastModified() == sidecar.savedModifiedMs + if (unchanged && sidecar.staging.renameTo(target)) { + Log.i(TAG, "subtitle refreshed id=$downloadId revision=${sidecar.revision}") + sidecar.path to sidecar.revision + } else { + null + } + }.toMap() + if (published.isEmpty()) return null + return tracks.copy( + subtitles = tracks.subtitles.map { saved -> + published[saved.path]?.let { saved.copy(revision = it) } ?: saved + }, + ) + } + + fun discard() { + sidecars.forEach { it.staging.delete() } + } + + private companion object { + const val TAG = "OfflineTrackAssets" + } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/lan/PrairieCastTls.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/lan/PrairieCastTls.kt index 939480c648..dc0dac7745 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/lan/PrairieCastTls.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/lan/PrairieCastTls.kt @@ -37,8 +37,12 @@ import org.bouncycastle.tls.crypto.impl.bc.BcTlsCrypto * external-PSK hook. */ object PrairieCastTls { - private val KEY = "prairie-cast-v1".toByteArray(Charsets.UTF_8) - private val IDENTITY = "prairie-cast".toByteArray(Charsets.UTF_8) + // Fresh arrays on every use: BouncyCastle zeroes the PSK buffer it is + // handed once a TLS 1.2 handshake has derived its keys. Handing it one + // shared array let the first TLS 1.2 client wipe the key for the rest of + // the process, failing every later handshake (TLS 1.3 included). + private fun key(): ByteArray = "prairie-cast-v1".toByteArray(Charsets.UTF_8) + private fun identity(): ByteArray = "prairie-cast".toByteArray(Charsets.UTF_8) private const val TLS12_SUITE = CipherSuite.TLS_PSK_WITH_AES_128_GCM_SHA256 private const val TLS13_SUITE = CipherSuite.TLS_AES_128_GCM_SHA256 private const val HANDSHAKE_TIMEOUT_MS = 10_000 @@ -117,7 +121,7 @@ object PrairieCastTls { object : TlsPSKIdentityManager { override fun getHint(): ByteArray? = null - override fun getPSK(identity: ByteArray?): ByteArray = KEY + override fun getPSK(identity: ByteArray?): ByteArray = key() }, ) { override fun getCipherSuites(): IntArray = intArrayOf(TLS13_SUITE, TLS12_SUITE) @@ -128,7 +132,7 @@ object PrairieCastTls { override fun allowCertificateStatus(): Boolean = false override fun getExternalPSK(identities: Vector<*>?): TlsPSKExternal = - BasicTlsPSKExternal(IDENTITY, bcCrypto.createSecret(KEY), PRFAlgorithm.tls13_hkdf_sha256) + BasicTlsPSKExternal(identity(), bcCrypto.createSecret(key()), PRFAlgorithm.tls13_hkdf_sha256) override fun notifyAlertRaised( alertLevel: Short, @@ -146,7 +150,7 @@ object PrairieCastTls { private val bcCrypto: BcTlsCrypto, ) : PSKTlsClient( bcCrypto, - BasicTlsPSKIdentity(IDENTITY, KEY), + BasicTlsPSKIdentity(identity(), key()), ) { override fun getSupportedCipherSuites(): IntArray = intArrayOf(TLS13_SUITE, TLS12_SUITE) @@ -157,8 +161,8 @@ object PrairieCastTls { Vector().apply { add( BasicTlsPSKExternal( - IDENTITY, - bcCrypto.createSecret(KEY), + identity(), + bcCrypto.createSecret(key()), PRFAlgorithm.tls13_hkdf_sha256, ), ) diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pairing/CompanionPairingCoordinator.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pairing/CompanionPairingCoordinator.kt index bdfcbf612f..4c9e2ddcf3 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pairing/CompanionPairingCoordinator.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pairing/CompanionPairingCoordinator.kt @@ -4,6 +4,8 @@ import android.util.Log import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.channels.ClosedReceiveChannelException import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.coroutineScope @@ -14,17 +16,25 @@ import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout +import org.prairieserver.prairie.model.auth.DeviceCodeFormat import org.prairieserver.prairie.model.auth.DeviceLoginDecisionResponse import org.prairieserver.prairie.model.auth.DeviceLoginLookupResponse import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.PrairieAuthUnavailableException import org.prairieserver.prairie.network.AuthScopeSnapshot import org.prairieserver.prairie.network.IdentityTransitionBarrier import org.prairieserver.prairie.network.ServerRegistry import org.prairieserver.prairie.network.TokenManager +import org.prairieserver.prairie.network.signedInEntries +import org.prairieserver.prairie.network.api.ServerIdentityApi +import org.prairieserver.prairie.pairing.PairingEndpoint +import org.prairieserver.prairie.pairing.PairingFailureCode import org.prairieserver.prairie.pairing.PairingMessage import org.prairieserver.prairie.pairing.PairingProtocol +import org.prairieserver.prairie.pairing.PairingReceiverState import org.prairieserver.prairie.pairing.PairingServerStatus import org.prairieserver.prairie.repository.DeviceLoginRepository +import org.prairieserver.prairie.repository.ServerIdentityRepository data class CompanionPairingTarget( val deviceId: String, @@ -37,25 +47,99 @@ data class CompanionPairingTarget( val sessionId: String? = null, /** DNS-SD instance name used by onServiceLost; it can differ from the TXT display name. */ val serviceName: String = name, -) + /** TXT `srv`: the deployment identity a signed-out (`st=login`) TV wants. */ + val serverIdentity: String? = null, +) { + /** + * A TV on its sign-in screen for one server. Older TVs advertise no `st` + * (or `setup`); anything that isn't `login` is treated as a setup TV. + */ + val isSignedOutTv: Boolean get() = state == PairingReceiverState.Login.wire + + /** Only `setup` and `login` TVs are offered; an unknown state is a newer TV this build can't serve. */ + val isOfferable: Boolean + get() = state == null || state == PairingReceiverState.Setup.wire || + (isSignedOutTv && !serverIdentity.isNullOrBlank()) +} data class CompanionPairingServer( val id: String, val url: String, val displayName: String, + /** The phone's active server, preselected in the chooser. */ + val isActive: Boolean = false, ) +/** + * What the phone knows about a saved server beyond its URL: the deployment + * identity verified at that URL and the other addresses it offers + * (`GET /api/v2/system/connections`). Sent in `pushServer` so the TV can + * verify the server and reach it at another address (prairie-apple parity). + */ +interface CompanionServerIdentitySource { + /** Verified identity of [server], or null when it can't be verified. */ + suspend fun identity(server: CompanionPairingServer): String? + + /** Addresses [server] offers other devices, or null when unavailable. */ + suspend fun endpoints(server: CompanionPairingServer): List? + + /** The servers among [servers] whose verified identity is [serverId], in the order given. */ + suspend fun serversWithIdentity( + servers: List, + serverId: String, + ): List = servers.filter { identity(it) == serverId } +} + +/** Production identity source over the saved-server registry. */ +class RegistryCompanionServerIdentitySource( + private val registry: ServerRegistry, + private val identities: ServerIdentityRepository, + private val identityApi: ServerIdentityApi, + private val identityTransitions: IdentityTransitionBarrier, +) : CompanionServerIdentitySource { + override suspend fun identity(server: CompanionPairingServer): String? { + val entry = registry.entries.value.firstOrNull { it.id == server.id } ?: return null + return identities.identityOf(entry) + } + + /** Recorded identities match without the network; the rest are probed in parallel. */ + override suspend fun serversWithIdentity( + servers: List, + serverId: String, + ): List { + val candidates = registry.entries.value.filter { entry -> servers.any { it.id == entry.id } } + val matched = identities.entriesFor(serverId, candidates).map { it.id }.toSet() + return servers.filter { it.id in matched } + } + + override suspend fun endpoints(server: CompanionPairingServer): List? = + identityApi.connections( + AuthScopeSnapshot.profileless(server.id, server.url, identityTransitions.generation.value), + )?.endpoints?.takeIf { it.isNotEmpty() } +} + data class CompanionPairingServerSnapshot( val activeServerId: String?, val servers: List, ) +/** + * The first server's confirmation. People compare [userCode], the code the TV + * shows on screen (from the server's lookup, not the TV's frame). The match + * words are checked programmatically; [serverMatchCode] is shown only on a + * secondary line for TV apps released before user codes, which show the + * words alone. Drop that line on the same schedule as the web `/activate` + * card's "Older TV apps show ..." line. + */ data class CompanionPairingApproval( val targetName: String, val serverName: String, val userCode: String, - val tvMatchCode: String, val serverMatchCode: String, + /** The server's host, shown next to its name ("Home (prairie.example.com)"). */ + val serverHost: String = "", + /** The account approving signs the TV in as; null when it couldn't be read. */ + val accountName: String? = null, ) sealed class CompanionPairingStatus { @@ -83,6 +167,14 @@ interface CompanionDeviceLoginApprover { suspend fun lookup(server: CompanionPairingServer, code: String): ApiResult suspend fun approve(server: CompanionPairingServer, code: String): ApiResult suspend fun deny(server: CompanionPairingServer, code: String): ApiResult + + /** + * The account signed in on [server], which approving signs the TV in as + * (the approval card names it, like the web `/activate` card and the + * iPhone app). Null when it can't be read; the card then says "with this + * account". + */ + suspend fun accountName(server: CompanionPairingServer): String? = null } fun interface CompanionPairingTransportFactory { @@ -94,37 +186,30 @@ class RegistryCompanionPairingServerStore( private val tokenManager: TokenManager, ) : CompanionPairingServerStore { override suspend fun snapshot(): CompanionPairingServerSnapshot { - val signedInServers = buildList { - for (entry in registry.entries.value) { - if (entry.url.isBlank()) continue - if (tokenManager.getAccessTokenForScope(entry.id).isNullOrBlank()) continue - add(entry) - } - } + val activeId = registry.activeServerId.value return CompanionPairingServerSnapshot( - activeServerId = registry.activeServerId.value, - servers = signedInServers - .sortedWith( - compareByDescending { - it.id == registry.activeServerId.value - }.thenByDescending { it.lastUsedAtEpochMs }, + activeServerId = activeId, + servers = registry.signedInEntries(tokenManager).map { + CompanionPairingServer( + id = it.id, + url = it.url, + displayName = it.displayName, + isActive = it.id == activeId, ) - .map { - CompanionPairingServer( - id = it.id, - url = it.url, - displayName = it.displayName, - ) - }, + }, ) } - } class RepositoryCompanionDeviceLoginApprover( private val repository: DeviceLoginRepository, private val identityTransitions: IdentityTransitionBarrier, + /** The account on a scope's server, read with that server's own credentials. */ + private val accountNameOf: suspend (AuthScopeSnapshot) -> String? = { null }, ) : CompanionDeviceLoginApprover { + override suspend fun accountName(server: CompanionPairingServer): String? = + runCatching { accountNameOf(server.profilelessScope()) }.getOrNull()?.takeIf { it.isNotBlank() } + override suspend fun lookup( server: CompanionPairingServer, code: String, @@ -140,24 +225,15 @@ class RepositoryCompanionDeviceLoginApprover( code: String, ): ApiResult = repository.deny(server.profilelessScope(), code) - private fun CompanionPairingServer.profilelessScope() = AuthScopeSnapshot( - serverId = id, - serverUrl = url, - profileId = null, - profileToken = null, - // This inactive-server scope cannot carry the token manager's live - // persistent credential epoch. Pin its request to the current identity - // generation instead so a late refresh cannot overwrite a same-server - // account replacement. - identityGeneration = identityTransitions.generation.value, - isIdentityGenerationStamped = true, - ) + private fun CompanionPairingServer.profilelessScope() = + AuthScopeSnapshot.profileless(id, url, identityTransitions.generation.value) } class CompanionPairingCoordinator( private val serverStore: CompanionPairingServerStore, private val deviceLoginApprover: CompanionDeviceLoginApprover, private val transportFactory: CompanionPairingTransportFactory, + private val identitySource: CompanionServerIdentitySource? = null, ) { private val _status = MutableStateFlow(CompanionPairingStatus.Idle) val status: StateFlow = _status.asStateFlow() @@ -166,13 +242,38 @@ class CompanionPairingCoordinator( _status.value = CompanionPairingStatus.Idle } + /** + * The saved, signed-in server a signed-out TV ([CompanionPairingTarget.isSignedOutTv]) + * wants, matched by verified identity against its `srv`; null when this + * phone doesn't hold it and must not offer the TV. + */ + suspend fun serverForSignedOutTv(target: CompanionPairingTarget): CompanionPairingServer? { + val wanted = target.serverIdentity?.takeIf { target.isSignedOutTv && it.isNotBlank() } ?: return null + val source = identitySource ?: return null + return source.serversWithIdentity(serverStore.snapshot().orderedServers(), wanted).firstOrNull() + } + + /** + * [signInServer]: for a signed-out TV, the server [serverForSignedOutTv] + * already matched when the offer was made; resolved again only when absent. + */ suspend fun pair( target: CompanionPairingTarget, + signInServer: CompanionPairingServer? = null, chooseServers: suspend (List) -> List = { it }, confirmFirstMatch: suspend (CompanionPairingApproval) -> Boolean = { true }, ): CompanionPairingResult { val snapshot = serverStore.snapshot() - val orderedServers = snapshot.orderedServers() + // A signed-out TV wants exactly one server, the one it advertised: + // skip the chooser and push only that one. It must still be a server + // this phone is signed in to. + val signedOutServer = if (target.isSignedOutTv) { + (signInServer?.takeIf { matched -> snapshot.servers.any { it.id == matched.id } } ?: serverForSignedOutTv(target)) + ?: return fail(target, "This phone isn't signed in to the server ${target.name} uses.") + } else { + null + } + val orderedServers = signedOutServer?.let(::listOf) ?: snapshot.orderedServers() if (orderedServers.isEmpty()) { return fail(target, "No saved Prairie servers are available to send.") } @@ -191,12 +292,19 @@ class CompanionPairingCoordinator( coroutineScope { val inbox = Channel(Channel.UNLIMITED) val collector = launch { - connected.incoming.collect { inbox.send(it) } + try { + connected.incoming.collect { inbox.send(it) } + } finally { + // The TV hung up (or the read failed): wake any wait + // for its next message instead of sitting on it until + // the step's timeout. + inbox.close() + } } try { awaitCompatibleHello(inbox) _status.value = CompanionPairingStatus.PickServers(target.name) - val selectedServers = chooseServers(orderedServers) + val selectedServers = (if (signedOutServer != null) orderedServers else chooseServers(orderedServers)) .filter { selected -> orderedServers.any { it.id == selected.id } } if (selectedServers.isEmpty()) { error("Choose at least one server to continue.") @@ -239,11 +347,24 @@ class CompanionPairingCoordinator( serverNames = signedInNames.toList(), ) return CompanionPairingResult.Completed(signedInCount) + } catch (e: TimeoutCancellationException) { + // A step timeout is a failure, not a cancellation of the caller: + // rethrowing it left the card spinning on its progress step. + Log.w(TAG, "Companion setup timed out for ${target.name}", e) + return fail(target, "${target.name} stopped responding. Try again.") } catch (e: CancellationException) { throw e } catch (t: Throwable) { Log.e(TAG, "Companion setup failed for ${target.name}", t) - val message = t.userFacingPairingMessage() + val message = when (t) { + is PeerCancelledException -> if (t.reason == CONSENT_DENIED_REASON) { + "${target.name} didn't allow it." + } else { + "${target.name} cancelled." + } + is ClosedReceiveChannelException -> "${target.name} closed the connection. Try again." + else -> t.userFacingPairingMessage() + } _status.value = CompanionPairingStatus.Failed(target.name, message) return CompanionPairingResult.Failed(message) } finally { @@ -255,6 +376,16 @@ class CompanionPairingCoordinator( } } + /** What the phone says when the TV reports [this] failure for [serverName]. */ + private fun PairingFailureCode.phoneMessage(tvName: String, serverName: String): String = when (this) { + PairingFailureCode.Denied -> "The sign-in was declined." + PairingFailureCode.Expired -> "The code on $tvName expired. Try again." + PairingFailureCode.Unreachable -> "$tvName can't reach $serverName. Check the TV's network connection." + PairingFailureCode.IdentityMismatch -> "$tvName reached a different server at that address. Nothing was signed in." + PairingFailureCode.UpdateRequired -> "$serverName or the Prairie app on $tvName needs an update first." + PairingFailureCode.AuthFailed -> "$tvName couldn't finish signing in to $serverName." + } + private fun Throwable.userFacingPairingMessage(): String { val detail = generateSequence(this) { it.cause } .mapNotNull { it.message?.takeIf(String::isNotBlank) } @@ -271,51 +402,78 @@ class CompanionPairingCoordinator( confirmFirstMatch: suspend (CompanionPairingApproval) -> Boolean, ) { _status.value = CompanionPairingStatus.PushingServer(target.name, server.displayName) - transport.send(PairingMessage.PushServer(serverURL = server.url, serverName = server.displayName)) + transport.send( + PairingMessage.PushServer( + serverURL = server.url, + serverName = server.displayName, + serverIdentity = identitySource?.identity(server), + endpoints = identitySource?.endpoints(server), + ), + ) - val started = inbox.receiveFirst( - timeoutMs = deviceStartedTimeoutMs(requireUserConfirmation), - ) { it.serverURL == server.url } + // A TV can refuse the push before it starts a sign-in (a different + // server at that address, an expired code): it answers with the + // failed result instead, and the phone reports it at once. + val started = when ( + val first = inbox.receiveFirst(timeoutMs = deviceStartedTimeoutMs(requireUserConfirmation)) { + (it is PairingMessage.DeviceStarted && it.serverURL == server.url) || + (it is PairingMessage.ServerResult && it.serverURL == server.url) + } + ) { + is PairingMessage.DeviceStarted -> first + is PairingMessage.ServerResult -> + error(PairingFailureCode.fromWire(first.error).phoneMessage(target.name, server.displayName)) + else -> error("Unexpected TV setup message.") + } val lookup = when (val result = deviceLoginApprover.lookup(server, started.userCode)) { is ApiResult.Success -> result.data - is ApiResult.Error -> error(result.message.ifBlank { "Unable to verify TV setup code." }) - is ApiResult.NetworkError -> error(result.exception.message ?: "Unable to verify TV setup code.") + is ApiResult.Error -> error(approverFailure(result, "Unable to verify TV setup code.")) + is ApiResult.NetworkError -> error(approverFailure(result, "Unable to verify TV setup code.")) } val serverMatchCode = lookup.matchCode?.takeIf { it.isNotBlank() } ?: error("Server did not return a match code for TV setup.") if (serverMatchCode != started.matchCode) { deviceLoginApprover.deny(server, started.userCode) transport.send(PairingMessage.Cancel(reason = "match-code-mismatch")) - error("TV setup code mismatch. Nothing was approved.") + error("The codes didn't match. Nothing was approved.") } val approval = CompanionPairingApproval( targetName = target.name, serverName = server.displayName, - userCode = started.userCode, - tvMatchCode = started.matchCode, + userCode = lookup.userCode?.takeIf { it.isNotBlank() } ?: started.userCode, serverMatchCode = serverMatchCode, + serverHost = DeviceCodeFormat.host(server.url), ) if (requireUserConfirmation) { - _status.value = CompanionPairingStatus.AwaitingMatchConfirmation(approval) - if (!confirmFirstMatch(approval)) { + // The card names the account approving signs the TV in as, and + // nothing is approved for an account it couldn't name. The TV + // keeps its code, so "Try again" can pick it up. + val accountName = deviceLoginApprover.accountName(server) + if (accountName.isNullOrBlank()) { + transport.send(PairingMessage.Cancel(reason = "account-unknown")) + error("Couldn't confirm which account would sign in on ${target.name}. Try again.") + } + val shown = approval.copy(accountName = accountName) + _status.value = CompanionPairingStatus.AwaitingMatchConfirmation(shown) + if (!confirmFirstMatch(shown)) { deviceLoginApprover.deny(server, started.userCode) transport.send(PairingMessage.Cancel(reason = "user-cancelled")) - error("TV setup was cancelled.") + error("Cancelled. Nothing was approved.") } } _status.value = CompanionPairingStatus.Approving(target.name, server.displayName) when (val approved = deviceLoginApprover.approve(server, started.userCode)) { is ApiResult.Success -> Unit - is ApiResult.Error -> error(approved.message.ifBlank { "Unable to approve TV setup." }) - is ApiResult.NetworkError -> error(approved.exception.message ?: "Unable to approve TV setup.") + is ApiResult.Error -> error(approverFailure(approved, "Unable to approve TV setup.")) + is ApiResult.NetworkError -> error(approverFailure(approved, "Unable to approve TV setup.")) } val result = inbox.receiveFirst( timeoutMs = SERVER_RESULT_TIMEOUT_MS, ) { it.serverURL == server.url } if (result.status != PairingServerStatus.SignedIn) { - error(result.error ?: "The TV could not finish signing in.") + error(PairingFailureCode.fromWire(result.error).phoneMessage(target.name, server.displayName)) } } @@ -333,6 +491,11 @@ class CompanionPairingCoordinator( var found: T? = null while (found == null) { val message = receive() + // The TV withdrew (its user declined, or it left the screen): + // nothing it would have sent next is coming. + if (message is PairingMessage.Cancel && T::class != PairingMessage.Cancel::class) { + throw PeerCancelledException(message.reason) + } if (message is T && predicate(message)) { found = message } @@ -355,11 +518,39 @@ class CompanionPairingCoordinator( private fun deviceStartedTimeoutMs(isFirstServer: Boolean): Long = if (isFirstServer) FIRST_DEVICE_STARTED_TIMEOUT_MS else NEXT_DEVICE_STARTED_TIMEOUT_MS + /** The TV sent `cancel` while the phone waited for its next message. */ + private class PeerCancelledException(val reason: String) : IllegalStateException("peer cancelled: $reason") + private companion object { const val TAG = "CompanionPairing" + /** [PairingReceiver.denyPendingServer]'s reason when the TV user declines the push. */ + const val CONSENT_DENIED_REASON = "consent_denied" const val HELLO_TIMEOUT_MS = 15_000L const val FIRST_DEVICE_STARTED_TIMEOUT_MS = 90_000L const val NEXT_DEVICE_STARTED_TIMEOUT_MS = 30_000L const val SERVER_RESULT_TIMEOUT_MS = 30_000L } } + +/** + * The phone's text for a lookup or approval that failed. The sign-in + * provider being unreachable (503 `provider_unavailable` on the approver's + * refresh or answer) keeps this phone's session, so it says so instead of a + * raw reason. + */ +internal fun approverFailure(result: ApiResult.Error, fallback: String): String = + if (result.code == 503 && result.error == PrairieAuthUnavailableException.PROVIDER_UNAVAILABLE_PROBLEM) { + APPROVER_PROVIDER_UNAVAILABLE + } else { + result.message.ifBlank { fallback } + } + +internal fun approverFailure(result: ApiResult.NetworkError, fallback: String): String = when { + PrairieAuthUnavailableException.isProviderUnavailable(result.exception) -> APPROVER_PROVIDER_UNAVAILABLE + result.exception is PrairieAuthUnavailableException -> fallback + else -> result.exception.message ?: fallback +} + +internal const val APPROVER_PROVIDER_UNAVAILABLE = + "The sign-in provider can't be reached right now, so this device couldn't confirm your account. " + + "You're still signed in. Try again in a moment." diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pairing/CompanionPairingNsdBrowser.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pairing/CompanionPairingNsdBrowser.kt index 74340a51d9..d8597dd5bd 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pairing/CompanionPairingNsdBrowser.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pairing/CompanionPairingNsdBrowser.kt @@ -106,6 +106,7 @@ class CompanionPairingNsdBrowser(context: Context) { state = attributes.string("st"), version = version, sessionId = attributes.string("sid"), + serverIdentity = attributes.string("srv"), ) } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pairing/PairingAuthPort.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pairing/PairingAuthPort.kt index 5fe04937b5..9c8659cdb8 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pairing/PairingAuthPort.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pairing/PairingAuthPort.kt @@ -32,6 +32,8 @@ interface PairingAuthPort { refreshToken: String, expiresIn: Long, expectedIdentity: AccountSessionExpectation? = null, + /** Deployment identity verified at [serverUrl], recorded on the saved server. */ + verifiedServerId: String? = null, ) } @@ -52,6 +54,7 @@ class RegistryPairingAuthPort( refreshToken: String, expiresIn: Long, expectedIdentity: AccountSessionExpectation?, + verifiedServerId: String?, ): Unit = withContext(NonCancellable) { commitMutex.withLock { if (cleartextOriginConsent?.requiresApproval(serverUrl) == true) { @@ -101,6 +104,7 @@ class RegistryPairingAuthPort( // stale UPDATE_REQUIRED — so, like switchToServer, a replacement // probe is handed to the repository's background scope. authRepository?.refreshServerContractWithFallback(serverId) + if (verifiedServerId != null) serverRegistry.setVerifiedServerId(serverId, verifiedServerId) if (expectedIdentity != null && tokenManager.captureAccountSessionExpectation()?.generation != expectedIdentity.generation + 1) throw AccountSessionChangedException() } } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pairing/PairingReceiver.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pairing/PairingReceiver.kt index 8b706f6e81..a525588f5c 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pairing/PairingReceiver.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pairing/PairingReceiver.kt @@ -1,12 +1,19 @@ package org.prairieserver.prairie.common.pairing import android.util.Log +import org.prairieserver.prairie.model.auth.DeviceLoginPollResponse +import org.prairieserver.prairie.model.auth.DeviceLoginStartResponse +import org.prairieserver.prairie.network.api.ServerIdentityProbe +import org.prairieserver.prairie.pairing.PairingEndpoint +import org.prairieserver.prairie.pairing.PairingFailureCode import org.prairieserver.prairie.pairing.PairingMessage import org.prairieserver.prairie.pairing.PairingReceiverState import org.prairieserver.prairie.pairing.PairingProtocol import org.prairieserver.prairie.pairing.PairingServerStatus +import org.prairieserver.prairie.pairing.normalizeEndpointUrl import org.prairieserver.prairie.repository.DeviceLoginRepository import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin @@ -32,6 +39,51 @@ data class PairingDeviceIdentity( val platform: String = "android-tv", ) +/** How the sign-in screen's own request ended, relayed to the phone. */ +sealed interface NearbySignInOutcome { + data object SignedIn : NearbySignInOutcome + data class Failed(val code: PairingFailureCode) : NearbySignInOutcome +} + +/** + * The sign-in screen's own device request, lent to the LAN receiver in + * `login` mode. The phone approves the code the TV already shows, so there is + * only ever one code on screen; the sign-in screen polls, saves the session + * and routes on, and the receiver only relays the outcome. Mirrors the + * prairie-apple `NearbySignInCodeSource`. + */ +interface NearbySignInCodeSource { + /** The code on screen, waiting briefly while one is being fetched; null when none is available. */ + suspend fun codeForNearbyApproval(): DeviceLoginStartResponse? + + /** Waits until the request behind [deviceCode] signs in, fails, or is replaced by another code. */ + suspend fun nearbyApprovalOutcome(deviceCode: String): NearbySignInOutcome +} + +/** + * What the TV advertises while a screen accepts phones. + * + * - [Setup]: a first-run TV (or Add Server). Any signed-in phone may offer to + * set it up and choose which of its servers to push. + * - [login]: a TV on its sign-in screen for one server. TXT carries + * `st=login` and `srv=`; phones offer it only when they hold + * that server (matched by verified identity), and push only that one. The + * phone approves the code [source] already shows at [serverUrl]. + */ +data class PairingAdvertisement( + val state: PairingReceiverState, + val serverIdentity: String? = null, + val serverUrl: String? = null, + val source: NearbySignInCodeSource? = null, +) { + companion object { + val Setup = PairingAdvertisement(PairingReceiverState.Setup) + + fun login(serverIdentity: String, serverUrl: String, source: NearbySignInCodeSource) = + PairingAdvertisement(PairingReceiverState.Login, serverIdentity, serverUrl, source) + } +} + /** * UI-facing status of the TV pairing receiver. Mirrors the tvOS * `ReceiverPairingCoordinator.State`. @@ -48,21 +100,44 @@ sealed class PairingReceiverStatus { /** * A phone pushed a server and the TV user must allow it before the - * device-login (and its pairing code) starts — tvOS-parity consent step. + * device-login (and its sign-in code) starts — tvOS-parity consent step. */ data class ConsentRequested( val serverURL: String, val serverName: String, ) : PairingReceiverStatus() - /** Device-login started for a pushed server (interim). */ + /** Checking the server's address and starting device sign-in there (interim). */ data class Pairing(val serverURL: String, val serverName: String) : PairingReceiverStatus() - /** Showing the match code for a pushed server while the phone approves. */ + /** + * The pushed address didn't answer from this TV. [providerName] names the + * network provider behind it when the phone listed one; [alternateUrl] is + * a verified address of the same server the TV user may choose instead. + * Nothing switches without that choice. + */ + data class Unreachable( + val serverURL: String, + val serverName: String, + val providerName: String?, + val alternateUrl: String?, + ) : PairingReceiverStatus() + + /** + * Showing the TV's sign-in code for a pushed server while the phone + * approves. People compare [userCode] (the code the TV shows everywhere). + * [automatic] = a later server in a multi-server push, verified by the + * phone without asking. [matchCode] is the legacy match words: phones + * released before user codes show them instead, so a person-checked + * attempt names them in a fallback line (remove with the phones' + * "Older TV apps show ..." line). + */ data class AwaitingApproval( val serverURL: String, val serverName: String, - val matchCode: String, + val userCode: String, + val automatic: Boolean = false, + val matchCode: String? = null, ) : PairingReceiverStatus() /** A single server finished signing in; the phone may push more. */ @@ -71,8 +146,8 @@ sealed class PairingReceiverStatus { /** The phone sent Done after one or more servers signed in. */ data class Completed(val serverNames: List) : PairingReceiverStatus() - /** Terminal failure. */ - data class Failed(val serverName: String, val message: String) : PairingReceiverStatus() + /** Terminal failure for the last attempted server. */ + data class Failed(val serverName: String, val code: PairingFailureCode) : PairingReceiverStatus() } /** @@ -80,11 +155,16 @@ sealed class PairingReceiverStatus { * driven and transport-agnostic: it consumes one [PairingTransport] (real * TLS-PSK socket in production, in-memory fake in tests) and drives the flow: * - * 1. send [PairingMessage.Hello] (state = current setup/login). - * 2. on [PairingMessage.PushServer]: set the server via [AuthRepository], then - * run [DeviceLoginRepository.begin]; while it runs, observe its `state` — - * on Awaiting send [PairingMessage.DeviceStarted]; on Approved send - * [PairingMessage.ServerResult] signedIn; on Failed send `failed`. + * 1. send [PairingMessage.Hello] with the advertised state. + * 2. on [PairingMessage.PushServer]: ask the TV user once per session. In + * `setup` mode, pick the address to sign in at (identity-checked), run + * device login there and save the session. In `login` mode, check the + * pushed identity against the advertised one and lend the sign-in + * screen's own code ([NearbySignInCodeSource]). Either way send + * [PairingMessage.DeviceStarted], then [PairingMessage.ServerResult] + * with a [PairingFailureCode] on failure. A newer push replaces one in + * flight (the protocol is one server at a time; a new push means the + * phone gave up on the previous one). * 3. on [PairingMessage.Done] / [PairingMessage.Cancel] / EOF / error: finish. * * One [run] call drives exactly one connection. The real advertiser serializes @@ -96,7 +176,8 @@ class PairingReceiver( private val authPort: PairingAuthPort, private val deviceLogin: DeviceLoginPort, private val identityProvider: () -> PairingDeviceIdentity, - private val receiverStateProvider: () -> PairingReceiverState, + /** Classifies one address; production probes `GET /api/v2/system/identity`. */ + private val identityProbe: suspend (serverUrl: String) -> ServerIdentityProbe = { ServerIdentityProbe.Unreachable }, ) { private companion object { private const val TAG = "PairingReceiver" @@ -105,8 +186,9 @@ class PairingReceiver( private val _status = MutableStateFlow(PairingReceiverStatus.Idle) val status: StateFlow = _status.asStateFlow() - /** True while a server's device-login is in flight; the protocol is one at a time. */ - private var pollingServerUrl: String? = null + /** What the TV currently advertises; set by [TvPairingAdvertiser.start]. */ + @Volatile + var advertisement: PairingAdvertisement = PairingAdvertisement.Setup /** Push held until the TV user allows it (tvOS consent parity). */ private var pendingPush: PairingMessage.PushServer? = null @@ -121,27 +203,43 @@ class PairingReceiver( /** Session scope captured so [allowPendingServer] can launch the login. */ private var sessionScope: CoroutineScope? = null - /** The in-flight device-login child job, so EOF/Cancel teardown can cancel it. */ + /** The in-flight attempt, so a newer push, EOF or Cancel can cancel it. */ private var pushJob: Job? = null + /** The TV user's pending choice while [status] is [PairingReceiverStatus.Unreachable]. */ + private var alternateDecision: CompletableDeferred? = null + /** Active transport, so the UI can cancel the in-place receiver flow. */ private var activeTransport: PairingTransport? = null - private var signedInCount = 0 private val signedInNames = mutableListOf() + private enum class AlternateChoice { UseAlternate, Retry } + + /** An attempt failure with its wire code. */ + private class AttemptFailure(val code: PairingFailureCode) : Exception(code.wire) + + private data class LoginTarget(val url: String, val verifiedServerId: String?) + + /** Whether the advertiser is listening, so closing a session returns to Advertising rather than Idle. */ + @Volatile + private var listening = false + fun setAdvertising() { + listening = true _status.value = PairingReceiverStatus.Advertising } fun setIdle() { + listening = false _status.value = PairingReceiverStatus.Idle } + /** Cancel/Back/OK on the pairing panel: end the session, or close a failure left on screen. */ fun cancelActiveSession() { pushJob?.cancel() runCatching { activeTransport?.close() } - _status.value = PairingReceiverStatus.Idle + _status.value = if (listening) PairingReceiverStatus.Advertising else PairingReceiverStatus.Idle } /** @@ -156,7 +254,7 @@ class PairingReceiver( if (_status.value !is PairingReceiverStatus.ConsentRequested) return consented = true pendingPush = null - handlePushServer(push, transport, scope) + beginAttempt(push, transport, scope) } /** @@ -177,6 +275,19 @@ class PairingReceiver( _status.value = PairingReceiverStatus.Idle } + /** TV user chose the verified alternate address shown on the unreachable screen. */ + fun useAlternateAddress() { + val status = _status.value as? PairingReceiverStatus.Unreachable ?: return + if (status.alternateUrl == null) return + alternateDecision?.complete(AlternateChoice.UseAlternate) + } + + /** TV user fixed the connection (or set up the provider) and wants the pushed address tried again. */ + fun retryPushedAddress() { + if (_status.value !is PairingReceiverStatus.Unreachable) return + alternateDecision?.complete(AlternateChoice.Retry) + } + /** * Drive one connection to completion. Returns when the peer finishes * (Done), cancels, the stream ends, or an error is thrown. Always closes the @@ -184,13 +295,11 @@ class PairingReceiver( * the in-flight device-login and the transport is closed. */ suspend fun run(transport: PairingTransport) { - pollingServerUrl = null pushJob = null pendingPush = null consented = false sessionDenied = false activeTransport = transport - signedInCount = 0 signedInNames.clear() var preserveTerminalStatus = false try { @@ -200,7 +309,7 @@ class PairingReceiver( PairingMessage.Hello( tvName = identity.name, tvDeviceId = identity.deviceId, - state = receiverStateProvider(), + state = advertisement.state, supportedVersions = listOf(PairingProtocol.VERSION), ), ) @@ -220,19 +329,30 @@ class PairingReceiver( handlePushServer(message, transport, scope) is PairingMessage.Done -> { Log.i(TAG, "received pairing done") + // An in-flight server has no committed result; abandon it. pushJob?.cancelAndJoin() - if (signedInCount > 0) { - _status.value = PairingReceiverStatus.Completed(signedInNames.toList()) - preserveTerminalStatus = true - } else { - _status.value = PairingReceiverStatus.Idle + when { + signedInNames.isNotEmpty() -> { + _status.value = PairingReceiverStatus.Completed(signedInNames.toList()) + preserveTerminalStatus = true + } + // A lone failure keeps its explanation on screen. + _status.value is PairingReceiverStatus.Failed -> preserveTerminalStatus = true + else -> _status.value = PairingReceiverStatus.Idle } return@collectMessage false } is PairingMessage.Cancel -> { Log.i(TAG, "received pairing cancel") pushJob?.cancelAndJoin() - _status.value = PairingReceiverStatus.Idle + if (signedInNames.isNotEmpty()) { + // A peer timeout can race the persistence boundary. + // Never discard a sign-in that already committed. + _status.value = PairingReceiverStatus.Completed(signedInNames.toList()) + preserveTerminalStatus = true + } else { + _status.value = PairingReceiverStatus.Idle + } return@collectMessage false } else -> { @@ -243,168 +363,346 @@ class PairingReceiver( } // Stream ended (Done/Cancel/EOF) — cancel any in-flight device-login. pushJob?.cancel() + if (!preserveTerminalStatus) { + if (signedInNames.isNotEmpty()) { + _status.value = PairingReceiverStatus.Completed(signedInNames.toList()) + preserveTerminalStatus = true + } else if (_status.value is PairingReceiverStatus.Failed) { + preserveTerminalStatus = true + } + } } } catch (e: CancellationException) { throw e } catch (_: Throwable) { // Transport / decode error: connection dropped mid-session. + if (signedInNames.isNotEmpty()) { + _status.value = PairingReceiverStatus.Completed(signedInNames.toList()) + preserveTerminalStatus = true + } } finally { - pushJob?.cancel() - pushJob = null - pendingPush = null - sessionScope = null + // A newer connection may own the receiver already (the advertiser + // restarted while this one unwound): its session, push and status + // are not this run's to clear. if (activeTransport === transport) { + pushJob?.cancel() + pushJob = null + pendingPush = null + sessionScope = null + alternateDecision?.cancel() + alternateDecision = null activeTransport = null - } - if (!preserveTerminalStatus) { - _status.value = PairingReceiverStatus.Idle + if (!preserveTerminalStatus) { + _status.value = PairingReceiverStatus.Idle + } } transport.close() } } - /** - * Start device-login against the pushed (not-yet-trusted) server and drive - * its outcome back to the phone. One at a time — an overlapping PushServer - * while a poll is in flight is ignored. - */ - private fun handlePushServer( + private suspend fun handlePushServer( message: PairingMessage.PushServer, transport: PairingTransport, sessionScope: CoroutineScope, ) { - if (pollingServerUrl != null) return // one at a time; ignore overlap. if (sessionDenied) return // denied: session is tearing down. - val serverURL = message.serverURL - val serverName = message.serverName?.takeIf { it.isNotBlank() } ?: serverURL Log.i(TAG, "received pushed server") + // The protocol is one server at a time: a new push while one is in + // flight means the phone gave up on the previous server — supersede + // it, don't ignore the push (prairie-apple parity). + pushJob?.cancelAndJoin() + pushJob = null // tvOS parity: the first push of a session needs the TV user's consent - // before device-login starts (and before any pairing code shows). A + // before device-login starts (and before any sign-in code shows). A // newer push while consent is pending supersedes the earlier one. if (!consented) { pendingPush = message - _status.value = PairingReceiverStatus.ConsentRequested(serverURL, serverName) + _status.value = PairingReceiverStatus.ConsentRequested(message.serverURL, message.displayName()) return } - pollingServerUrl = serverURL - // Run the device-login begin/await CONCURRENTLY with continued inbound - // reading so Cancel/EOF can cancel this job and tear the session down. + beginAttempt(message, transport, sessionScope) + } + + private fun beginAttempt( + push: PairingMessage.PushServer, + transport: PairingTransport, + sessionScope: CoroutineScope, + ) { + // "Automatic" only once a sign-in has been COMMITTED: the phone + // auto-approves only after its user confirmed the first code. + val automatic = signedInNames.isNotEmpty() + val ad = advertisement pushJob = sessionScope.launch { - try { - runDeviceLogin( - serverURL = serverURL, - serverName = serverName, - fetchedName = message.serverName, - transport = transport, - ) - } finally { - deviceLogin.reset() - pollingServerUrl = null + if (ad.state == PairingReceiverState.Login) { + runLoginAttempt(push, transport, ad) + } else { + try { + runAttempt(push, transport, automatic) + } finally { + deviceLogin.reset() + } } } } - private suspend fun runDeviceLogin( - serverURL: String, - serverName: String, - fetchedName: String?, + /** + * `login` mode: only the advertised server is accepted, by the identity + * the phone pushed (a push without one can't be checked, so it's + * refused), and the phone approves the code the sign-in screen already + * shows. That screen polls, saves the session and routes on; this only + * relays the outcome. No second request is started, so there is nothing + * of this attempt's own to withdraw. + */ + private suspend fun runLoginAttempt( + push: PairingMessage.PushServer, transport: PairingTransport, + ad: PairingAdvertisement, ) { - _status.value = PairingReceiverStatus.Pairing(serverURL, serverName) - - val expectedIdentity = authPort.captureExpectedIdentity() ?: error("Unable to capture the current sign-in context") - val identity = identityProvider() - var deviceStartedSent = false - - coroutineScope { - // Observe the device-login state machine while begin() runs the - // poll loop. Cancel the collector once we reach a terminal state. - val observer = launch { - deviceLogin.state.collectMessage { state -> - when (state) { - is DeviceLoginRepository.DeviceLoginState.Awaiting -> { - if (!deviceStartedSent) { - deviceStartedSent = true - val session = state.session - _status.value = PairingReceiverStatus.AwaitingApproval( - serverURL = serverURL, - serverName = serverName, - matchCode = session.matchCode, - ) - transport.send( - PairingMessage.DeviceStarted( - serverURL = serverURL, - userCode = session.userCode, - matchCode = session.matchCode, - ), - ) - Log.i(TAG, "sent device-started") - } - true - } - is DeviceLoginRepository.DeviceLoginState.Approved -> { - // Persist the approved session's tokens BEFORE - // signaling SignedIn — same as the credential / QR - // login flow (TvLoginViewModel.handleDeviceLoginApproved). - // Without this the TV navigates to profile selection - // unauthenticated. The repository already guards - // against null tokens (Failed.MissingTokens). - val response = state.response - val accessToken = response.accessToken - val refreshToken = response.refreshToken - if (!accessToken.isNullOrBlank() && !refreshToken.isNullOrBlank()) { - authPort.persistApprovedSession( - serverUrl = serverURL, - serverName = fetchedName, - accessToken = accessToken, - refreshToken = refreshToken, - expiresIn = response.expiresIn ?: 0L, - expectedIdentity = expectedIdentity, - ) - } - signedInCount += 1 - signedInNames += serverName - _status.value = PairingReceiverStatus.SignedIn(signedInCount) - transport.send( - PairingMessage.ServerResult( - serverURL = serverURL, - status = PairingServerStatus.SignedIn, - error = null, - ), - ) - Log.i(TAG, "sent signed-in result") - false // terminal — stop observing. - } - is DeviceLoginRepository.DeviceLoginState.Failed -> { - _status.value = PairingReceiverStatus.Failed( - serverName = serverName, - message = state.message ?: state.reason.name, - ) - transport.send( - PairingMessage.ServerResult( - serverURL = serverURL, - status = PairingServerStatus.Failed, - error = state.message ?: state.reason.name, - ), - ) - Log.i(TAG, "sent failed result") - false // terminal — stop observing. - } - else -> true // Idle / Initiating — keep observing. + val pushedUrl = push.serverURL + val displayName = push.displayName() + try { + val source = ad.source ?: throw AttemptFailure(PairingFailureCode.AuthFailed) + val pushedIdentity = push.serverIdentity?.trim()?.takeIf { it.isNotEmpty() } + if (ad.serverIdentity == null || pushedIdentity != ad.serverIdentity) { + throw AttemptFailure(PairingFailureCode.IdentityMismatch) + } + _status.value = PairingReceiverStatus.Pairing(pushedUrl, displayName) + val code = source.codeForNearbyApproval() ?: throw AttemptFailure(PairingFailureCode.Expired) + _status.value = PairingReceiverStatus.AwaitingApproval( + serverURL = pushedUrl, + serverName = displayName, + userCode = code.userCode, + matchCode = code.matchCode, + ) + transport.send( + PairingMessage.DeviceStarted(serverURL = pushedUrl, userCode = code.userCode, matchCode = code.matchCode), + ) + Log.i(TAG, "sent device-started for the sign-in screen's code") + when (val outcome = source.nearbyApprovalOutcome(code.deviceCode)) { + NearbySignInOutcome.SignedIn -> reportSignedIn(pushedUrl, displayName, transport) + is NearbySignInOutcome.Failed -> throw AttemptFailure(outcome.code) + } + } catch (failure: AttemptFailure) { + reportFailure(pushedUrl, displayName, failure.code, transport) + } + } + + private suspend fun reportSignedIn(pushedUrl: String, displayName: String, transport: PairingTransport) { + // An attempt that outlived its connection (the session save can't be + // cancelled) must not report into the connection that replaced it. + if (activeTransport !== transport) return + signedInNames += displayName + _status.value = PairingReceiverStatus.SignedIn(signedInNames.size) + // Best-effort: the tokens are committed, so a lost confirmation + // frame must not repaint a real sign-in as a failure. + runCatching { + transport.send( + PairingMessage.ServerResult( + serverURL = pushedUrl, + status = PairingServerStatus.SignedIn, + error = null, + ), + ) + } + Log.i(TAG, "sent signed-in result") + } + + private suspend fun reportFailure( + pushedUrl: String, + displayName: String, + code: PairingFailureCode, + transport: PairingTransport, + ) { + if (activeTransport !== transport) return + Log.i(TAG, "server pairing failed: ${code.wire}") + _status.value = PairingReceiverStatus.Failed(displayName, code) + runCatching { + transport.send( + PairingMessage.ServerResult( + serverURL = pushedUrl, + status = PairingServerStatus.Failed, + error = code.wire, + ), + ) + } + } + + private suspend fun runAttempt( + push: PairingMessage.PushServer, + transport: PairingTransport, + automatic: Boolean, + ) { + // Every frame back to the phone names the PUSHED address exactly as + // sent, whatever address this TV ends up using: phones key their + // per-server state on the URL they sent. + val pushedUrl = push.serverURL + val displayName = push.displayName() + try { + _status.value = PairingReceiverStatus.Pairing(pushedUrl, displayName) + val target = resolveLoginTarget(push, displayName) + val expectedIdentity = authPort.captureExpectedIdentity() + ?: throw AttemptFailure(PairingFailureCode.AuthFailed) + val device = identityProvider() + + val terminal = coroutineScope { + var deviceStartedSent = false + val observer = launch { + deviceLogin.state.collectMessage { state -> + if (state is DeviceLoginRepository.DeviceLoginState.Awaiting && !deviceStartedSent) { + deviceStartedSent = true + val session = state.session + _status.value = PairingReceiverStatus.AwaitingApproval( + serverURL = pushedUrl, + serverName = displayName, + userCode = session.userCode, + automatic = automatic, + matchCode = session.matchCode, + ) + transport.send( + PairingMessage.DeviceStarted( + serverURL = pushedUrl, + userCode = session.userCode, + matchCode = session.matchCode, + ), + ) + Log.i(TAG, "sent device-started") } + true } } - deviceLogin.begin( - serverUrl = serverURL, - deviceName = identity.name, - devicePlatform = identity.platform, + serverUrl = target.url, + deviceName = device.name, + devicePlatform = device.platform, ) - // begin() has reached a terminal state; let the observer drain it - // (it stops itself on the terminal value). - observer.join() + observer.cancel() + deviceLogin.state.value } + + val approved = when (terminal) { + is DeviceLoginRepository.DeviceLoginState.Approved -> terminal.response + is DeviceLoginRepository.DeviceLoginState.Failed -> throw AttemptFailure(terminal.reason.pairingFailure()) + else -> throw AttemptFailure(PairingFailureCode.AuthFailed) + } + persist(approved, target, push, expectedIdentity) + reportSignedIn(pushedUrl, displayName, transport) + } catch (failure: AttemptFailure) { + reportFailure(pushedUrl, displayName, failure.code, transport) + } + } + + /** + * Commit the approved tokens. A failed commit is a failed attempt, and the + * phone is told so: the TV must never sit on "almost there" while the phone + * waits for a result that isn't coming (prairie-apple#554). + */ + private suspend fun persist( + response: DeviceLoginPollResponse, + target: LoginTarget, + push: PairingMessage.PushServer, + expectedIdentity: org.prairieserver.prairie.network.AccountSessionExpectation, + ) { + val accessToken = response.accessToken + val refreshToken = response.refreshToken + if (accessToken.isNullOrBlank() || refreshToken.isNullOrBlank()) { + throw AttemptFailure(PairingFailureCode.AuthFailed) + } + try { + authPort.persistApprovedSession( + serverUrl = target.url, + serverName = push.serverName, + accessToken = accessToken, + refreshToken = refreshToken, + expiresIn = response.expiresIn ?: 0L, + expectedIdentity = expectedIdentity, + verifiedServerId = target.verifiedServerId, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + Log.w(TAG, "pairing persist failed", e) + throw AttemptFailure(PairingFailureCode.AuthFailed) + } } + + /** + * The address to run device authorization at (`setup` mode). + * + * With an identity, the pushed address must answer with that + * identity from this TV. If it doesn't answer at all, the server's other + * addresses are checked for the same identity and the first match is + * OFFERED, never taken. An address answering with a different identity is + * never used. Legacy pushes (no identity) use the pushed address exactly. + */ + private suspend fun resolveLoginTarget(push: PairingMessage.PushServer, displayName: String): LoginTarget { + val pushedIdentity = push.serverIdentity?.trim()?.takeIf { it.isNotEmpty() } + if (pushedIdentity == null) return LoginTarget(push.serverURL, null) + + val pushedUrl = normalizeEndpointUrl(push.serverURL) + val endpoints = push.endpoints.orEmpty() + while (true) { + when (val probe = identityProbe(pushedUrl)) { + is ServerIdentityProbe.Identity -> + if (probe.serverId == pushedIdentity) { + return LoginTarget(push.serverURL, pushedIdentity) + } else { + // The phone verified this identity at this very address; + // a different answer from here is not the server it meant. + throw AttemptFailure(PairingFailureCode.IdentityMismatch) + } + ServerIdentityProbe.UnsupportedServer -> throw AttemptFailure(PairingFailureCode.IdentityMismatch) + ServerIdentityProbe.Unreachable -> Unit + } + + var alternate: PairingEndpoint? = null + for (endpoint in endpoints) { + if (endpoint.url == pushedUrl) continue + val answer = identityProbe(endpoint.url) + if (answer is ServerIdentityProbe.Identity && answer.serverId == pushedIdentity) { + alternate = endpoint + break + } + } + val provider = endpoints.firstOrNull { it.kind == PairingEndpoint.Kind.Provider && it.url == pushedUrl } + val decision = CompletableDeferred() + alternateDecision = decision + _status.value = PairingReceiverStatus.Unreachable( + serverURL = push.serverURL, + serverName = displayName, + providerName = provider?.displayName?.takeIf { it.isNotBlank() }, + alternateUrl = alternate?.url, + ) + val choice = try { + decision.await() + } finally { + alternateDecision = null + } + _status.value = PairingReceiverStatus.Pairing(push.serverURL, displayName) + if (choice == AlternateChoice.UseAlternate && alternate != null) { + return LoginTarget(alternate.url, pushedIdentity) + } + } + } + + private fun PairingMessage.PushServer.displayName(): String = + serverName?.takeIf { it.isNotBlank() } ?: normalizeEndpointUrl(serverURL) +} + +/** The wire code for a failed device-login attempt. */ +internal fun DeviceLoginRepository.FailureReason.pairingFailure(): PairingFailureCode = when (this) { + DeviceLoginRepository.FailureReason.Denied -> PairingFailureCode.Denied + DeviceLoginRepository.FailureReason.Expired, + DeviceLoginRepository.FailureReason.Consumed, + DeviceLoginRepository.FailureReason.Canceled, + -> PairingFailureCode.Expired + DeviceLoginRepository.FailureReason.Unreachable -> PairingFailureCode.Unreachable + DeviceLoginRepository.FailureReason.UpdateRequired -> PairingFailureCode.UpdateRequired + DeviceLoginRepository.FailureReason.StartFailed, + DeviceLoginRepository.FailureReason.RateLimited, + DeviceLoginRepository.FailureReason.Unsupported, + DeviceLoginRepository.FailureReason.MissingTokens, + DeviceLoginRepository.FailureReason.UnknownStatus, + -> PairingFailureCode.AuthFailed } /** diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pairing/TvPairingAdvertiser.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pairing/TvPairingAdvertiser.kt index 7f280cddd6..82f4155a0f 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pairing/TvPairingAdvertiser.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pairing/TvPairingAdvertiser.kt @@ -25,15 +25,17 @@ import java.util.concurrent.atomic.AtomicBoolean * wiring. * * TXT record carries: v (protocol version), name (device name), id (stable - * device id), sid (fresh nonce per [start]), st (receiver state). + * device id), sid (fresh nonce per [start]), st (receiver state) and, for a + * signed-out TV (`st=login`), srv (the deployment identity of the server it + * wants, from `GET /api/v2/system/identity`). Phones offer a `login` TV only + * when they hold a saved server with that verified identity. * - * UI (a later step) observes [PairingReceiver.status]. This class owns only the + * The UI observes [PairingReceiver.status]. This class owns only the * networking lifecycle. */ class TvPairingAdvertiser( private val context: Context, private val receiver: PairingReceiver, - private val receiverStateProvider: () -> PairingReceiverState, ) { private companion object { private const val TAG = "TvPairingAdvertiser" @@ -52,22 +54,53 @@ class TvPairingAdvertiser( /** True between [start] and [stop]; gates restoring Advertising after a connection. */ private val running = AtomicBoolean(false) - /** Start advertising and accepting pairing connections. Idempotent restart. */ + /** + * Which [start] call owns the advertiser. Two screens use it (server setup + * and sign-in) and navigation composes both during a transition, so the + * leaving screen's [stop] must not tear down what the arriving one started. + */ + private var owner = 0L + + /** + * Start advertising [advertisement] and accepting pairing connections. + * Idempotent restart: the server-setup screen advertises + * [PairingAdvertisement.Setup]; the sign-in screen advertises + * [PairingAdvertisement.login] once it knows its server's identity. + */ @Synchronized - fun start() { + fun start(advertisement: PairingAdvertisement = PairingAdvertisement.Setup): Long { stop() + val token = ++owner + receiver.advertisement = advertisement running.set(true) val identity = currentIdentity() val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO).also { this.scope = it } scope.launch { - val socket = ServerSocket(0).also { serverSocket = it } - val port = socket.localPort - registerService(port, identity) - Log.i(TAG, "started pairing listener on port $port") - receiver.setAdvertising() - acceptLoop(socket) + val socket = ServerSocket(0) + // A later start() or stop() may have run while the socket opened: + // then this startup publishes nothing and closes its socket. + val published = synchronized(this@TvPairingAdvertiser) { + if (token != owner || !running.get()) return@synchronized false + serverSocket = socket + registerService(socket.localPort, identity) + receiver.setAdvertising() + true + } + if (!published) { + runCatching { socket.close() } + return@launch + } + Log.i(TAG, "started pairing listener on port ${socket.localPort}") + acceptLoop(socket, token) } + return token + } + + /** Stop only if the [start] that returned [token] still owns the advertiser. */ + @Synchronized + fun stop(token: Long) { + if (token == owner) stop() } /** Stop advertising, close the listening socket, and release any session. */ @@ -86,7 +119,7 @@ class TvPairingAdvertiser( receiver.setIdle() } - private suspend fun acceptLoop(socket: ServerSocket) { + private suspend fun acceptLoop(socket: ServerSocket, token: Long) { while (true) { val client: Socket = try { withContext(Dispatchers.IO) { socket.accept() } @@ -116,30 +149,45 @@ class TvPairingAdvertiser( Log.w(TAG, "pairing connection failed", t) runCatching { client.close() } } finally { - // Release for the next peer and resume advertising state — - // but only if we're still running. After stop()/cancellation - // the NSD + socket are down, so leave the receiver Idle rather - // than falsely claiming Advertising. Completed is a terminal - // UI dwell state; the setup screen advances after showing it. - busy.set(false) - if (receiver.status.value is PairingReceiverStatus.Completed) { - running.set(false) - runCatching { socket.close() } - if (serverSocket === socket) { - serverSocket = null - } - registrationListener?.let { listener -> - runCatching { nsdManager.unregisterService(listener) } - } - registrationListener = null - } else if (running.get()) { - receiver.setAdvertising() - } + finishConnection(socket, token) } } } } + /** + * Release for the next peer and resume advertising state, but only if + * we're still running. After stop()/cancellation the NSD + socket are + * down, so leave the receiver Idle rather than falsely claiming + * Advertising. Completed is a terminal UI dwell state; the setup screen + * advances after showing it. A lone Failed stays on screen with its + * explanation until the person closes it + * (PairingReceiver.cancelActiveSession). + * + * A connection whose [start] no longer owns the advertiser touches + * nothing: a later [start] already reset it, and the listener, busy flag + * and receiver state are that owner's now. + */ + @Synchronized + private fun finishConnection(socket: ServerSocket, token: Long) { + if (token != owner) return + busy.set(false) + val ended = receiver.status.value + if (ended is PairingReceiverStatus.Completed) { + running.set(false) + runCatching { socket.close() } + if (serverSocket === socket) { + serverSocket = null + } + registrationListener?.let { listener -> + runCatching { nsdManager.unregisterService(listener) } + } + registrationListener = null + } else if (running.get() && resumesAdvertisingAfterConnection(ended)) { + receiver.setAdvertising() + } + } + private fun registerService(port: Int, identity: PairingDeviceIdentity) { val sid = UUID.randomUUID().toString() val serviceInfo = NsdServiceInfo().apply { @@ -150,7 +198,11 @@ class TvPairingAdvertiser( setAttribute("name", identity.name) setAttribute("id", identity.deviceId) setAttribute("sid", sid) - setAttribute("st", receiverStateProvider().wire) + val advertisement = receiver.advertisement + setAttribute("st", advertisement.state.wire) + if (advertisement.state == PairingReceiverState.Login) { + advertisement.serverIdentity?.let { setAttribute("srv", it) } + } } val listener = object : NsdManager.RegistrationListener { override fun onServiceRegistered(info: NsdServiceInfo) {} @@ -173,3 +225,11 @@ class TvPairingAdvertiser( return PairingDeviceIdentity(name = name, deviceId = PairingDeviceId.stable(context)) } } + +/** + * Whether the receiver goes back to Advertising when a connection ends with + * [ended]. Completed dwells and then leaves the screen; a lone Failed keeps + * its explanation on screen until the person closes it. + */ +internal fun resumesAdvertisingAfterConnection(ended: PairingReceiverStatus): Boolean = + ended !is PairingReceiverStatus.Completed && ended !is PairingReceiverStatus.Failed diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pip/PrairiePictureInPictureCoordinator.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pip/PrairiePictureInPictureCoordinator.kt index abc45ed8d8..687958d946 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pip/PrairiePictureInPictureCoordinator.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pip/PrairiePictureInPictureCoordinator.kt @@ -121,6 +121,33 @@ class PrairiePictureInPictureCoordinator( } } + /** + * Whether the player may show its own Picture in Picture button. Unlike + * auto-enter on leaving the app, a viewer who asks for PiP gets it while + * paused too, so playing is not required here. + */ + fun canOfferPictureInPicture(activity: Activity, surface: PrairiePictureInPictureSurface): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false + val state = stateBySurface[surface] ?: return false + return prairieCanEnterPictureInPicture( + surface = surface, + sdkInt = Build.VERSION.SDK_INT, + deviceSupportsPictureInPicture = activity.supportsPictureInPicture(), + enabled = state.enabled, + videoActive = state.videoActive, + isPlaying = true, + ) + } + + /** Enters PiP because the viewer pressed the player's PiP button. */ + fun enterPictureInPictureOnRequest(activity: Activity, surface: PrairiePictureInPictureSurface): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false + if (!canOfferPictureInPicture(activity, surface)) return false + val state = stateBySurface[surface] ?: return false + activity.enterPictureInPictureMode(buildParams(activity, surface, state)) + return true + } + fun enterPictureInPictureIfEligible( activity: Activity, surface: PrairiePictureInPictureSurface, diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/AudioCapabilityManager.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/AudioCapabilityManager.kt index 7f97b5ef73..154e71c03d 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/AudioCapabilityManager.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/AudioCapabilityManager.kt @@ -15,6 +15,7 @@ import androidx.media3.common.C import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.audio.AudioCapabilities import androidx.media3.exoplayer.audio.AudioCapabilitiesReceiver +import org.prairieserver.prairie.common.player.audio.HdmiSinkDtsSupport import org.prairieserver.prairie.model.playback.AudioPassthroughCapabilities import org.prairieserver.prairie.model.playback.AudioPassthroughEntry import kotlinx.coroutines.flow.MutableStateFlow @@ -224,8 +225,13 @@ class AudioCapabilityManager( } private fun mapCapabilities(caps: AudioCapabilities): AudioPassthroughCapabilities { + // The platform can accept DTS for a sink that does not decode it (an + // Amlogic box in front of an LG 2025 TV); never claim passthrough there. + val sinkDecodesDts by lazy { HdmiSinkDtsSupport.hdmiSinkDecodesDts(appContext) } val supportedEncodings = encodingSupport.filter { support -> - Build.VERSION.SDK_INT >= support.minSdk && caps.supportsEncoding(support.encoding) + Build.VERSION.SDK_INT >= support.minSdk && + caps.supportsEncoding(support.encoding) && + (support.codec !in DTS_CODECS || sinkDecodesDts) } val codecs = supportedEncodings.map(EncodingSupport::codec) @@ -428,6 +434,7 @@ class AudioCapabilityManager( private companion object { const val TAG = "AudioCapabilityMgr" const val ROUTE_HASH_BYTES = 16 + val DTS_CODECS = setOf("dts", "dts_hd") val encodingSupport = listOf( EncodingSupport("ac3", AudioFormat.ENCODING_AC3), diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/HdrModeSelection.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/HdrModeSelection.kt deleted file mode 100644 index 1f974bea8e..0000000000 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/HdrModeSelection.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.prairieserver.prairie.common.player - -enum class HdrType { SDR, HDR10, HDR10_PLUS, HLG, DOLBY_VISION } - -/** - * Pure HDR target selection: honor the content's HDR type only when the display - * advertises it; otherwise fall back to SDR (tone-mapped). The Android read of - * Display.Mode.getSupportedHdrTypes (API-34+) feeds [displaySupported] at the - * call site; below API-34 the platform negotiates HDR implicitly and the caller - * passes an empty set so this returns SDR — we never force an unsupported mode. - */ -object HdrModeSelection { - fun choose(contentHdr: HdrType, displaySupported: Set): HdrType = when { - contentHdr == HdrType.SDR -> HdrType.SDR - contentHdr in displaySupported -> contentHdr - else -> HdrType.SDR - } -} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackCapabilityDetector.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackCapabilityDetector.kt index bb793a5f98..3129186a43 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackCapabilityDetector.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackCapabilityDetector.kt @@ -4,7 +4,6 @@ import android.app.UiModeManager import android.content.Context import android.content.res.Configuration import android.media.MediaCodecList -import android.media.MediaFormat import android.os.Build import androidx.annotation.OptIn import androidx.compose.runtime.RememberObserver @@ -32,6 +31,8 @@ import org.prairieserver.prairie.model.playback.CLIENT_DV7_TO_DV81 import org.prairieserver.prairie.model.playback.CLIENT_DV7_TO_HDR10 import org.prairieserver.prairie.model.playback.CLIENT_DV_TRANSFORM_RECIPE_VERSION import org.prairieserver.prairie.model.playback.NATIVE_HLS_PLAYBACK_V1_FEATURE +import org.prairieserver.prairie.model.playback.NATIVE_EMBEDDED_SUBTITLE_CODECS +import org.prairieserver.prairie.model.playback.NativeEmbeddedSubtitleCapability import org.prairieserver.prairie.model.playback.CLIENT_SELECTED_AUDIO_TRACK_V1_CLAIM import org.prairieserver.prairie.model.playback.CLIENT_DV8_BASE_LAYER_FALLBACK_V1_CLAIM import org.prairieserver.prairie.model.playback.HdrCapabilities @@ -40,7 +41,6 @@ import org.prairieserver.prairie.model.playback.PlaybackTransformationExecutor import org.prairieserver.prairie.model.playback.PlaybackTransformationV3 import org.prairieserver.prairie.model.playback.PlaybackOutputContext import org.prairieserver.prairie.model.playback.AudioPassthroughCapabilities -import org.prairieserver.prairie.model.playback.AudioPassthroughEntry import kotlinx.coroutines.flow.StateFlow import org.prairieserver.prairie.libass.LibassBridge @@ -173,13 +173,6 @@ class PlaybackCapabilityDetector( } } - /** Decoder-only HDR facts from the most recent [detect], for diagnostics. */ - @Volatile - private var lastDecoderHdr: HdrCapabilities? = null - - /** Decoder-only HDR support independent of the attached display. */ - val decoderHdrCapabilities: HdrCapabilities? - get() = lastDecoderHdr /** * Inspect the resolved [Tracks] object (emitted by `Player.Listener.onTracksChanged`) * and declare whether direct play can proceed. Looks at the selected video @@ -357,7 +350,6 @@ class PlaybackCapabilityDetector( display = displayProbe.hdr, ).withDolbyVisionPolicy(dolbyVision) lastDisplayProbe = displayProbe - lastDecoderHdr = codecProbe.hdr.withDolbyVisionPolicy(dolbyVision) val platformAudio = detectPlatformSoftwareAudioCodecs() val ffmpegAudio = if (ffmpegAvailable) { @@ -528,11 +520,22 @@ class PlaybackCapabilityDetector( maxChannels = passthrough?.maxChannels, hdrDetails = caps.hdrDetails, subtitles = DeliverySubtitleCapabilities( - nativeEmbedded = listOf(org.prairieserver.prairie.model.playback.NativeEmbeddedSubtitleCapability( - container = "mp4", - codecs = listOf("mov_text"), - trackIdentity = "container_track_id", - )), + // MP4 and MatroskaExtractor both set each track's + // Format.id to the container track ID the server + // records. libass's Matroska extractor subclass + // forwards formats unchanged, so ASS keeps the same + // identity; its flags tell the planner whether the + // stream path keeps styling and attached fonts. + nativeEmbedded = NATIVE_EMBEDDED_SUBTITLE_CODECS.map { (container, codecs) -> + val ass = "ass" in codecs + NativeEmbeddedSubtitleCapability( + container = container, + codecs = codecs, + trackIdentity = "container_track_id", + assStyling = ass && libassDirectFidelity, + fontAttachments = ass && libassEmbeddedFonts, + ) + }, embeddedText = true, sidecarText = true, assStyling = libassDirectFidelity, diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackRecoverySettings.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackRecoverySettings.kt deleted file mode 100644 index 4b7bd966a4..0000000000 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackRecoverySettings.kt +++ /dev/null @@ -1,41 +0,0 @@ -package org.prairieserver.prairie.common.player - -import androidx.compose.runtime.* -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.launch -import org.prairieserver.prairie.network.ApiResult -import org.prairieserver.prairie.repository.PlaybackRepository - -class PlaybackRecoverySettings { - var visible by mutableStateOf(false) - var busy by mutableStateOf(false) - var message by mutableStateOf("Retry unfinished playback requests and confirm they have stopped.") - var retry: () -> Unit = {} -} - -/** An explicit user action; entering Settings only reads the local journal. */ -@Composable -fun rememberPlaybackRecoverySettings(repository: PlaybackRepository): PlaybackRecoverySettings { - val pending by repository.pendingPlayback.collectAsState() - val scope = rememberCoroutineScope() - val state = remember { PlaybackRecoverySettings() } - LaunchedEffect(pending) { - try { state.visible = repository.pendingPlaybackCount() > 0 } - catch (e: CancellationException) { throw e } - catch (_: Exception) { state.visible = true; state.message = "Playback recovery storage is unavailable." } - } - state.retry = { - if (!state.busy) scope.launch { - state.busy = true - try { - state.message = when (val result = repository.recoverPlayback()) { - is ApiResult.Success -> if (repository.pendingPlaybackCount() == 0) "Playback stops confirmed." - else "Some playback requests remain unavailable for recovery." - is ApiResult.Error -> result.message - is ApiResult.NetworkError -> "Playback recovery could not reach the server. Retry when connected." - } - } finally { state.busy = false } - } - } - return state -} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycle.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycle.kt index 3e17a31850..3aa93c8983 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycle.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycle.kt @@ -102,8 +102,6 @@ class PlaybackSessionLifecycle( /** Session [pendingStopJob] is stopping; guarded by `pendingStopLock`. */ private var pendingStopSessionId: String? = null - private val externalFinalizationLock = Any() - private val pendingExternalFinalizations = mutableMapOf() private data class ActiveSessionSnapshot( val state: SessionState, @@ -358,20 +356,6 @@ class PlaybackSessionLifecycle( true } - suspend fun confirmActiveSessionPublication(sessionId: String): Boolean = - settlePendingPublicationIfCurrent( - sessionId = sessionId, - confirm = true, - settleManager = { true }, - ) - - suspend fun rollbackUnpublishedActiveSession(sessionId: String): Boolean = - settlePendingPublicationIfCurrent( - sessionId = sessionId, - confirm = false, - settleManager = { true }, - ) - private fun captureActiveSessionSnapshot(): ActiveSessionSnapshot = ActiveSessionSnapshot( state = _state.value, @@ -572,21 +556,16 @@ class PlaybackSessionLifecycle( flushFinalProgress() } - var pendingStop = false + // A failed stop stays in the playback journal, which retries it before the next + // start; the server expires the session if that never comes. if (sessionId != null && stopActiveSessionOnStop) { - val r = sessionManager.stopSession(sessionId) - pendingStop = sessionManager.isSequenced(sessionId) && r !is ApiResult.Success - when (r) { + when (val r = sessionManager.stopSession(sessionId)) { is ApiResult.Error -> Log.w(TAG, "stopSession error: ${r.code} ${r.message}") is ApiResult.NetworkError -> Log.w(TAG, "stopSession network error: ${r.exception}") else -> {} } } - if (pendingStop) { - _state.value = SessionState.Failed("Playback stop is pending. Retry from playback recovery.") - return@withLock false // Retain the old session and clocks until its terminal receipt. - } lastStartParams = null lastReportedPosition = null lastReportedDuration = 0.0 @@ -673,47 +652,11 @@ class PlaybackSessionLifecycle( } /** - * Reports and stops a session that remains externally owned. - * - * This does not adopt the session or mutate lifecycle-owned playback state. - * The application scope outlives the external owner, while [NonCancellable] - * and IO dispatch keep its final network writes off teardown callers. + * Waits for a queued [stopAsync] teardown (final progress + stopSession) + * to finish. Callers that revoke the credentials those requests ride on + * must await this first, or the teardown lands as a 401. */ - fun reportAndStopExternalSessionAsync( - sessionId: String, - positionSeconds: Double, - isPaused: Boolean, - ) { - val job = synchronized(externalFinalizationLock) { - pendingExternalFinalizations[sessionId] - ?.takeUnless { it.isCompleted } - ?: scope.launch( - context = NonCancellable + Dispatchers.IO, - start = CoroutineStart.LAZY, - ) { - runCatching { - sessionManager.reportProgress( - sessionId = sessionId, - position = positionSeconds, - isPaused = isPaused, - ) - } - runCatching { sessionManager.stopSession(sessionId) } - }.also { - pendingExternalFinalizations[sessionId] = it - } - } - job.invokeOnCompletion { - synchronized(externalFinalizationLock) { - if (pendingExternalFinalizations[sessionId] === job) { - pendingExternalFinalizations.remove(sessionId) - } - } - } - job.start() - } - - private suspend fun awaitPendingStop() { + suspend fun awaitPendingStop() { val job = synchronized(pendingStopLock) { pendingStopJob } ?: return job.join() } @@ -1064,4 +1007,6 @@ data class StartParams( val qualityPreference: String? = null, val startPosition: Double? = null, val clientPlaybackContext: ClientPlaybackContext, + /** Watch Party starts pin the file (false); renewals must keep that. */ + val allowAlternateVersions: Boolean? = null, ) diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManager.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManager.kt index 73188a83e3..c17e0d5481 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManager.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManager.kt @@ -297,6 +297,8 @@ open class PlaybackSessionManager( progressPersistence: ProgressPersistenceV3 = ProgressPersistenceV3.SERVER, deferPublication: Boolean = false, expectedMetadataOwner: AuthScopeSnapshot? = null, + /** False pins [fileId] through every replan (Watch Party). Null keeps the server default. */ + allowAlternateVersions: Boolean? = null, ): ApiResult = contentStartMutex.withLock { if (!tokenManager.acceptsMetadataOwner(expectedMetadataOwner, profileId)) return@withLock ApiResult.Error(0, "identity_changed", "The metadata viewer changed before playback admission.") @@ -375,6 +377,7 @@ open class PlaybackSessionManager( bandwidthCapKbps = maxBitrateKbps?.takeIf { it > 0 }, capabilities = capabilities, clientPlaybackContext = clientPlaybackContext, + allowAlternateVersions = allowAlternateVersions, ) if (!tokenManager.acceptsMetadataOwner(expectedMetadataOwner, profileId)) return@withLock ApiResult.Error(0, "identity_changed", "The metadata viewer changed before playback admission.") @@ -1243,24 +1246,6 @@ open class PlaybackSessionManager( return true } - /** - * Rolls back whatever deferred publication this manager still holds. - * - * The lifecycle's `rollbackCurrentPendingPublication` can only settle a - * publication the *lifecycle* knows about, and reports success when it has - * none — but the manager's is created first, so a cancellation between the - * two leaves this side pending with no owner. Callers about to start fresh - * content should clear both. - * - * Returns true when nothing is pending or the rollback succeeded. - */ - suspend fun rollbackCurrentPendingVideoPublication(): Boolean { - val pendingSessionId = videoAttemptMutex.withLock { - pendingVideoPublication?.replacement?.sessionId - } ?: return true - return rollbackUnpublishedVideoSession(pendingSessionId) - } - /** * Drops manager ownership of [sessionId] and stops it. * diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackTeardownGate.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackTeardownGate.kt index 2c04ba31ee..e84f255d89 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackTeardownGate.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackTeardownGate.kt @@ -30,9 +30,6 @@ class PlaybackTeardownGate(private val lifecycle: PlaybackSessionLifecycle) { private val claimed = AtomicBoolean(false) - /** True once some route has taken ownership of this screen's teardown. */ - val isClaimed: Boolean get() = claimed.get() - /** * Ordered teardown, awaited before navigating to the next item. * diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackV3Session.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackV3Session.kt index 83017a86c8..69c8d36cba 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackV3Session.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackV3Session.kt @@ -114,6 +114,7 @@ internal fun PlaybackPlanV3.toSessionResponse( it == SUBTITLE_DELIVERY_SIDECAR || it == SUBTITLE_DELIVERY_BURN_IN_ONLY }, + syncKey = item.syncKey, ) } .distinctBy(PlayerSubtitleInfo::index) @@ -179,6 +180,7 @@ internal fun PlaybackPlanV3.toSessionResponse( decisionTrace = listOf(decisionReason), requestedMediaFileId = requestedMediaFileId ?: mediaFileId, effectiveMediaFileId = effectiveFileId, + effectiveRecipe = effectiveRecipe, ) return PlaybackSessionResponse( sessionId = sessionId, diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairiePlayerFactory.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairiePlayerFactory.kt index 1b2084c457..011a92e192 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairiePlayerFactory.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairiePlayerFactory.kt @@ -40,6 +40,7 @@ import androidx.media3.extractor.text.SubtitleParser import androidx.media3.extractor.ts.DefaultTsPayloadReaderFactory import androidx.media3.extractor.ts.TsExtractor import org.prairieserver.prairie.common.player.audio.DelayAudioProcessor +import org.prairieserver.prairie.common.player.audio.HdmiSinkDtsSupport import org.prairieserver.prairie.common.player.audio.PassthroughSuppressingAudioSink import org.prairieserver.prairie.common.player.subtitle.OffsetSubtitleParserFactory import org.prairieserver.prairie.common.player.subtitle.PgsSupExtractor @@ -180,9 +181,10 @@ class PrairiePlayerFactory( ): ExoPlayer { // When the build flag is on and the current ABI's JNI library loads, // extension renderers (FFmpeg audio) follow the platform renderers and - // fill only codec gaps. This keeps native passthrough/MediaCodec paths + // fill codec gaps. This keeps native passthrough/MediaCodec paths // preferred while retaining a last-resort local decoder for - // forced-original and recovery cases. + // forced-original and recovery cases. The one exception is DTS on an + // HDMI sink without DTS, which goes to FFmpeg (see [HdmiSinkDtsSupport]). // // When the flag is off (compile-time bisect), we set _MODE_OFF // rather than _MODE_ON so extension renderers are *not even @@ -201,11 +203,17 @@ class PrairiePlayerFactory( // across players because Koin gives us a single DelayAudioProcessor // instance; in practice PrairiePlaybackService creates exactly // one ExoPlayer per process so there's no contention. - val audioSink: AudioSink = PassthroughSuppressingAudioSink(DefaultAudioSink.Builder(context) - .setAudioProcessorChain( - DefaultAudioSink.DefaultAudioProcessorChain(delayProcessor), - ) - .build()) + val audioSink: AudioSink = PassthroughSuppressingAudioSink( + DefaultAudioSink.Builder(context) + .setAudioProcessorChain( + DefaultAudioSink.DefaultAudioProcessorChain(delayProcessor), + ) + .build(), + forceDecode = { format -> + preferFfmpegAudio && + HdmiSinkDtsSupport.shouldDecodeWithFfmpeg(context, format.sampleMimeType) + }, + ) val media3RenderersFactory = object : DefaultRenderersFactory(context) { override fun buildAudioSink( @@ -274,10 +282,18 @@ class PrairiePlayerFactory( setMediaCodecSelector { mimeType, requiresSecureDecoder, requiresTunnelingDecoder -> val infos = MediaCodecSelector.DEFAULT .getDecoderInfos(mimeType, requiresSecureDecoder, requiresTunnelingDecoder) - if (mimeType == MimeTypes.VIDEO_H265) { - infos.filter { it.hardwareAccelerated }.ifEmpty { infos } - } else { - infos + when { + mimeType == MimeTypes.VIDEO_H265 -> + infos.filter { it.hardwareAccelerated }.ifEmpty { infos } + // Amlogic boxes expose a platform DTS decoder that played + // silent on a sink without DTS (LG 2025 TVs). Hiding it + // hands the track to FFmpeg, which outputs multichannel PCM. + // A DTS:X track also queries plain DTS as its alternative + // MIME, so on such a sink it is left without a local + // decoder and goes back to the server, which beats silence. + preferFfmpegAudio && !requiresSecureDecoder && + HdmiSinkDtsSupport.shouldDecodeWithFfmpeg(context, mimeType) -> emptyList() + else -> infos } } } @@ -725,11 +741,29 @@ class PrairiePlayerFactory( // A sidecar must not decide when playback starts or what loads // next — left as a plain merged child it starves the video until // its own download reaches the resume point. See the wrapper. - return SidecarSubtitleMediaSource(progressive, playbackFloor) + // A PGS sidecar also reads only a bounded stretch ahead: each of + // its cues carries a caption image, and a whole film of them does + // not fit in a phone's heap. + return SidecarSubtitleMediaSource( + progressive, + playbackFloor, + maxLookaheadUs = if (configuration.mimeType == MimeTypes.APPLICATION_PGS) { + PGS_SIDECAR_LOOKAHEAD_US + } else { + C.TIME_UNSET + }, + ) } } private companion object { + /** + * Two minutes of captions ahead of the playhead. The loader can read + * one check interval (1 MiB of the file) past that before it parks, + * so a PGS sidecar holds a few MB instead of the whole film. + */ + const val PGS_SIDECAR_LOOKAHEAD_US = 120_000_000L + val replayableTextSubtitleMimeTypes = setOf( MimeTypes.TEXT_SSA, MimeTypes.APPLICATION_SUBRIP, diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleManager.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleManager.kt index 6a47168a1b..bd3f33301e 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleManager.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleManager.kt @@ -328,7 +328,8 @@ class SubtitleManager( } private fun buildCaptionStyle(appearance: SubtitleAppearance): CaptionStyleCompat { - val foreground = parseHexColor(appearance.fontColor) + val foregroundAlpha = appearance.textOpacity.coerceIn(1, 100) * 255 / 100 + val foreground = parseHexColor(appearance.fontColor, foregroundAlpha) val backgroundAlpha = if (appearance.backgroundStyle == SubtitleBackgroundStylePreset.Box) { (appearance.backgroundOpacity.coerceIn(0, 100) * 255 / 100) } else { diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleSyncNames.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleSyncNames.kt new file mode 100644 index 0000000000..7b4d0e4c0f --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleSyncNames.kt @@ -0,0 +1,9 @@ +package org.prairieserver.prairie.common.player + +import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo +import org.prairieserver.prairie.model.subtitles.SubtitleSyncState +import org.prairieserver.prairie.player.trackLanguageDisplayName + +/** The subtitle's language name for sync feedback ("English"), or null when it has none. */ +fun subtitleSyncName(tracks: List, subtitle: SubtitleSyncState): String? = + trackLanguageDisplayName(tracks.firstOrNull { it.syncKey == subtitle.key }?.language ?: subtitle.language) diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/audio/HdmiSinkDtsSupport.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/audio/HdmiSinkDtsSupport.kt new file mode 100644 index 0000000000..7ef30aaa34 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/audio/HdmiSinkDtsSupport.kt @@ -0,0 +1,140 @@ +package org.prairieserver.prairie.common.player.audio + +import android.content.Context +import android.media.AudioDeviceInfo +import android.media.AudioFormat +import android.media.AudioManager +import android.os.Build +import android.util.Log +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.UnstableApi +import org.prairieserver.prairie.common.player.FfmpegAudioSupport + +/** + * Whether the HDMI sink itself decodes DTS, read from the encodings the sink's + * output port lists (`AudioDeviceInfo.getEncodings()`) rather than from the + * platform's passthrough answer (Media3 `AudioCapabilities`). + * + * The two disagree on Amlogic boxes (onn 4K Pro, Mi Box S): the platform + * accepts DTS encodings and exposes a platform DTS decoder because the SoC + * handles DTS itself, while the port of a sink with no DTS descriptor (every + * LG 2025 TV) lists only Dolby codecs. On an onn 4K Pro in front of an LG C5, + * DTS through that platform decoder played silent; the bundled FFmpeg decoder + * plays it as multichannel PCM. + */ +@UnstableApi +object HdmiSinkDtsSupport { + private const val TAG = "HdmiSinkDtsSupport" + + // AudioFormat constants newer than minSdk, as literals so they are read on + // every platform level (a HAL can report them before the SDK names them). + private const val ENCODING_PCM_24BIT_PACKED = 21 + private const val ENCODING_PCM_32BIT = 22 + private const val ENCODING_DTS_UHD_P1 = 27 + private const val ENCODING_DTS_HD_MA = 29 + private const val ENCODING_DTS_UHD_P2 = 30 + + private val dtsMimeTypes = setOf( + MimeTypes.AUDIO_DTS, + MimeTypes.AUDIO_DTS_EXPRESS, + MimeTypes.AUDIO_DTS_HD, + ) + + private val dtsSinkEncodings = setOf( + AudioFormat.ENCODING_DTS, + AudioFormat.ENCODING_DTS_HD, + ENCODING_DTS_UHD_P1, + ENCODING_DTS_HD_MA, + ENCODING_DTS_UHD_P2, + ) + + // Neither PCM nor the IEC 61937 transport says which codecs a sink decodes. + private val nonCodecEncodings = setOf( + AudioFormat.ENCODING_DEFAULT, + AudioFormat.ENCODING_PCM_8BIT, + AudioFormat.ENCODING_PCM_16BIT, + AudioFormat.ENCODING_PCM_FLOAT, + ENCODING_PCM_24BIT_PACKED, + ENCODING_PCM_32BIT, + AudioFormat.ENCODING_IEC61937, + ) + + @Volatile private var lastLoggedVerdict: String? = null + + /** + * Whether the sinks in [hdmiSinkEncodings] (one `AudioDeviceInfo.getEncodings()` + * array each) decode DTS. A sink counts as described only when it lists a + * compressed codec; with no sink, or any undescribed one, the answer is + * true so a HAL that reports nothing keeps today's behavior. + */ + internal fun sinkAdvertisesDts(hdmiSinkEncodings: List): Boolean { + val undescribed = { encodings: IntArray -> encodings.all { it in nonCodecEncodings } } + if (hdmiSinkEncodings.isEmpty() || hdmiSinkEncodings.any(undescribed)) return true + return hdmiSinkEncodings.any { encodings -> encodings.any { it in dtsSinkEncodings } } + } + + /** + * True when a DTS track of [mimeType] must be decoded by FFmpeg: the sink + * does not decode DTS and the packaged FFmpeg build decodes this MIME. + * Without FFmpeg the platform path stays, since it is then the only + * decoder there is. + */ + internal fun shouldDecodeWithFfmpeg( + mimeType: String?, + ffmpegSupportsMimeType: (String) -> Boolean, + sinkDecodesDts: () -> Boolean, + ): Boolean = mimeType != null && + mimeType in dtsMimeTypes && + ffmpegSupportsMimeType(mimeType) && + !sinkDecodesDts() + + /** [shouldDecodeWithFfmpeg] against the HDMI sink on the active media route. */ + fun shouldDecodeWithFfmpeg(context: Context, mimeType: String?): Boolean = + shouldDecodeWithFfmpeg( + mimeType = mimeType, + ffmpegSupportsMimeType = FfmpegAudioSupport::supportsMimeType, + sinkDecodesDts = { hdmiSinkDecodesDts(context) }, + ) + + /** + * [sinkAdvertisesDts] against the HDMI sink on the active media route. + * Inspection failures answer true, keeping the platform's own handling. + */ + fun hdmiSinkDecodesDts(context: Context): Boolean = runCatching { + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + val encodings = activeHdmiSinkEncodings(audioManager) + sinkAdvertisesDts(encodings).also { decodes -> + val verdict = "$decodes ${encodings.joinToString { it.contentToString() }}" + if (verdict != lastLoggedVerdict) { + lastLoggedVerdict = verdict + Log.i(TAG, "HDMI sink DTS support: $verdict") + } + } + }.getOrElse { error -> + Log.w(TAG, "HDMI sink inspection failed; keeping platform DTS handling", error) + true + } + + /** + * Encodings of the HDMI outputs a movie plays through. API 33+ reads the + * active media route; a route with no HDMI device (USB, Bluetooth, the + * phone speaker) yields none, which [sinkAdvertisesDts] treats as + * undescribed. Older releases can only list every connected HDMI output. + */ + private fun activeHdmiSinkEncodings(audioManager: AudioManager): List { + val devices = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val attrs = android.media.AudioAttributes.Builder() + .setUsage(android.media.AudioAttributes.USAGE_MEDIA) + .setContentType(android.media.AudioAttributes.CONTENT_TYPE_MOVIE) + .build() + audioManager.getAudioDevicesForAttributes(attrs) + } else { + audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).toList() + } + return devices.filter { isHdmi(it.type) }.map(AudioDeviceInfo::getEncodings) + } + + private fun isHdmi(type: Int): Boolean = type == AudioDeviceInfo.TYPE_HDMI || + type == AudioDeviceInfo.TYPE_HDMI_ARC || + (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && type == AudioDeviceInfo.TYPE_HDMI_EARC) +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/audio/PassthroughSuppressionRegistry.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/audio/PassthroughSuppressionRegistry.kt index 6137b7bb63..8fa8bdfc44 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/audio/PassthroughSuppressionRegistry.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/audio/PassthroughSuppressionRegistry.kt @@ -85,12 +85,13 @@ object PassthroughSuppressionRegistry : PassthroughSuppressionScope { @UnstableApi class PassthroughSuppressingAudioSink( private val delegate: AudioSink, + private val forceDecode: (Format) -> Boolean = { false }, ) : ForwardingAudioSink(delegate) { override fun supportsFormat(format: Format): Boolean = getFormatSupport(format) != AudioSink.SINK_FORMAT_UNSUPPORTED override fun getFormatSupport(format: Format): Int = - if (PassthroughSuppressionRegistry.isSuppressed(format)) { + if (PassthroughSuppressionRegistry.isSuppressed(format) || forceDecode(format)) { // Returning SUPPORTED_WITH_TRANSCODING still makes // MediaCodecAudioRenderer treat the encoded format as sink- // playable and select bypass/passthrough again. Mark only the diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/debug/PlaybackDebugReceiver.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/debug/PlaybackDebugReceiver.kt index f82028e382..0e4f3ba551 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/debug/PlaybackDebugReceiver.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/debug/PlaybackDebugReceiver.kt @@ -3,6 +3,7 @@ package org.prairieserver.prairie.common.player.debug import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.os.SystemClock import androidx.media3.common.C import androidx.media3.common.Format import androidx.media3.common.Player @@ -11,6 +12,8 @@ import org.json.JSONObject import org.koin.java.KoinJavaComponent import org.prairieserver.prairie.common.BuildConfig import org.prairieserver.prairie.common.player.ActivePlayerHolder +import org.prairieserver.prairie.common.player.watchparty.WatchPartyDebugRegistry +import org.prairieserver.prairie.repository.WatchTogetherRepository /** * Debug-only adb hook for scripted playback testing (see @@ -50,6 +53,11 @@ class PlaybackDebugReceiver : BroadcastReceiver() { private fun statusJson(player: Player?): JSONObject { val json = JSONObject() + // Correlate samples across devices: monotonic and wall time at the + // moment of sampling, plus the room's server-clock estimate. + json.put("sampledElapsedMs", SystemClock.elapsedRealtime()) + json.put("sampledWallMs", System.currentTimeMillis()) + partyJson()?.let { json.put("party", it) } PlaybackDebugState.screenError?.let { json.put("screenError", it) } PlaybackDebugState.screenPositionSec?.let { json.put("screenPositionSec", it) } PlaybackDebugState.screenDurationSec?.let { json.put("screenDurationSec", it) } @@ -91,6 +99,56 @@ class PlaybackDebugReceiver : BroadcastReceiver() { return json } + /** Room and binding state while a Watch Party is active; never tokens or tickets. */ + private fun partyJson(): JSONObject? { + val repository = runCatching { + KoinJavaComponent.get(WatchTogetherRepository::class.java) + }.getOrNull() ?: return null + val room = repository.roomSnapshot.value + val ended = repository.ended.value + if (room == null && ended == null) return null + val json = JSONObject() + room?.let { + json.put("roomId", it.roomId) + json.put("phase", it.phase.wire) + json.put("playbackState", it.playbackState.wire) + json.put("selectionRevision", it.selectionRevision) + json.put("generation", it.generation) + json.put("selfRole", it.selfRole.wire) + json.put("anchorSec", it.anchorPositionSeconds) + json.put("roomPaused", it.isPaused) + json.put("attachedSessionId", it.attachedSessionId ?: JSONObject.NULL) + json.put("selfReady", it.selfMember?.isReady == true) + json.put("selfIgnoreWait", it.selfIgnoreWait) + json.put("memberCount", it.memberCount) + } + ended?.let { json.put("endedReason", it.reason) } + val connection = repository.connectionState.value + json.put("connectionEpoch", connection.epoch) + json.put("connected", connection.writable) + repository.lastSocketEnd?.let { json.put("lastSocketEnd", it) } + val clock = repository.clock.value + clock.offsetMs?.let { json.put("serverOffsetMs", it) } + clock.rttMs?.let { json.put("clockRttMs", it) } + repository.latestTransportCommand.value?.command?.let { command -> + json.put("latestCommandId", command.commandId) + json.put("latestCommandAction", command.action.wire) + } + WatchPartyDebugRegistry.binding?.debug?.value?.let { debug -> + json.put("bindingAttachedSessionId", debug.attachedSessionId ?: JSONObject.NULL) + json.put("serverAttached", debug.serverAttached) + json.put("pendingCommandId", debug.pendingCommandId ?: JSONObject.NULL) + json.put("appliedCommandId", debug.appliedCommandId ?: JSONObject.NULL) + json.put("readinessCommandId", debug.readinessCommandId ?: JSONObject.NULL) + json.put("catchingUp", debug.catchingUp) + json.put("correctionRate", debug.correctionRate ?: JSONObject.NULL) + json.put("reloadInFlight", debug.reloadInFlight) + json.put("stallReported", debug.stallReported) + json.put("suspended", debug.suspended) + } + return json + } + private fun runCommand(player: Player?, intent: Intent): JSONObject { val json = JSONObject() val cmd = intent.getStringExtra(EXTRA_CMD) diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/subtitle/SidecarSubtitleMediaSource.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/subtitle/SidecarSubtitleMediaSource.kt index 2d65190382..c32209b0b5 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/subtitle/SidecarSubtitleMediaSource.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/subtitle/SidecarSubtitleMediaSource.kt @@ -56,11 +56,19 @@ class SidecarPlaybackFloor { * Captions that arrive after the playhead has passed them are the extractor's * problem, not this class's: it publishes the live position through * [SidecarPlaybackFloor] so a REPLACE-behaviour extractor can drop them. + * + * [maxLookaheadUs] bounds how far past the playhead the sidecar may read. + * Without it the whole file is read up front, and everything read stays in the + * sample queue until playback reaches it. That is nothing for a text sidecar, + * but a feature-length `.sup` holds tens of MB of caption images, enough to + * run a phone with a 192 MB heap out of memory. [C.TIME_UNSET] keeps the + * unbounded read. */ @UnstableApi class SidecarSubtitleMediaSource( child: MediaSource, private val floor: SidecarPlaybackFloor, + private val maxLookaheadUs: Long = C.TIME_UNSET, ) : WrappingMediaSource(child) { override fun createPeriod( @@ -70,6 +78,7 @@ class SidecarSubtitleMediaSource( ): MediaPeriod = NonGatingSidecarPeriod( mediaSource.createPeriod(id, allocator, startPositionUs), floor, + maxLookaheadUs, ) override fun releasePeriod(mediaPeriod: MediaPeriod) { @@ -81,10 +90,17 @@ class SidecarSubtitleMediaSource( internal class NonGatingSidecarPeriod( val delegate: MediaPeriod, private val floor: SidecarPlaybackFloor, + private val maxLookaheadUs: Long = C.TIME_UNSET, ) : MediaPeriod, MediaPeriod.Callback { private var callback: MediaPeriod.Callback? = null + /** The delegate parked far enough ahead that it was left parked; see [continueUnlessAhead]. */ + private var heldAhead = false + + /** A delegate's buffered position is only defined once it has prepared. */ + private var prepared = false + override fun prepare(callback: MediaPeriod.Callback, positionUs: Long) { this.callback = callback floor.set(positionUs) @@ -92,6 +108,7 @@ internal class NonGatingSidecarPeriod( } override fun onPrepared(mediaPeriod: MediaPeriod) { + prepared = true callback?.onPrepared(this) } @@ -99,10 +116,42 @@ internal class NonGatingSidecarPeriod( // The delegate parked its loader (interval reached, or a cancelled load // finished unwinding). Nobody upstream will continue a child that // reports nothing to load, so do it here. - kickDelegate("requested") + continueUnlessAhead("requested") callback?.onContinueLoadingRequested(this) } + /** + * Continues the delegate unless its queued cues already reach more than + * [maxLookaheadUs] past the playhead. A held delegate stays parked, and + * [resumeIfCaughtUp] continues it from the playback ticks once the + * playhead closes the gap. + */ + private fun continueUnlessAhead(reason: String) { + if (isAheadOfPlayhead()) { + if (!heldAhead) { + org.prairieserver.prairie.common.player.SubDiag.log( + "sidecar held($reason) buffered=${delegate.bufferedPositionUs / 1000}ms " + + "floor=${floor.get() / 1000}ms", + ) + } + heldAhead = true + return + } + heldAhead = false + kickDelegate(reason) + } + + private fun resumeIfCaughtUp() { + if (heldAhead && !isAheadOfPlayhead()) continueUnlessAhead("caught-up") + } + + private fun isAheadOfPlayhead(): Boolean { + if (maxLookaheadUs == C.TIME_UNSET || !prepared) return false + val bufferedUs = delegate.bufferedPositionUs + if (bufferedUs == C.TIME_END_OF_SOURCE || bufferedUs == C.TIME_UNSET) return false + return bufferedUs - floor.get() > maxLookaheadUs + } + /** * `ProgressiveMediaPeriod` never restarts itself: after `prepare()` it * reads until its check interval and parks; `selectTracks` that enables a @@ -113,13 +162,24 @@ internal class NonGatingSidecarPeriod( */ private fun kickDelegate(reason: String) { if (delegate.isLoading) return - val continued = delegate.continueLoading( - LoadingInfo.Builder() - .setPlaybackPositionUs(floor.get()) - .setPlaybackSpeed(1f) - .setLastRebufferRealtimeMs(C.TIME_UNSET) - .build(), - ) + val loadingInfo = LoadingInfo.Builder() + .setPlaybackPositionUs(floor.get()) + .setPlaybackSpeed(1f) + .setLastRebufferRealtimeMs(C.TIME_UNSET) + .build() + val continued = try { + delegate.continueLoading(loadingInfo) + } catch (_: IllegalStateException) { + // A prepared ProgressiveMediaPeriod only starts a new load from a + // pending reset. A load cancelled because its track was disabled + // has none, and when the track is enabled again before that + // cancel lands (a subtitle remount reselecting the same track), + // the cancel's continue request reaches here without one. Restart + // the load from the playhead, as a seek would. + org.prairieserver.prairie.common.player.SubDiag.log("sidecar restart($reason) floor=${floor.get() / 1000}ms") + delegate.seekToUs(floor.get()) + delegate.continueLoading(loadingInfo) + } org.prairieserver.prairie.common.player.SubDiag.log( "sidecar kick($reason) continued=$continued floor=${floor.get() / 1000}ms", ) @@ -142,17 +202,26 @@ internal class NonGatingSidecarPeriod( val result = delegate.selectTracks(selections, mayRetainStreamFlags, streams, streamResetFlags, positionUs) // Enabling the text track (a subtitle pick after start, or the first // selection once prepared) is what makes the delegate willing to load. - if (selections.any { it != null }) kickDelegate("select") + if (selections.any { it != null }) continueUnlessAhead("select") return result } - override fun discardBuffer(positionUs: Long, toKeyframe: Boolean) = + /** + * Called on the playing period every playback tick, unlike + * [reevaluateBuffer], which stops once the next item starts loading. So + * this is what keeps a held sidecar reading through the end of an item. + */ + override fun discardBuffer(positionUs: Long, toKeyframe: Boolean) { delegate.discardBuffer(positionUs, toKeyframe) + floor.set(maxOf(floor.get(), positionUs)) + resumeIfCaughtUp() + } override fun readDiscontinuity(): Long = delegate.readDiscontinuity() override fun seekToUs(positionUs: Long): Long { floor.set(positionUs) + heldAhead = false val result = delegate.seekToUs(positionUs) // An idle delegate is left reset-but-parked by a seek; a loading one is // cancelled and comes back through onContinueLoadingRequested. @@ -181,5 +250,6 @@ internal class NonGatingSidecarPeriod( // period position — the live floor for "this cue is already history". floor.set(positionUs) delegate.reevaluateBuffer(positionUs) + resumeIfCaughtUp() } } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/EpisodeSelectionHandoff.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/EpisodeSelectionHandoff.kt index 936a4644ed..4b5528faad 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/EpisodeSelectionHandoff.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/EpisodeSelectionHandoff.kt @@ -1,9 +1,5 @@ package org.prairieserver.prairie.common.player.video -import kotlinx.serialization.Serializable -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json import org.prairieserver.prairie.common.player.normalizedSubtitleCodecFamily import org.prairieserver.prairie.model.catalog.AudioTrack import org.prairieserver.prairie.model.catalog.FileVersion @@ -17,7 +13,6 @@ import org.prairieserver.prairie.playback.subtitleLabelIndicatesHearingImpaired * episode-local identity: target IDs and subtitle indexes are resolved only * after the next episode's catalog detail is available. */ -@Serializable data class EpisodeSelectionHandoff( val source: EpisodeSourceIntent? = null, val subtitle: EpisodeSubtitleIntent = EpisodeSubtitleIntent.auto(), @@ -35,10 +30,8 @@ data class EpisodeSelectionHandoff( val audio: EpisodeAudioIntent = EpisodeAudioIntent.auto(), ) -@Serializable enum class EpisodeAudioMode { AUTO, TRACK } -@Serializable data class EpisodeAudioIntent( val mode: EpisodeAudioMode, val language: String? = null, @@ -171,7 +164,6 @@ fun resolveAudioSelectionAcrossVersions( ) } -@Serializable data class EpisodeSourceIntent( val resolution: String, val videoCodec: String? = null, @@ -179,13 +171,10 @@ data class EpisodeSourceIntent( val container: String? = null, ) -@Serializable enum class EpisodeDynamicRange { SDR, HDR, DOLBY_VISION } -@Serializable enum class EpisodeSubtitleMode { AUTO, OFF, TRACK } -@Serializable data class EpisodeSubtitleIntent( val mode: EpisodeSubtitleMode, val language: String? = null, @@ -290,15 +279,6 @@ fun resolveEpisodeSubtitleIntent( ) } -fun encodeEpisodeSelectionHandoff(handoff: EpisodeSelectionHandoff): String = - episodeSelectionHandoffJson.encodeToString(handoff) - -fun decodeEpisodeSelectionHandoff(value: String?): EpisodeSelectionHandoff? = - value?.takeIf { it.isNotBlank() }?.let { encoded -> - runCatching { episodeSelectionHandoffJson.decodeFromString(encoded) } - .getOrNull() - } - private fun FileVersion.matchesEpisodeVideoCodec(intent: EpisodeSourceIntent): Boolean = intent.videoCodec != null && normalizedEpisodeToken(codecVideo ?: videoTracks?.firstOrNull()?.codec) == intent.videoCodec @@ -404,9 +384,3 @@ private fun normalizedEpisodeToken(value: String?): String? = ?.lowercase() ?.filter(Char::isLetterOrDigit) ?.takeIf { it.isNotEmpty() } - -private val episodeSelectionHandoffJson = Json { - encodeDefaults = false - explicitNulls = false - ignoreUnknownKeys = true -} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/MarkerReconciliationFence.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/MarkerReconciliationFence.kt new file mode 100644 index 0000000000..aaa7151200 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/MarkerReconciliationFence.kt @@ -0,0 +1,9 @@ +package org.prairieserver.prairie.common.player.video + +/** A late read must not replace a newer marker event or reconnect snapshot. */ +class MarkerReconciliationFence { + private var generation = 0L + fun begin(): Long = ++generation + fun invalidate() { generation++ } + fun isCurrent(ticket: Long): Boolean = ticket == generation +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/MarkerSkipTarget.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/MarkerSkipTarget.kt new file mode 100644 index 0000000000..0f255073a5 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/MarkerSkipTarget.kt @@ -0,0 +1,18 @@ +package org.prairieserver.prairie.common.player.video + +import org.prairieserver.prairie.model.catalog.TimeRange + +enum class ManualMarkerKind { Recap, Credits } +data class MarkerSkipTarget(val kind: ManualMarkerKind, val endSeconds: Double) + +/** The active range is end-exclusive. Recap wins if provider ranges overlap. */ +fun markerSkipTarget(position: Double, recap: TimeRange?, credits: TimeRange?): MarkerSkipTarget? { + if (!position.isFinite()) return null + fun TimeRange.active() = start.isFinite() && end.isFinite() && start >= 0 && + end > start && position >= start && position < end + return when { + recap?.active() == true -> MarkerSkipTarget(ManualMarkerKind.Recap, recap.end) + credits?.active() == true -> MarkerSkipTarget(ManualMarkerKind.Credits, credits.end) + else -> null + } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/PlaybackContainerPolicy.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/PlaybackContainerPolicy.kt index 82f470a574..3bd83b6be7 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/PlaybackContainerPolicy.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/PlaybackContainerPolicy.kt @@ -2,12 +2,22 @@ package org.prairieserver.prairie.common.player.video /** * Containers Media3 opens directly for video sources. + * + * `m2ts` and `mts` are deliberately absent. Blu-ray and AVCHD files carry + * 192-byte BDAV packets, and Media3's `TsExtractor` reads only 188-byte + * transport-stream packets, so no extractor recognizes them and direct play + * fails with `ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED`. Leaving them out + * makes the server remux these files for playback and for single-title and + * season downloads. A whole-series download at Original quality still fetches + * the original bytes, because the server does not check device capabilities + * for that batch. Add them back only once Media3 supports 192-byte packets + * and a device test passes. */ val media3OriginalVideoContainers: List = listOf( "mp4", "m4v", "mov", "qt", "webm", "mkv", "matroska", "avi", - "ts", "mpegts", "mpeg-ts", "m2ts", "mts", + "ts", "mpegts", "mpeg-ts", ) /** diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlaybackSessionCoordinator.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlaybackSessionCoordinator.kt index 84e8fc6336..15d732323b 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlaybackSessionCoordinator.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlaybackSessionCoordinator.kt @@ -47,6 +47,8 @@ class VideoPlaybackSessionCoordinator( chapters = result.chapters, seriesId = result.seriesId, seriesTitle = result.seriesTitle, + year = result.year, + contentType = result.contentType, seasonNumber = result.seasonNumber, episodeNumber = result.episodeNumber, resolvedEpisodeSelection = result.resolvedEpisodeSelection, @@ -57,6 +59,8 @@ class VideoPlaybackSessionCoordinator( VideoPlayerUiState.Error( contentId = result.contentId, message = result.message, + diagnosticsCode = result.diagnosticsCode, + terminalReason = result.terminalReason, ) } is VideoPlaybackStartResult.ServerUnreachable -> { diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlaybackStartRequest.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlaybackStartRequest.kt index 74e03ed4cd..8683736063 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlaybackStartRequest.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlaybackStartRequest.kt @@ -1,6 +1,7 @@ package org.prairieserver.prairie.common.player.video import org.prairieserver.prairie.common.player.StartParams +import org.prairieserver.prairie.watchtogether.WatchPartyPlaybackContext data class VideoPlaybackStartRequest( val contentId: String, @@ -43,4 +44,10 @@ data class VideoPlaybackStartRequest( */ val recoveryStartParams: StartParams? = null, val libraryId: Int? = null, + /** + * Watch Party playback: the exact room file, position, and paused state. + * Room starts never use a downloaded copy, personal resume, or another + * version, and pin the file through every replan. + */ + val room: WatchPartyPlaybackContext? = null, ) diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlaybackStartResult.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlaybackStartResult.kt index 083d748fd5..9d75658b7b 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlaybackStartResult.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlaybackStartResult.kt @@ -56,12 +56,22 @@ sealed interface VideoPlaybackStartResult { /** TV's target-catalog resolution of [VideoPlaybackStartRequest.episodeSelectionHandoff]. */ val resolvedEpisodeSelection: ResolvedEpisodeSelection? = null, val seriesTitle: String? = null, + /** Release year, for the player's "MOVIE · 2026" line. */ + val year: Int? = null, + /** Catalog item type ("movie", "episode", …); the same line names a film. */ + val contentType: String? = null, ) : VideoPlaybackStartResult data class Error( val contentId: String, val message: String, val diagnosticsCode: PlaybackDiagnosticsCode? = null, + /** + * The server's terminal refusal reason exactly as sent, when the + * failure was a playback terminal. A Watch Party reports it to the + * room; [diagnosticsCode] keeps only the reasons safe for telemetry. + */ + val terminalReason: String? = null, ) : VideoPlaybackStartResult /** @@ -113,6 +123,7 @@ value class PlaybackDiagnosticsCode private constructor(val wireValue: String) { "replan_loop_detected", "source_metadata_incomplete", "source_unavailable", + "subtitle_conversion_unsupported", "transcoding_disabled", "transcode_start_failed", "transcode_node_unavailable", diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlayerUiState.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlayerUiState.kt index e4ce1b1fd9..26bacf6b28 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlayerUiState.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlayerUiState.kt @@ -21,6 +21,10 @@ sealed interface VideoPlayerUiState { data class Error( override val contentId: String, val message: String, + /** Why the start failed, when known; a server refusal carries its terminal reason. */ + val diagnosticsCode: PlaybackDiagnosticsCode? = null, + /** The server's terminal refusal reason, when there was one. */ + val terminalReason: String? = null, ) : VideoPlayerUiState { override val hasPlayableMedia: Boolean = false } @@ -82,6 +86,10 @@ sealed interface VideoPlayerUiState { /** Target-catalog decision for the one-shot episode-selection handoff. */ val resolvedEpisodeSelection: ResolvedEpisodeSelection? = null, val seriesTitle: String? = null, + /** Release year, for the player's "MOVIE · 2026" line. */ + val year: Int? = null, + /** Catalog item type ("movie", "episode", …); the same line names a film. */ + val contentType: String? = null, ) : VideoPlayerUiState { override val hasPlayableMedia: Boolean = true diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/watchparty/WatchPartyDebugRegistry.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/watchparty/WatchPartyDebugRegistry.kt new file mode 100644 index 0000000000..500e1c8a76 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/watchparty/WatchPartyDebugRegistry.kt @@ -0,0 +1,21 @@ +package org.prairieserver.prairie.common.player.watchparty + +import org.prairieserver.prairie.watchtogether.RoomPlaybackBinding + +/** + * The binding of the player screen currently in a Watch Party, for the + * debug-only harness receiver. Holds no credentials. + */ +object WatchPartyDebugRegistry { + @Volatile + var binding: RoomPlaybackBinding? = null + private set + + fun register(binding: RoomPlaybackBinding) { + this.binding = binding + } + + fun unregister(binding: RoomPlaybackBinding) { + if (this.binding === binding) this.binding = null + } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/watchparty/WatchPartyPlayback.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/watchparty/WatchPartyPlayback.kt new file mode 100644 index 0000000000..f0dfc90653 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/watchparty/WatchPartyPlayback.kt @@ -0,0 +1,142 @@ +package org.prairieserver.prairie.common.player.watchparty + +import android.os.SystemClock +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.SourceFallbackReason +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.repository.WatchPartyEnded +import org.prairieserver.prairie.repository.WatchTogetherRepository +import org.prairieserver.prairie.watchtogether.RoomPlaybackBinding +import org.prairieserver.prairie.watchtogether.RoomPlaybackNotice +import org.prairieserver.prairie.watchtogether.RoomPlayerPort +import org.prairieserver.prairie.watchtogether.RoomSession +import org.prairieserver.prairie.watchtogether.RoomTransportIntent +import org.prairieserver.prairie.watchtogether.RoomTransportResult +import org.prairieserver.prairie.watchtogether.WatchPartyAvailabilityRepository +import org.prairieserver.prairie.watchtogether.WatchPartyPlaybackContext +import org.prairieserver.prairie.watchtogether.playbackContext +import org.prairieserver.prairie.watchtogether.roomTransportAuthorized +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlin.math.abs + +/** + * One player screen's view of the Watch Party engagement. It owns the + * [RoomPlaybackBinding] for the screen's lifetime and gives the screen the + * room's playback context, status, and actions. The process-wide + * [RoomSession] owns the socket; disposing this never leaves the room. + * + * Both the phone and TV players use it in place of their own controllers. + */ +class WatchPartyPlayback( + private val roomId: String, + private val repository: WatchTogetherRepository, + private val roomSession: RoomSession, + private val availability: WatchPartyAvailabilityRepository, + player: RoomPlayerPort, + scope: CoroutineScope, +) { + val binding = RoomPlaybackBinding( + room = repository, + player = player, + parentScope = scope, + monotonicNowMs = SystemClock::elapsedRealtime, + wallNowMs = System::currentTimeMillis, + ) + + val room: StateFlow get() = repository.roomSnapshot + + /** Why the engagement ended, once it has; the screen leaves the player. */ + val ended: StateFlow get() = repository.ended + + val notices: SharedFlow get() = binding.notices + val catchingUp: StateFlow get() = binding.catchingUp + val offerLowerQuality: StateFlow get() = binding.offerLowerQuality + + /** + * The playback the player must be prepared for. It changes only when the + * room starts a new epoch (a new selection revision or file); anchor and + * membership updates keep the current player. + */ + val playbackContext: Flow = + repository.roomSnapshot + .map { it?.playbackContext() } + .distinctUntilChanged { old, new -> + old?.selectionRevision == new?.selectionRevision && old?.fileId == new?.fileId + } + + fun start() { + binding.start() + WatchPartyDebugRegistry.register(binding) + roomSession.adopt(roomId) + } + + /** Stop this screen's binding; the room engagement continues. */ + fun dispose() { + WatchPartyDebugRegistry.unregister(binding) + binding.dispose() + } + + fun requestPlayPause(pause: Boolean): RoomTransportResult = binding.requestPlayPause(pause) + + fun requestSeek(sourceSeconds: Double): RoomTransportResult = binding.requestSeek(sourceSeconds) + + /** Whether the local member may use [intent] right now. */ + fun allows(intent: RoomTransportIntent): Boolean = roomTransportAuthorized(room.value, intent) + + /** + * A seek this screen did not issue (notification, headset, Bluetooth, + * Assistant, any other MediaSession controller). The host's becomes a room + * request; anyone else's is denied and explained. Restore the local + * position before dispatching: only the accepted room command may apply + * the target, and a host report must never re-anchor the room early. + */ + fun onExternalSeek(fromSeconds: Double, toSeconds: Double, restore: (Double) -> Unit): RoomTransportResult { + if (abs(toSeconds - fromSeconds) < EXTERNAL_SEEK_NOISE_SECONDS) return RoomTransportResult.Ignored + // Phone and TV retain issued seek targets across both the controller + // and session callbacks to prevent correction loops. Restore each + // outside seek, including repeated denied guest input. + restore(fromSeconds) + return binding.requestSeek(toSeconds) + } + + /** Leave this device's membership. For a host, the room ends two minutes later unless they rejoin. */ + fun leave() { + dispose() + roomSession.depart(closeRoom = false) + } + + /** End the party for everyone (host). */ + fun endForEveryone() { + dispose() + roomSession.depart(closeRoom = true) + } + + /** + * Report a playback refusal. Only the four approved reasons, and only when + * the server advertises coordinated fallback, ask the room to move + * everyone to another source; anything else stays a local error, and no + * other file is ever chosen locally. Returns true when the room took over. + */ + suspend fun reportRefusal(context: WatchPartyPlaybackContext, failedFileId: Int, reason: String?): Boolean { + val approved = SourceFallbackReason.fromWire(reason?.trim()?.lowercase()) ?: return false + if (availability.features?.sourceFallback != true) return false + if (room.value?.selectionRevision != context.selectionRevision) return false + val result = repository.requestSourceFallback( + selectionRevision = context.selectionRevision, + failedFileId = failedFileId.toString(), + reason = approved, + ) + return result is ApiResult.Success && + result.data.room.selectionRevision != context.selectionRevision + } + + private companion object { + /** Seek adjustments smaller than this are the player settling, not a request. */ + const val EXTERNAL_SEEK_NOISE_SECONDS = 1.0 + } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/watchparty/WatchPartyPlayerSupport.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/watchparty/WatchPartyPlayerSupport.kt new file mode 100644 index 0000000000..fa38052ab6 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/watchparty/WatchPartyPlayerSupport.kt @@ -0,0 +1,63 @@ +package org.prairieserver.prairie.common.player.watchparty + +import android.os.SystemClock +import org.prairieserver.prairie.watchtogether.RoomPlaybackNotice +import org.prairieserver.prairie.watchtogether.RoomTransportIntent +import kotlin.math.abs + +/** + * Seeks a player screen sent to Media3, remembered briefly so a seek made + * anywhere else (notification, headset or Bluetooth skip, Assistant, another + * MediaSession controller) can be told apart from its own. Positions are on + * the mounted player's timeline. + * + * Register a target before calling `seekTo`: a MediaController reports its own + * seek to listeners synchronously, inside that call. + */ +class IssuedSeekTracker( + private val nowMs: () -> Long = SystemClock::elapsedRealtime, +) { + private data class Issued(val targetMs: Long, val issuedAtMs: Long) + + private val issued = ArrayDeque() + + fun note(targetMs: Long) { + prune() + issued.addLast(Issued(targetMs.coerceAtLeast(0L), nowMs())) + while (issued.size > MAX_TRACKED) issued.removeFirst() + } + + /** + * True when a seek landing at [positionMs] was not one this screen issued. + * A match is not consumed: a MediaController reports its own seek at once + * and the session reports the same seek again, and a guest that took the + * second report for someone else's seek undid it, which the room then + * redid, in a loop. Issued targets simply expire. + */ + fun isExternal(positionMs: Long): Boolean { + prune() + return issued.none { abs(it.targetMs - positionMs) <= TOLERANCE_MS } + } + + private fun prune() { + val cutoff = nowMs() - EXPIRY_MS + while (issued.isNotEmpty() && issued.first().issuedAtMs < cutoff) issued.removeFirst() + } + + companion object { + const val TOLERANCE_MS = 500L + const val EXPIRY_MS = 3_000L + private const val MAX_TRACKED = 16 + } +} + +/** Plain-language text for a room notice, on phone and TV alike. */ +fun watchPartyNoticeText(notice: RoomPlaybackNotice): String = when (notice) { + is RoomPlaybackNotice.Denied -> when (notice.intent) { + RoomTransportIntent.Seek -> "Only the host can seek." + RoomTransportIntent.PlayPause -> "Only the host can play or pause." + } + RoomPlaybackNotice.Reconnecting -> "Reconnecting to the party…" + RoomPlaybackNotice.ClockUnavailable -> "Waiting for party timing. If this continues, leave and rejoin." + RoomPlaybackNotice.Undelivered -> "Couldn't reach the party. Try again." +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/profiles/ProfileAwayTracker.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/profiles/ProfileAwayTracker.kt new file mode 100644 index 0000000000..baefa2b71d --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/profiles/ProfileAwayTracker.kt @@ -0,0 +1,125 @@ +package org.prairieserver.prairie.common.profiles + +import androidx.media3.common.Player +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.prairieserver.prairie.common.player.ActivePlayerHolder + +/** + * Applies the Profile Selection setting when Prairie leaves and returns to the + * foreground. Mirrors prairie-apple ContentView's `markProfileAwayStartIfNeeded` + * and `handleReturnFromBackgroundIfNeeded`. Shared by the phone and TV apps. + * + * Main thread only: the app's main Activity calls it from onStop/onStart, and + * it reads the shared Media3 player, which lives on the main looper. + * + * @param onLeave runs each time Prairie leaves the foreground, with the current + * state (the TV empties its Watch Next row under Every Time). + * @param onAwayIntervalStarted runs with the time a timed away interval runs + * out, once it starts (the TV schedules its Watch Next expiry check). + * @param isCasting whether this device is playing to another screen (the + * phone's Cast session); that is still use of the profile. + */ +class ProfileAwayTracker( + private val preferences: ProfileLaunchPreferences, + private val activePlayerHolder: ActivePlayerHolder, + private val onLeave: (ProfileLaunchState) -> Unit = {}, + private val onAwayIntervalStarted: (expiresAtMs: Long) -> Unit = {}, + private val isCasting: () -> Boolean = { false }, +) { + private val _selectionRequired = MutableStateFlow(false) + + /** + * Raised when a return to Prairie must show Who's Watching. While it is up, + * the main Activity covers the screen; the navigation graph performs the + * switch and then calls [onSelectionHandled]. + */ + val selectionRequired: StateFlow = _selectionRequired.asStateFlow() + + private var returningFromBackground = false + private var watchedPlayer: Player? = null + + // Playback that stops while Prairie is hidden starts the away clock then; + // playback that resumes stops it. This also catches the video player's + // own pause, which reaches the session after onStop has run. + private val playbackListener = object : Player.Listener { + override fun onEvents(player: Player, events: Player.Events) { + if (events.containsAny(Player.EVENT_PLAY_WHEN_READY_CHANGED, Player.EVENT_PLAYBACK_STATE_CHANGED)) { + markAwayStart() + } + } + } + + fun onBackground() { + returningFromBackground = true + onLeave(preferences.state.value) + markAwayStart() + watchedPlayer?.removeListener(playbackListener) + watchedPlayer = activePlayerHolder.player.value?.also { it.addListener(playbackListener) } + } + + fun onForeground() { + watchedPlayer?.removeListener(playbackListener) + watchedPlayer = null + // The first start of a process is a launch, which the main Activity's + // start-route resolution handles. + if (!returningFromBackground) return + returningFromBackground = false + if (!keepsProfileActive() && preferences.requiresSelectionAfterBackground()) { + _selectionRequired.value = true + } else { + preferences.clearBackgroundedAt() + } + } + + fun onSelectionHandled() { + _selectionRequired.value = false + } + + /** + * Ask now, while Prairie is in the foreground: the phone uses it once the + * server is reachable again after it kept a profile offline for its + * downloads. The navigation graph handles it as it does a return. + */ + fun requestSelection() { + _selectionRequired.value = true + } + + /** + * Playback on another screen (the phone's Cast session) started or + * stopped. While Prairie is hidden this moves the away clock, as a local + * player's events do; no local player hears about it. + */ + fun onRemotePlaybackChanged() { + if (returningFromBackground) markAwayStart() + } + + /** + * Playback that keeps going while Prairie is hidden (an audiobook, or video + * in background play or picture-in-picture) is still use of the profile, + * as on Apple. The TV player stops video when Prairie is hidden, so there + * only audio qualifies. Uses play intent rather than `isPlaying` so a + * rebuffer does not count as leaving. + */ + fun keepsProfileActive(): Boolean { + if (isCasting()) return true + val player = activePlayerHolder.player.value ?: return false + return player.playWhenReady && + player.playbackState != Player.STATE_IDLE && + player.playbackState != Player.STATE_ENDED + } + + private fun markAwayStart() { + if (keepsProfileActive()) { + preferences.clearBackgroundedAt() + return + } + // Keep the earliest start: later player events (a stop after the + // pause) must not restart a clock that is already running. + if (preferences.state.value.backgroundedAtMs != null) return + preferences.markBackgrounded() + // Automatic records no interval, and the untimed Every Time has no expiry. + preferences.state.value.awayExpiresAtMs?.let(onAwayIntervalStarted) + } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/profiles/ProfileLaunchPreferences.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/profiles/ProfileLaunchPreferences.kt new file mode 100644 index 0000000000..b680f3c553 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/profiles/ProfileLaunchPreferences.kt @@ -0,0 +1,238 @@ +package org.prairieserver.prairie.common.profiles + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * When this device asks "Who's watching?" instead of reopening the last + * profile. Mirrors prairie-apple `ProfileLaunchBehavior` (General → Profile + * Selection), including its titles and descriptions. Shared by the phone and + * TV apps; each keeps its own choice on the device. + */ +enum class ProfileLaunchBehavior( + /** Stored value. Same strings as prairie-apple's raw values. */ + val raw: String, + val title: String, + private val describe: (device: String) -> String, + /** How long Prairie can be away before the picker shows again; null for the untimed choices. */ + val awayTimeoutMs: Long?, +) { + Automatic( + raw = "automatic", + title = "Automatic", + describe = { device -> + "Use the last profile selected on $device. " + + "A protected profile can reopen without asking for its PIN." + }, + awayTimeoutMs = null, + ), + EveryTime( + raw = "askEveryLaunch", + title = "Every Time", + describe = { "Show Who's Watching whenever you return to Prairie." }, + awayTimeoutMs = null, + ), + AfterOneHour( + raw = "afterOneHour", + title = "After 1 Hour", + describe = { "Show Who's Watching when you've been away from Prairie for 1 hour." }, + awayTimeoutMs = 60 * 60 * 1000L, + ), + AfterTwelveHours( + raw = "afterTwelveHours", + title = "After 12 Hours", + describe = { "Show Who's Watching when you've been away from Prairie for 12 hours." }, + awayTimeoutMs = 12 * 60 * 60 * 1000L, + ), + ; + + /** The choice's description, naming [device] (for example "this Android TV") where it matters. */ + fun description(device: String): String = describe(device) + + companion object { + /** Unknown or missing values fall back to today's behavior. */ + fun fromRaw(raw: String?): ProfileLaunchBehavior = + entries.firstOrNull { it.raw == raw } ?: Automatic + } +} + +/** + * The policy plus the start of the current interval away from Prairie. Pure, so + * the timing rules are unit-testable. Mirrors prairie-apple `ProfileLaunchState`. + */ +data class ProfileLaunchState( + val behavior: ProfileLaunchBehavior = ProfileLaunchBehavior.Automatic, + /** Epoch ms when Prairie last went to the background unused; null while in use. */ + val backgroundedAtMs: Long? = null, + /** + * Android TV only: a new TV offers the first profile picked on it as the + * one to open automatically. True until that offer is answered or a choice is made in + * Settings. + */ + val rememberOfferPending: Boolean = false, +) { + /** + * Cold start. Every Time asks even when the previous process never + * recorded a background; the timed choices ask only once a recorded away + * interval has run out. + */ + fun requiresSelectionAtLaunch(nowMs: Long): Boolean = + behavior == ProfileLaunchBehavior.EveryTime || hasExpiredAwayInterval(nowMs) + + /** Warm return. Needs a recorded background, so a brief pause never locks. */ + fun requiresSelectionAfterBackground(nowMs: Long): Boolean { + if (backgroundedAtMs == null) return false + return behavior == ProfileLaunchBehavior.EveryTime || hasExpiredAwayInterval(nowMs) + } + + /** + * Android TV only. Whether the launcher's Watch Next row may show the active profile's + * titles. prairie-apple `TopShelfProfilePolicy`: never with Every Time, and + * not once a timed choice has expired. + */ + fun allowsWatchNext(nowMs: Long): Boolean = + behavior != ProfileLaunchBehavior.EveryTime && !requiresSelectionAfterBackground(nowMs) + + /** When the current away interval runs out; null when nothing is timing out. */ + val awayExpiresAtMs: Long? + get() { + val since = backgroundedAtMs ?: return null + val timeout = behavior.awayTimeoutMs ?: return null + return since + timeout + } + + /** Automatic never keeps an away interval. Choosing a behavior answers the offer. */ + fun withBehavior(behavior: ProfileLaunchBehavior): ProfileLaunchState = copy( + behavior = behavior, + backgroundedAtMs = if (behavior == ProfileLaunchBehavior.Automatic) null else backgroundedAtMs, + rememberOfferPending = false, + ) + + /** The answer to the offer: remember opens the picked profile automatically from now on. */ + fun answeringRememberOffer(remember: Boolean): ProfileLaunchState = when { + !rememberOfferPending -> this + remember -> withBehavior(ProfileLaunchBehavior.Automatic) + else -> copy(rememberOfferPending = false) + } + + companion object { + /** + * Android TV only. The state of a TV with no saved choice. A new TV asks who's watching + * and offers to remember the first profile picked; a TV that already + * remembers a profile (it predates this setting) keeps opening it. + */ + fun firstLaunch(hasRememberedProfile: Boolean): ProfileLaunchState = + if (hasRememberedProfile) { + ProfileLaunchState(behavior = ProfileLaunchBehavior.Automatic) + } else { + ProfileLaunchState(behavior = ProfileLaunchBehavior.EveryTime, rememberOfferPending = true) + } + } + + fun markedBackgrounded(atMs: Long): ProfileLaunchState = + if (behavior == ProfileLaunchBehavior.Automatic) copy(backgroundedAtMs = null) else copy(backgroundedAtMs = atMs) + + private fun hasExpiredAwayInterval(nowMs: Long): Boolean { + val since = backgroundedAtMs ?: return false + val timeout = behavior.awayTimeoutMs ?: return false + // A clock behind the recorded start (a device that booted before its time + // synced, or a manual change) cannot show how long Prairie was away, so + // ask rather than keep the profile open indefinitely. + if (nowMs < since) return true + return nowMs - since >= timeout + } +} + +/** + * Device-wide Profile Selection setting. It is never sent to the server, so a + * choice on this device leaves every other device alone (prairie-apple + * profile-launch design §12). It survives sign-out; the profile itself does + * not. + * + * Backed by SharedPreferences rather than DataStore: `onStop` writes the away + * marker and `onStart` reads it straight after, and SharedPreferences makes + * that write visible synchronously. + * + * The phone and TV apps each pass their own file name. The TV's is + * [TV_PREFS_NAME], unchanged from when this lived in the TV app, so choices + * made there survive the move. + */ +class ProfileLaunchPreferences( + private val prefs: SharedPreferences, + private val clock: () -> Long = System::currentTimeMillis, +) { + constructor(context: Context, prefsName: String) : + this(context.getSharedPreferences(prefsName, Context.MODE_PRIVATE)) + + private val _state = MutableStateFlow(read()) + val state: StateFlow = _state.asStateFlow() + + fun requiresSelectionAtLaunch(): Boolean = state.value.requiresSelectionAtLaunch(clock()) + + fun requiresSelectionAfterBackground(): Boolean = state.value.requiresSelectionAfterBackground(clock()) + + fun allowsWatchNext(): Boolean = state.value.allowsWatchNext(clock()) + + fun setBehavior(behavior: ProfileLaunchBehavior) = update { it.withBehavior(behavior) } + + /** + * Android TV only. Saves the starting choice the first time this TV runs + * Prairie; later launches keep what is saved. [hasRememberedProfile] says + * whether the TV already opens a profile from an earlier version. The + * phone never calls this, so it starts at Automatic with no offer. + */ + @Synchronized + fun settleFirstLaunch(hasRememberedProfile: Boolean) { + if (prefs.contains(KEY_BEHAVIOR)) return + write(ProfileLaunchState.firstLaunch(hasRememberedProfile)) + } + + val isRememberOfferPending: Boolean get() = state.value.rememberOfferPending + + fun answerRememberOffer(remember: Boolean) = update { it.answeringRememberOffer(remember) } + + /** Start the away clock. A no-op under Automatic. */ + fun markBackgrounded() = update { it.markedBackgrounded(clock()) } + + /** End the away interval: Prairie is in use again with a chosen profile. */ + fun clearBackgroundedAt() = update { it.copy(backgroundedAtMs = null) } + + @Synchronized + private fun update(change: (ProfileLaunchState) -> ProfileLaunchState) { + val next = change(_state.value) + if (next == _state.value) return + write(next) + } + + private fun write(next: ProfileLaunchState) { + prefs.edit { + putString(KEY_BEHAVIOR, next.behavior.raw) + val at = next.backgroundedAtMs + if (at == null) remove(KEY_BACKGROUNDED_AT) else putLong(KEY_BACKGROUNDED_AT, at) + if (next.rememberOfferPending) putBoolean(KEY_REMEMBER_OFFER, true) else remove(KEY_REMEMBER_OFFER) + } + _state.value = next + } + + private fun read(): ProfileLaunchState = ProfileLaunchState( + behavior = ProfileLaunchBehavior.fromRaw(prefs.getString(KEY_BEHAVIOR, null)), + backgroundedAtMs = if (prefs.contains(KEY_BACKGROUNDED_AT)) prefs.getLong(KEY_BACKGROUNDED_AT, 0L) else null, + rememberOfferPending = prefs.getBoolean(KEY_REMEMBER_OFFER, false), + ) + + companion object { + /** The Android TV app's file. */ + const val TV_PREFS_NAME = "silo_tv_profile_launch" + + /** The phone and tablet app's file. */ + const val PHONE_PREFS_NAME = "silo_profile_launch" + + private const val KEY_BEHAVIOR = "behavior" + private const val KEY_BACKGROUNDED_AT = "backgrounded_at_ms" + private const val KEY_REMEMBER_OFFER = "remember_offer_pending" + } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/requests/RequestRouter.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/requests/RequestRouter.kt new file mode 100644 index 0000000000..ccb2ef96d4 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/requests/RequestRouter.kt @@ -0,0 +1,52 @@ +package org.prairieserver.prairie.common.requests + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import org.prairieserver.prairie.model.request.MediaRequest +import org.prairieserver.prairie.model.request.RequestMediaResult +import org.prairieserver.prairie.model.request.libraryItemToOpen +import org.prairieserver.prairie.repository.RequestDetailCache +import org.prairieserver.prairie.repository.RequestsRepository +import org.prairieserver.prairie.repository.cacheKey + +/** + * The standard taps on request cards and rows, on phone and TV: a title that + * reads "In library" opens the library item; anything else opens the request + * detail, seeded with what the tapped card already knows so the page is + * complete on its first frame. Only an approval queue opens a page pinned to + * one request; every other way in opens an ordinary page. + */ +class RequestRouter internal constructor( + private val repository: RequestsRepository, + private val openLibraryItem: () -> (String) -> Unit, + private val openRequestDetail: () -> (mediaType: String, tmdbId: Int) -> Unit, +) { + fun openResult(result: RequestMediaResult) { + repository.cache.seed(result) + repository.cache.unpinModeration(RequestDetailCache.Key(result.mediaType, result.tmdbId)) + result.libraryItemToOpen()?.let { openLibraryItem()(it) } ?: openRequestDetail()(result.mediaType, result.tmdbId) + } + + fun openRecord(record: MediaRequest) { + repository.cache.unpinModeration(record.cacheKey()) + record.libraryItemToOpen()?.let { openLibraryItem()(it) } ?: openRequestDetail()(record.mediaType, record.tmdbId) + } + + /** Opens someone else's request from an approval queue: the page decides on that exact request. */ + fun openModerationRecord(record: MediaRequest) { + repository.cache.pinModeration(record) + openRequestDetail()(record.mediaType, record.tmdbId) + } +} + +@Composable +fun rememberRequestRouter( + repository: RequestsRepository, + onLibraryItemClick: (String) -> Unit, + onRequestDetailClick: (mediaType: String, tmdbId: Int) -> Unit, +): RequestRouter { + val library = rememberUpdatedState(onLibraryItemClick) + val detail = rememberUpdatedState(onRequestDetailClick) + return remember(repository) { RequestRouter(repository, { library.value }, { detail.value }) } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/requests/RequestUi.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/requests/RequestUi.kt new file mode 100644 index 0000000000..02476f1cb8 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/requests/RequestUi.kt @@ -0,0 +1,133 @@ +package org.prairieserver.prairie.common.requests + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale +import org.prairieserver.prairie.model.request.MediaRequest +import org.prairieserver.prairie.model.request.RequestDisplayState +import org.prairieserver.prairie.model.request.RequestMediaType +import org.prairieserver.prairie.model.request.RequestProgress +import org.prairieserver.prairie.model.request.RequestStatusTint +import org.prairieserver.prairie.model.request.RequestStep +import org.prairieserver.prairie.model.request.RequestTargetSummary +import org.prairieserver.prairie.model.request.requestReasonCopy + +/** + * The request status colors, shared by phone and TV and matching the Apple + * clients. Status text stays monochrome; the dot and the current stage are the + * only chromatic elements. + */ +object RequestColors { + val Amber = Color(0xFFF59E0B) + val Sky = Color(0xFF38BDF8) + val Emerald = Color(0xFF34D399) + val Rose = Color(0xFFFB7185) +} + +fun RequestStatusTint.color(neutral: Color): Color = when (this) { + RequestStatusTint.Amber -> RequestColors.Amber + RequestStatusTint.Sky -> RequestColors.Sky + RequestStatusTint.Emerald -> RequestColors.Emerald + RequestStatusTint.Rose -> RequestColors.Rose + RequestStatusTint.Neutral -> neutral +} + +/** + * Four-segment stage track: steps behind the request are solid, the step it's + * on carries the status tint, and the rest stay dim. + */ +@Composable +fun RequestStageTrack( + progress: RequestProgress, + modifier: Modifier = Modifier, + height: Dp = 4.dp, + doneColor: Color = Color(0xFFEDEDED).copy(alpha = 0.82f), + restColor: Color = Color.White.copy(alpha = 0.14f), +) { + Row( + modifier = modifier + .fillMaxWidth() + .semantics { contentDescription = progress.longLabel }, + horizontalArrangement = Arrangement.spacedBy(height), + ) { + RequestStep.entries.forEach { step -> + val fill = when { + step == progress.currentStep -> progress.tint.color(restColor) + step.ordinal < progress.completedSteps -> doneColor + else -> restColor + } + Box( + modifier = Modifier + .weight(1f) + .height(height) + .background(fill, RoundedCornerShape(50)), + ) + } + } +} + +/** Row and caption copy shared by the phone list and the TV preview panel. */ +object RequestRowCopy { + private val dayFormat = DateTimeFormatter.ofPattern("MMM d", Locale.getDefault()) + private val stampFormat = DateTimeFormatter.ofPattern("MMM d 'at' h:mm a", Locale.getDefault()) + + fun mediaTypeLabel(mediaType: String): String = when (mediaType) { + RequestMediaType.Movie -> "Movie" + RequestMediaType.Series -> "Series" + else -> "Title" + } + + /** "Sep 24" for a server timestamp, or null when it can't be read. */ + fun day(timestamp: String?): String? = format(timestamp, dayFormat) + + /** "Sep 10 at 8:50 AM", for the detail page's step timestamps. */ + fun stamp(timestamp: String?): String? = format(timestamp, stampFormat) + + /** "Movie · Requested Sep 24", or "Movie · Added Sep 13" once it landed. */ + fun meta(record: MediaRequest, progress: RequestProgress): String { + val kind = mediaTypeLabel(record.mediaType) + if (progress.display == RequestDisplayState.InLibrary) { + day(record.completedAt)?.let { return "$kind · Added $it" } + } + return day(record.createdAt)?.let { "$kind · Requested $it" } ?: kind + } + + /** + * The status line under the stage track: the long label, plus the + * per-quality summary while in flight or the reason when stuck. + */ + fun status(record: MediaRequest, progress: RequestProgress): String { + val display = progress.display + if (display is RequestDisplayState.NeedsAttention) { + requestReasonCopy(display.reason)?.let { return "${progress.longLabel} · $it" } + } + if (display == RequestDisplayState.OnTheWay) { + RequestTargetSummary.text(record.targets)?.let { return "${progress.longLabel} · $it" } + } + return progress.longLabel + } + + /** "Series · 1080p · 4K" for admin rows. */ + fun kindAndQuality(record: MediaRequest): String = + listOfNotNull(mediaTypeLabel(record.mediaType), RequestTargetSummary.qualities(record.targets)).joinToString(" · ") + + private fun format(timestamp: String?, formatter: DateTimeFormatter): String? { + val value = timestamp?.takeIf { it.isNotBlank() } ?: return null + return runCatching { formatter.format(Instant.parse(value).atZone(ZoneId.systemDefault())) }.getOrNull() + } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/AndroidPlayerSettingsStore.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/AndroidPlayerSettingsStore.kt index e9d71fec12..69f259f914 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/AndroidPlayerSettingsStore.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/AndroidPlayerSettingsStore.kt @@ -8,12 +8,14 @@ import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.preferencesDataStoreFile import org.prairieserver.prairie.domain.player.IntroSkipMode import org.prairieserver.prairie.model.download.DownloadQuality import org.prairieserver.prairie.model.settings.EffectiveSettingValue import org.prairieserver.prairie.model.settings.LanguageOptions import org.prairieserver.prairie.model.settings.PlaybackSettingsKeys +import org.prairieserver.prairie.model.settings.PlaybackSpeedRange import org.prairieserver.prairie.model.settings.QualityPresets import org.prairieserver.prairie.model.settings.SettingKeys import org.prairieserver.prairie.model.settings.SettingScope @@ -51,6 +53,8 @@ class AndroidPlayerSettingsStore( private val getDeviceId: suspend () -> String? = { null }, private val serverChangeSignal: Flow = flowOf(Unit), private val getAuthScope: suspend () -> org.prairieserver.prairie.network.AuthScopeSnapshot? = { null }, + /** Null in tests that never reach a server; text opacity then counts as supported. */ + private val contractRevision: SettingsContractRevision? = null, private val dataStoreFactory: (profileId: String) -> DataStore = { profileId -> PreferenceDataStoreFactory.create( produceFile = { context.preferencesDataStoreFile(fileNameFor(profileId)) }, @@ -261,8 +265,12 @@ class AndroidPlayerSettingsStore( override val hdrEnabledFlow: Flow = profileScopedFlow(true) { p, s -> p.boolFor(s, PlaybackSettingsKeys.HdrEnabled, true) } + // Off until the server says otherwise: the contract default is false, so + // defaulting on here sent Profile 7 sources down the HDR10 path before the + // first refresh (or offline) when the server would have played them as + // Dolby Vision. override val dvProfile7HDR10FallbackFlow: Flow = - profileScopedFlow(true) { p, s -> p.boolFor(s, PlaybackSettingsKeys.DvProfile7HDR10Fallback, true) } + profileScopedFlow(false) { p, s -> p.boolFor(s, PlaybackSettingsKeys.DvProfile7HDR10Fallback, false) } override val dolbyVisionEnabledFlow: Flow = profileScopedFlow(true) { p, s -> p.boolFor(s, PlaybackSettingsKeys.DolbyVisionEnabled, true) } @@ -286,6 +294,9 @@ class AndroidPlayerSettingsStore( ?: LetterboxExpansion.Default } + override val trueBlackBarsFlow: Flow = + profileScopedFlow(false) { p, s -> p.boolFor(s, PlaybackSettingsKeys.TrueBlackBars, false) } + override val downloadsWifiOnlyFlow: Flow = profileScopedFlow(true) { p, s -> p.boolFor(s, PlaybackSettingsKeys.DownloadsWifiOnly, true) } @@ -307,7 +318,10 @@ class AndroidPlayerSettingsStore( // ---- Doubles ------------------------------------------------------- override val playbackSpeedFlow: Flow = profileScopedFlow(1.0) { p, s -> - p.stringFor(s, PlaybackSettingsKeys.PlaybackSpeed, "1.0").toDoubleOrNull() ?: 1.0 + // Normalized on read too, so a value an older build stored outside + // the contract range (up to 4.0) is not played back as is. + p.stringFor(s, PlaybackSettingsKeys.PlaybackSpeed, "1.0").toDoubleOrNull() + ?.let(PlaybackSpeedRange::normalize) ?: 1.0 } // ---- Ints ---------------------------------------------------------- @@ -386,6 +400,9 @@ class AndroidPlayerSettingsStore( override val subtitleMatchesDeviceFlow: Flow = profileScopedFlow(false) { p, s -> p.boolFor(s, PlaybackSettingsKeys.SubtitleMatchesDevice, false) } + override val deviceOverrideKeysFlow: Flow> = + profileScopedFlow(emptySet()) { p, s -> p[deviceOverridesKey(s)].orEmpty() } + override val showAudiobooksFlow: Flow = profileScopedFlow(false) { p, s -> p.boolFor(s, PlaybackSettingsKeys.NavShowAudiobooks, false) } @@ -400,6 +417,17 @@ class AndroidPlayerSettingsStore( if (matchDevice) deviceCaptioningAppearance(context, appearance) else appearance } + override val subtitleTextOpacitySupportedFlow: Flow = + if (contractRevision == null) { + flowOf(true) + } else { + combine(currentScopeFlow, contractRevision.known) { scope, known -> + val revision = known?.takeIf { scope != null && it.serverUrl == scope.serverUrl } + ?.manifestRevision + revision == null || SubtitleAppearance.supportsTextOpacity(revision) + }.distinctUntilChanged() + } + override suspend fun setSubtitleMatchesDevice(enabled: Boolean) = writeBoolLocal(PlaybackSettingsKeys.SubtitleMatchesDevice, enabled) @@ -449,6 +477,9 @@ class AndroidPlayerSettingsStore( writeStringLocal(PlaybackSettingsKeys.LetterboxExpansion, safe) } + override suspend fun setTrueBlackBars(value: Boolean) = + writeBoolLocal(PlaybackSettingsKeys.TrueBlackBars, value) + override suspend fun setDownloadsWifiOnly(value: Boolean) = writeBoolLocal(PlaybackSettingsKeys.DownloadsWifiOnly, value) @@ -459,9 +490,14 @@ class AndroidPlayerSettingsStore( writeStringLocal(PlaybackSettingsKeys.DefaultDownloadQuality, DownloadQuality.fromWire(value).wire) override suspend fun setPlaybackSpeed(value: Double) { - val clamped = value.coerceIn(0.25, 4.0) + // The server refuses a speed outside the contract range or off its + // step, and the flusher then drops the write for good. + val clamped = PlaybackSpeedRange.normalize(value) withScope { scope, store -> - store.edit { it[stringPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.PlaybackSpeed)] = clamped.toString() } + store.edit { + it[stringPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.PlaybackSpeed)] = clamped.toString() + markDeviceOverride(it, scope, PlaybackSettingsKeys.PlaybackSpeed) + } serverSettingsFlusher.enqueue( scope.profileId, PlaybackSettingsKeys.PlaybackSpeed, @@ -502,6 +538,7 @@ class AndroidPlayerSettingsStore( store.edit { it[stringPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.PreferredQuality)] = normalized it[intPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.MaxBitrateKbps)] = capped + markDeviceOverride(it, scope, PlaybackSettingsKeys.PreferredQuality, PlaybackSettingsKeys.MaxBitrateKbps) } serverSettingsFlusher.enqueue( scope.profileId, @@ -520,8 +557,20 @@ class AndroidPlayerSettingsStore( } } - override suspend fun setAudioLanguage(value: String) = - writeString(PlaybackSettingsKeys.AudioLanguage, value) + /** + * `""` (no preference) clears this device's value instead of storing an + * explicit null over the profile's language. The legacy endpoint spelled + * "no preference" as `""` and its migration turned that into no stored + * row; sending JSON null at device scope would instead pin "no + * preference" here and hide the profile's choice. + */ + override suspend fun setAudioLanguage(value: String) { + if (value.isEmpty()) { + resetDeviceSetting(PlaybackSettingsKeys.AudioLanguage) + } else { + writeString(PlaybackSettingsKeys.AudioLanguage, value) + } + } override suspend fun setVideoGravity(value: String) { val safe = if (value in VALID_VIDEO_GRAVITY) value else "fit" @@ -532,10 +581,27 @@ class AndroidPlayerSettingsStore( writeString(PlaybackSettingsKeys.OrientationMode, value) override suspend fun setSubtitleAppearance(value: SubtitleAppearance) { - val sanitized = value.sanitized() - val json = sanitized.toJsonString() + updateSubtitleAppearance { value } + } + + // Reads the current appearance from `prefs` inside the same `edit` + // transaction that writes the transformed result. DataStore serializes + // `edit` calls against each other (each transform lambda runs to + // completion holding the store's internal lock before the next one + // starts), so this is the actual fix for two callers racing on a + // separately-read "current" value — not just a smaller window. + // + // The enqueue happens inside the transaction for the same reason: the + // flusher keeps the last value enqueued per key, so enqueueing after + // `edit` returns would let two concurrent edits enqueue in the opposite + // order to the one they committed in, and the server would keep the + // older composite. + override suspend fun updateSubtitleAppearance(transform: (SubtitleAppearance) -> SubtitleAppearance) { withScope { scope, store -> store.edit { prefs -> + val current = prefs.projectedAppearance(scope) + val sanitized = transform(current).sanitized() + val json = sanitized.toJsonString() prefs[stringPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.SubtitleAppearance)] = json prefs[stringPreferencesKey(scope.keyPrefix + SAVED_CUSTOM_SUBTITLE_APPEARANCE)] = json // The granular slots are rewritten from the composite rather @@ -545,8 +611,9 @@ class AndroidPlayerSettingsStore( // Setting an explicit appearance implicitly enables the // device override (matches iOS `setSubtitleAppearance`). prefs[booleanPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.SubtitleUsesDeviceOverride)] = true + markDeviceOverride(prefs, scope, PlaybackSettingsKeys.SubtitleAppearance) + serverSettingsFlusher.enqueue(scope.profileId, PlaybackSettingsKeys.SubtitleAppearance, json, scope.serverUrl, scope.authority) } - serverSettingsFlusher.enqueue(scope.profileId, PlaybackSettingsKeys.SubtitleAppearance, json, scope.serverUrl, scope.authority) } } @@ -561,15 +628,17 @@ class AndroidPlayerSettingsStore( */ override suspend fun flushProjectedSubtitleAppearance() { withScope { scope, store -> - val snapshot = store.data.first() - val projected = snapshot.projectedAppearance(scope) - val json = projected.toJsonString() - if (snapshot.stringFor(scope, PlaybackSettingsKeys.SubtitleAppearance, "") == json) return@withScope + // Projected and enqueued inside the transaction, like + // [updateSubtitleAppearance], so a concurrent edit cannot commit + // between the read and the write or enqueue out of commit order. store.edit { prefs -> + val json = prefs.projectedAppearance(scope).toJsonString() + if (prefs.stringFor(scope, PlaybackSettingsKeys.SubtitleAppearance, "") == json) return@edit prefs[stringPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.SubtitleAppearance)] = json prefs[stringPreferencesKey(scope.keyPrefix + SAVED_CUSTOM_SUBTITLE_APPEARANCE)] = json + markDeviceOverride(prefs, scope, PlaybackSettingsKeys.SubtitleAppearance) + serverSettingsFlusher.enqueue(scope.profileId, PlaybackSettingsKeys.SubtitleAppearance, json, scope.serverUrl, scope.authority) } - serverSettingsFlusher.enqueue(scope.profileId, PlaybackSettingsKeys.SubtitleAppearance, json, scope.serverUrl, scope.authority) } } @@ -586,7 +655,21 @@ class AndroidPlayerSettingsStore( // ---- Server-sync surface ------------------------------------------ override suspend fun refreshFromServer() { - val repo = settingsRepository ?: return + refreshFromServerConfirmed() + } + + /** + * [refreshFromServer], answering whether the server's effective values + * were read and applied. + * + * With [pinned], the refresh is for that captured scope only: the request + * carries its authority, the answer is written to its store, and a scope + * that stops being the active one (a profile switch, a sign-out, a server + * change) makes it apply nothing and answer false. Without it the refresh + * follows whichever scope is active when it runs. + */ + private suspend fun refreshFromServerConfirmed(pinned: Scope? = null): Boolean { + val repo = settingsRepository ?: return false // Push before pull. Local writes sit in the flusher's debounce for // ~750ms; a refresh inside that window read the server's OLD value and // wrote it back over the change the user had just made. The player @@ -594,26 +677,65 @@ class AndroidPlayerSettingsStore( // restarting the session in place reverted the toggle every time. // Draining first makes the pull observe the write. Offline, both // fail and the local value stands. + // + // Re-read the server's settings revision first, so the push below + // gates revision-dependent fields on a current answer and a server + // upgraded while the app ran is noticed. + contractRevision?.let { revision -> + (pinned?.serverUrl ?: getServerUrl())?.takeIf { it.isNotBlank() }?.let { url -> runCatching { revision.refresh(url) } } + } runCatching { serverSettingsFlusher.flushNow() } - withScope { scope, store -> - // Batched canonical resolution: one request answers every - // device-relevant key, each with the scope it resolved from. - val result = repo.getEffectiveValues(RemoteDeviceSettings) - if (result !is ApiResult.Success) return@withScope - // Draining is not the same as landing: a write that failed - // transiently stays queued for retry and flushNow still returns - // normally, so this response was answered from the value the write - // has not reached yet. Applying it for those keys is the same - // clobber the push-first order exists to prevent — it put the - // server's old Dolby Vision value back and restarted the session on - // it. Every other key still hydrates from the canonical answer. - applyEffectiveLocally( - scope = scope, - store = store, - effective = result.data, - unlandedKeys = serverSettingsFlusher.pendingKeys(scope.profileId), - ) + if (pinned != null) { + return applyServerEffective(repo, pinned, storeFor(pinned.profileId), pinned.authority) { isStillCurrent(pinned) } } + var applied = false + withScope { scope, store -> applied = applyServerEffective(repo, scope, store, authority = null) { true } } + return applied + } + + private suspend fun applyServerEffective( + repo: SettingsRepository, + scope: Scope, + store: DataStore, + authority: org.prairieserver.prairie.network.AuthScopeSnapshot?, + isCurrent: suspend () -> Boolean, + ): Boolean { + if (!isCurrent()) return false + // Batched canonical resolution: one request answers every + // device-relevant key, each with the scope it resolved from. + val result = repo.getEffectiveValues(RemoteDeviceSettings, authority = authority) + if (result !is ApiResult.Success || !isCurrent()) return false + // Draining is not the same as landing: a write that failed + // transiently stays queued for retry and flushNow still returns + // normally, so this response was answered from the value the write + // has not reached yet. Applying it for those keys is the same + // clobber the push-first order exists to prevent — it put the + // server's old Dolby Vision value back and restarted the session on + // it. Every other key still hydrates from the canonical answer. + return applyEffectiveLocally( + scope = scope, + store = store, + effective = result.data, + unlandedKeys = serverSettingsFlusher.pendingKeys(scope.profileId), + isCurrent = isCurrent, + ) + } + + /** + * Whether [captured] is still the active scope: same profile, server and + * device, and the same signed-in identity (the check + * [importLegacyDeviceSettings] applies to its captured authority). + */ + private suspend fun isStillCurrent(captured: Scope): Boolean { + val now = currentScope() ?: return false + if (now.profileId != captured.profileId || now.serverUrl != captured.serverUrl || now.deviceId != captured.deviceId) { + return false + } + val owner = captured.authority ?: return now.authority == null + val current = now.authority + return owner.isSameIdentityAs(current) && + owner.profileId == current?.profileId && + owner.profileToken == current?.profileToken } override suspend fun setSubtitleDeviceOverrideEnabled(enabled: Boolean) { @@ -638,8 +760,10 @@ class AndroidPlayerSettingsStore( // otherwise the fields left by whatever resolved while the // override was off win right back over it. writeGranularAppearance(it, scope, sanitized) + markDeviceOverride(it, scope, PlaybackSettingsKeys.SubtitleAppearance) + // In the transaction, so it enqueues in commit order. + serverSettingsFlusher.enqueue(scope.profileId, PlaybackSettingsKeys.SubtitleAppearance, json, scope.serverUrl, scope.authority) } - serverSettingsFlusher.enqueue(scope.profileId, PlaybackSettingsKeys.SubtitleAppearance, json, scope.serverUrl, scope.authority) serverSettingsFlusher.flushNow() } else { store.edit { @@ -650,6 +774,7 @@ class AndroidPlayerSettingsStore( ?.let { value -> it[savedCustomKey] = value } } it[booleanPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.SubtitleUsesDeviceOverride)] = false + clearDeviceOverride(it, scope, PlaybackSettingsKeys.SubtitleAppearance) } serverSettingsFlusher.enqueueDelete(scope.profileId, PlaybackSettingsKeys.SubtitleAppearance, scope.serverUrl, scope.authority) serverSettingsFlusher.flushNow() @@ -658,15 +783,35 @@ class AndroidPlayerSettingsStore( } } + /** + * Goes back to the profile's value for [key] on this device: deletes the + * device-scoped row through the same scoped queue every write uses, so a + * profile switch or an offline spell treats the clear exactly like a set. + * + * Keys one control writes together ([PlaybackSettingsKeys.deviceOverrideGroup]) + * are cleared together. The override marker is dropped right away, so the + * picker shows the choice at once; the value itself follows from the + * refresh once the delete has landed. Until then the device keeps playing + * with the value it had, because the refresh leaves a key with an unsent + * write alone. + */ override suspend fun resetDeviceSetting(key: String) { - withScope { scope, _ -> - serverSettingsFlusher.enqueueDelete(scope.profileId, key, scope.serverUrl, scope.authority) + val keys = PlaybackSettingsKeys.deviceOverrideGroup(key).filter { it in RemoteDeviceSettings } + if (keys.isEmpty()) return + withScope { scope, store -> + store.edit { + clearDeviceOverride(it, scope, *keys.toTypedArray()) + for (cleared in keys) { + serverSettingsFlusher.enqueueDelete(scope.profileId, cleared, scope.serverUrl, scope.authority) + } + } serverSettingsFlusher.flushNow() refreshFromServer() } } - override suspend fun resetAllDeviceSettings() { + override suspend fun resetAllDeviceSettings(): Boolean { + var landed = false withScope { scope, store -> // Only the server-stored keys have rows to delete; the granular // subtitle.* fields live inside playback.subtitle_appearance. @@ -674,6 +819,7 @@ class AndroidPlayerSettingsStore( serverSettingsFlusher.enqueueDelete(scope.profileId, key, scope.serverUrl, scope.authority) } store.edit { + it.remove(deviceOverridesKey(scope)) it[booleanPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.SubtitleUsesDeviceOverride)] = false // The local-only playback keys have no server row to delete, so // the refresh below can never restore their defaults. Removing @@ -683,12 +829,24 @@ class AndroidPlayerSettingsStore( it.remove(booleanPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.PictureInPictureEnabled)) it.remove(booleanPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.ForceHdrPassthrough)) it.remove(stringPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.LetterboxExpansion)) + it.remove(booleanPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.TrueBlackBars)) it.remove(intPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.ResumeRewindSeconds)) it.remove(intPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.PassOutThreshold)) } serverSettingsFlusher.flushNow() - refreshFromServer() + val unsent = serverSettingsFlusher.pendingKeys(scope.profileId).any { it in RemoteDeviceSettings } + // An empty queue is not proof: the flusher also drops deletes it + // cannot retry (a 401/403, a replaced owner). Only the server's + // own answer, with nothing left resolving from this device, + // confirms the profile's settings apply. The refresh is pinned to + // the scope this reset started in: if the user switches profile or + // signs out meanwhile, another profile's answer must neither land + // in this store nor confirm this reset. + val confirmed = refreshFromServerConfirmed(pinned = scope) + val remaining = store.data.first()[deviceOverridesKey(scope)].orEmpty() + landed = !unsent && confirmed && remaining.none { it in RemoteDeviceSettings } && isStillCurrent(scope) } + return landed } override suspend fun flushPendingDeviceSettings() { @@ -703,8 +861,13 @@ class AndroidPlayerSettingsStore( // cannot describe them yet, so the local value stays authoritative // until the queued write lands. unlandedKeys: Set = emptySet(), - ) { + isCurrent: suspend () -> Boolean = { true }, + ): Boolean { + var applied = false store.edit { prefs -> + if (!isCurrent()) return@edit + applied = true + val overrides = prefs[deviceOverridesKey(scope)].orEmpty().toMutableSet() for (key in RemoteDeviceSettings) { if (key in unlandedKeys) continue // The canonical endpoint answers every known key, including @@ -717,7 +880,12 @@ class AndroidPlayerSettingsStore( // guessed at. val entry = effective[key] ?: continue writeJsonValue(prefs, scope, key, entry.value) + // Where the value resolved from is the truth about whether + // this device holds its own, including one set elsewhere (the + // web admin, a previous install) or cleared out-of-band. + if (entry.scope == SettingScope.PROFILE_DEVICE.wire) overrides += key else overrides -= key } + prefs[deviceOverridesKey(scope)] = overrides // An appearance whose write has not landed leaves the flag and the // granular overlay alone too: they describe where the composite // resolved from, and the response predates the queued edit. @@ -746,6 +914,7 @@ class AndroidPlayerSettingsStore( } } } + return applied } /** @@ -803,14 +972,20 @@ class AndroidPlayerSettingsStore( private suspend fun writeBool(key: String, value: Boolean) { withScope { scope, store -> - store.edit { it[booleanPreferencesKey(scope.keyPrefix + key)] = value } + store.edit { + it[booleanPreferencesKey(scope.keyPrefix + key)] = value + markDeviceOverride(it, scope, key) + } serverSettingsFlusher.enqueue(scope.profileId, key, value.toString(), scope.serverUrl, scope.authority) } } private suspend fun writeInt(key: String, value: Int) { withScope { scope, store -> - store.edit { it[intPreferencesKey(scope.keyPrefix + key)] = value } + store.edit { + it[intPreferencesKey(scope.keyPrefix + key)] = value + markDeviceOverride(it, scope, key) + } serverSettingsFlusher.enqueue(scope.profileId, key, value.toString(), scope.serverUrl, scope.authority) } } @@ -841,7 +1016,10 @@ class AndroidPlayerSettingsStore( private suspend fun writeString(key: String, value: String) { withScope { scope, store -> - store.edit { it[stringPreferencesKey(scope.keyPrefix + key)] = value } + store.edit { + it[stringPreferencesKey(scope.keyPrefix + key)] = value + markDeviceOverride(it, scope, key) + } serverSettingsFlusher.enqueue(scope.profileId, key, value, scope.serverUrl, scope.authority) } } @@ -870,6 +1048,9 @@ class AndroidPlayerSettingsStore( store.edit { prefs -> if (!current()) return@edit writeRawString(prefs, scope, key, raw) + // The server now holds it at device scope, so it shows as this + // device's own even before a refresh confirms it. + markDeviceOverride(prefs, scope, key) if (key == PlaybackSettingsKeys.SubtitleAppearance) { val appearance = SubtitleAppearance.decode(raw).sanitized() prefs[stringPreferencesKey(scope.keyPrefix + SAVED_CUSTOM_SUBTITLE_APPEARANCE)] = raw @@ -881,6 +1062,28 @@ class AndroidPlayerSettingsStore( return current() } + private fun deviceOverridesKey(scope: Scope): Preferences.Key> = + stringSetPreferencesKey(scope.keyPrefix + DEVICE_OVERRIDE_KEYS) + + /** Records that this device now holds its own value for [keys]. */ + private fun markDeviceOverride( + prefs: androidx.datastore.preferences.core.MutablePreferences, + scope: Scope, + vararg keys: String, + ) { + val remote = keys.filter { it in RemoteDeviceSettings } + if (remote.isEmpty()) return + prefs[deviceOverridesKey(scope)] = prefs[deviceOverridesKey(scope)].orEmpty() + remote + } + + private fun clearDeviceOverride( + prefs: androidx.datastore.preferences.core.MutablePreferences, + scope: Scope, + vararg keys: String, + ) { + prefs[deviceOverridesKey(scope)] = prefs[deviceOverridesKey(scope)].orEmpty() - keys.toSet() + } + private suspend inline fun withScope( block: (scope: Scope, store: DataStore) -> Unit, ) { @@ -946,6 +1149,12 @@ class AndroidPlayerSettingsStore( private companion object { const val SAVED_CUSTOM_SUBTITLE_APPEARANCE = "subtitle_appearance.saved_custom" + /** + * Local-only: the server-stored keys this device holds its own value + * for. Set by every write from here and corrected by every refresh, + * which says where each value resolved from. + */ + const val DEVICE_OVERRIDE_KEYS = "device_override_keys" const val MIGRATION_SENTINEL_LEGACY = "migration_v1" const val MISSING_SENTINEL = "__missing__" // F1/F2 local-only defaults (mirror DefaultResumeRewindSeconds=7.0 and diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/BrowsePrefsStore.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/BrowsePrefsStore.kt new file mode 100644 index 0000000000..e26a125b7e --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/BrowsePrefsStore.kt @@ -0,0 +1,66 @@ +package org.prairieserver.prairie.common.settings + +import android.content.Context +import androidx.core.content.edit +import kotlinx.serialization.json.Json +import org.prairieserver.prairie.catalog.filter.CatalogFilterState +import org.prairieserver.prairie.network.ServerRegistry + +/** + * Persists browse filter + sort state per server, per profile, per library — + * mirrors iOS `BrowsePrefsStore` (`ios.browsePrefs...`, + * here `.browsePrefs.…`). Nothing is persisted without an active + * profile, so anonymous browsing never leaks into a profile's saved state. + * + * The phone and TV apps each pass their own [keyPrefix] (`android`, + * `androidtv`), as Apple keeps `ios.` / `tv.` / `mac.` apart. + * + * A Movies- or Series-scoped view of a mixed library saves under its own + * `.type-` key, so the two views keep separate filters. + * + * Gated by a user-facing "Preserve sort & filters" toggle (default ON); + * turning it off clears the saved state. + */ +class BrowsePrefsStore( + context: Context, + private val serverRegistry: ServerRegistry, + private val keyPrefix: String = "android", +) { + private val prefs = context.getSharedPreferences("browse_prefs", Context.MODE_PRIVATE) + private val json = Json { ignoreUnknownKeys = true } + + private fun base(libraryId: Int?, mediaScope: String? = null): String? { + val serverId = serverRegistry.activeServerId.value ?: "default" + val profileId = serverRegistry.activeEntry.value?.profileId + ?.takeIf { it.isNotBlank() } ?: return null + val lib = libraryId?.toString() ?: "all" + return "$keyPrefix.browsePrefs.$serverId.$profileId.$lib" + (mediaScope?.let { ".type-$it" } ?: "") + } + + fun savedState(libraryId: Int?, mediaScope: String? = null): CatalogFilterState? { + if (!preserveEnabled(libraryId, mediaScope)) return null + val key = base(libraryId, mediaScope) ?: return null + val raw = prefs.getString("$key.state", null) ?: return null + return runCatching { json.decodeFromString(raw) }.getOrNull() + } + + fun saveState(libraryId: Int?, state: CatalogFilterState, mediaScope: String? = null) { + if (!preserveEnabled(libraryId, mediaScope)) return + val key = base(libraryId, mediaScope) ?: return + prefs.edit { putString("$key.state", json.encodeToString(state)) } + } + + /** Default ON when the key is absent (iOS parity). */ + fun preserveEnabled(libraryId: Int?, mediaScope: String? = null): Boolean { + val key = base(libraryId, mediaScope) ?: return false + return prefs.getBoolean("$key.preserve", true) + } + + fun setPreserveEnabled(libraryId: Int?, enabled: Boolean, mediaScope: String? = null) { + val key = base(libraryId, mediaScope) ?: return + prefs.edit { + putBoolean("$key.preserve", enabled) + if (!enabled) remove("$key.state") + } + } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/CardPresentationStore.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/CardPresentationStore.kt index 05eb2cd054..5acfbf4a07 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/CardPresentationStore.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/CardPresentationStore.kt @@ -460,7 +460,7 @@ class DefaultCardPresentationStore( val deviceId: String, ) { val cacheKeyPrefix: String = - "cp_" + sha256Hex("$serverUrl|$profileId|$clientFamily|$deviceId").take(24) + settingsCachePrefix("cp_", "$serverUrl|$profileId|$clientFamily|$deviceId") } private suspend fun currentIdentity(): Identity? { @@ -530,10 +530,5 @@ class DefaultCardPresentationStore( CardPresentationSource.Default -> EffectiveSettingValue.SOURCE_DEFAULT CardPresentationSource.Unknown -> null } - - fun sha256Hex(s: String): String = - java.security.MessageDigest.getInstance("SHA-256") - .digest(s.toByteArray(Charsets.UTF_8)) - .joinToString(separator = "") { "%02x".format(it) } } } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/DeviceCaptioningAppearance.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/DeviceCaptioningAppearance.kt index ae85ddcfc5..cda2c5ba29 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/DeviceCaptioningAppearance.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/DeviceCaptioningAppearance.kt @@ -23,6 +23,7 @@ fun deviceCaptioningAppearance(context: Context, base: SubtitleAppearance): Subt val edgeType = style.takeIf { it.hasEdgeType() }?.edgeType val edgeColor = style.takeIf { it.hasEdgeColor() }?.edgeColor + val foregroundAlpha = fontColor?.let { AndroidColor.alpha(it) } val backgroundAlpha = backgroundColor?.let { AndroidColor.alpha(it) } val backgroundStyle = when { backgroundAlpha == null -> SubtitleBackgroundStylePreset.Box @@ -34,6 +35,7 @@ fun deviceCaptioningAppearance(context: Context, base: SubtitleAppearance): Subt fontSize = fontSizePresetFor(manager.fontScale), fontFamily = base.fontFamily, fontColor = fontColor?.let(::rgbHex) ?: base.fontColor, + textOpacity = foregroundAlpha?.let { (it * 100 / 255).coerceIn(1, 100) } ?: base.textOpacity, backgroundColor = backgroundColor?.let(::rgbHex) ?: base.backgroundColor, backgroundStyle = backgroundStyle, backgroundOpacity = backgroundAlpha?.let { (it * 100) / 255 } ?: base.backgroundOpacity, diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/EpisodeSpoilerStore.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/EpisodeSpoilerStore.kt new file mode 100644 index 0000000000..83ce6ea231 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/EpisodeSpoilerStore.kt @@ -0,0 +1,457 @@ +package org.prairieserver.prairie.common.settings + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import org.prairieserver.prairie.model.settings.EffectiveSettingValue +import org.prairieserver.prairie.model.settings.EpisodeSpoilerPrefs +import org.prairieserver.prairie.model.settings.EpisodeSpoilers +import org.prairieserver.prairie.model.settings.SettingKeys +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.repository.SettingsRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull + +/** Whether the connected server knows the revision-17 spoiler keys. */ +enum class EpisodeSpoilerSupport { + Supported, + + /** Definitive answer: the server predates the keys. Never write. */ + Unsupported, + + /** No answer yet (cold start without a cache, or offline). */ + Unknown, +} + +/** One of the two spoiler switches. */ +enum class EpisodeSpoilerSetting(val key: String) { + Images(SettingKeys.CATALOG_HIDE_UNWATCHED_EPISODE_IMAGES), + Overviews(SettingKeys.CATALOG_HIDE_UNWATCHED_EPISODE_OVERVIEWS), +} + +data class EpisodeSpoilerState( + val support: EpisodeSpoilerSupport = EpisodeSpoilerSupport.Unknown, + val hideImages: Boolean = false, + val hideOverviews: Boolean = false, +) { + val isSupported: Boolean get() = support == EpisodeSpoilerSupport.Supported + + /** + * What surfaces apply. Nothing is hidden unless the server is known to + * support the keys, so an older server shows episodes as before. + */ + val prefs: EpisodeSpoilerPrefs + get() = if (isSupported) EpisodeSpoilerPrefs(hideImages, hideOverviews) else EpisodeSpoilerPrefs.NONE + + fun value(setting: EpisodeSpoilerSetting): Boolean = when (setting) { + EpisodeSpoilerSetting.Images -> hideImages + EpisodeSpoilerSetting.Overviews -> hideOverviews + } + + fun with(setting: EpisodeSpoilerSetting, enabled: Boolean): EpisodeSpoilerState = when (setting) { + EpisodeSpoilerSetting.Images -> copy(hideImages = enabled) + EpisodeSpoilerSetting.Overviews -> copy(hideOverviews = enabled) + } +} + +/** + * The signed-in profile's spoiler protection for unwatched episodes + * (`catalog.hide_unwatched_episode_images` and + * `catalog.hide_unwatched_episode_overviews`, settings contract revision 17), + * shared by every phone and TV surface through `LocalEpisodeSpoilerPrefs`. + * + * Both keys allow only `scope=profile`, so writes go straight to the profile + * row (never through [ServerSettingsFlusher], which writes `profile_device`). + * Same lifecycle as [CardPresentationStore]: lazy hydration seeded from a + * last-known cache, refresh on foreground and reconnect through + * [ServerDrivenConfigRefresher], optimistic writes with rollback, and [clear] + * at sign-out and profile-switch boundaries. + * + * Like [SeekIntervalStore], it also resets and re-resolves on its own when the + * active server or profile changes, and a hydration only counts for the + * identity it ran against. Signing in to another server through Add Server + * clears nothing, so a store that trusted a plain "already hydrated" flag + * kept the previous server's answer until the next cold start. + */ +interface EpisodeSpoilerStore { + val state: StateFlow + val lastError: StateFlow + + /** Why the last switch change wasn't saved, for Settings; cleared by the next save. */ + val saveError: StateFlow + + /** Idempotent first load. Safe to call from every provider mount. */ + suspend fun hydrateIfNeeded() + + /** Re-probe capabilities and re-resolve both keys. */ + suspend fun refresh() + + /** + * Apply [enabled] locally, then write it at profile scope; restores the + * last server-confirmed value if the write fails. Ignored unless the + * server is known to support the keys. + */ + fun set(setting: EpisodeSpoilerSetting, enabled: Boolean) + + /** Session boundary: drop state so the next profile starts clean. */ + fun clear() +} + +class DefaultEpisodeSpoilerStore private constructor( + private val repository: SettingsRepository, + private val scope: CoroutineScope, + private val getActiveProfileId: suspend () -> String?, + private val getServerUrl: suspend () -> String?, + private val captureAuthority: suspend () -> AuthScopeSnapshot?, + private val cache: EpisodeSpoilerCache, + identityChanges: Flow, +) : EpisodeSpoilerStore { + + constructor( + context: Context, + repository: SettingsRepository, + scope: CoroutineScope, + getActiveProfileId: suspend () -> String?, + getServerUrl: suspend () -> String?, + captureAuthority: suspend () -> AuthScopeSnapshot?, + identityChanges: Flow = emptyFlow(), + ) : this( + repository = repository, + scope = scope, + getActiveProfileId = getActiveProfileId, + getServerUrl = getServerUrl, + captureAuthority = captureAuthority, + cache = SharedPreferencesEpisodeSpoilerCache( + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE), + ), + identityChanges = identityChanges, + ) + + private val _state = MutableStateFlow(EpisodeSpoilerState()) + private val _lastError = MutableStateFlow(null) + override val state: StateFlow = _state.asStateFlow() + override val lastError: StateFlow = _lastError.asStateFlow() + private val _saveError = MutableStateFlow(null) + override val saveError: StateFlow = _saveError.asStateFlow() + + private val lock = Any() + + /** Bumped at every session boundary; stale work compares and bails. */ + @Volatile + private var generation = 0 + + /** Bumped by every local mutation; a refresh that started earlier must + * not paint its older server answer over the newer local value. */ + private var mutationEpoch = 0L + + @Volatile + private var hasHydrated = false + + /** The server|profile the shown state belongs to; null after a reset. */ + @Volatile + private var stateIdentity: String? = null + + /** The auth scope captured with [stateIdentity]; writes are pinned to it so + * a server or profile switch cannot retarget a queued toggle. */ + private var stateAuthority: AuthScopeSnapshot? = null + + /** The last values the server confirmed; a failed write restores these. */ + private var confirmed = EpisodeSpoilerState() + + /** Latest local request per setting. Only that request may change what is + * shown when it settles; an older one only updates [confirmed]. */ + private val latestRequest = mutableMapOf() + private var requestCounter = 0L + + private val refreshLock = Mutex() + + // Serializes writes so the wire order matches the order of the taps. + private val writeLock = Mutex() + + init { + scope.launch { + identityChanges.collectLatest { onIdentityChanged() } + } + } + + private suspend fun onIdentityChanged() { + val identity = currentIdentity() + val authority = captureAuthority() + val startGeneration = synchronized(lock) { + resetLocked() + stateIdentity = identity + stateAuthority = authority + generation + } + // A refresh for the previous identity can hold refreshLock through a + // network call; show this identity's last-known answer meanwhile. + if (identity != null) seedFromCache(identity, startGeneration) + hydrateIfNeeded() + } + + override suspend fun hydrateIfNeeded() { + if (hasHydrated && stateIdentity == currentIdentity()) return + refresh() + } + + override suspend fun refresh(): Unit = refreshLock.withLock { + val identity = currentIdentity() ?: return + val authority = captureAuthority() + val startGeneration: Int + val startEpoch: Long + synchronized(lock) { + // Another server or profile: the shown answer is not ours. + if (stateIdentity != identity) { + resetLocked() + stateIdentity = identity + } + stateAuthority = authority + startGeneration = generation + startEpoch = mutationEpoch + } + seedFromCache(identity, startGeneration) + + val supported = when (val caps = repository.contractCapabilities()) { + is ApiResult.Success -> EpisodeSpoilers.isSupported(caps.data) + // A server without the contract routes answers 404: that is an + // older server, not a transient failure. + is ApiResult.Error -> + if (caps.code == 404) false else return commitError(caps.message, startGeneration) + is ApiResult.NetworkError -> return commitError(caps.exception.message, startGeneration) + } + // Superseded by a session boundary: release refreshLock now rather + // than after a second request whose answer would be discarded. + if (generation != startGeneration) return + val resolved = if (!supported) { + EpisodeSpoilerState(support = EpisodeSpoilerSupport.Unsupported) + } else { + when (val result = repository.getEffectiveValues(EpisodeSpoilers.KEYS, authority = authority)) { + is ApiResult.Success -> EpisodeSpoilerState( + support = EpisodeSpoilerSupport.Supported, + hideImages = decode(result.data, EpisodeSpoilerSetting.Images), + hideOverviews = decode(result.data, EpisodeSpoilerSetting.Overviews), + ) + // A server can reach the revision without these keys when + // another setting took that revision number; it refuses them + // as unknown_setting (404). That is unsupported, not a + // transient failure to retry forever. + is ApiResult.Error -> + if (result.code == 404) EpisodeSpoilerState(support = EpisodeSpoilerSupport.Unsupported) + else return commitError(result.message, startGeneration) + is ApiResult.NetworkError -> + return commitError(result.exception.message, startGeneration) + } + } + val committed = synchronized(lock) { + if (generation != startGeneration || mutationEpoch != startEpoch || latestRequest.isNotEmpty()) { + return@synchronized false + } + _state.value = resolved + confirmed = resolved + _lastError.value = null + hasHydrated = true + true + } + if (committed) cache.write(identity, resolved) + } + + /** Apply the last-known answer while nothing better is on screen. */ + private fun seedFromCache(identity: String, startGeneration: Int) { + if (hasHydrated) return + val cached = cache.read(identity) ?: return + synchronized(lock) { + if (!hasHydrated && generation == startGeneration && + _state.value.support == EpisodeSpoilerSupport.Unknown + ) { + _state.value = cached + confirmed = cached + } + } + } + + /** A transient failure keeps whatever answer (cached or earlier) is shown. */ + private fun commitError(message: String?, startGeneration: Int) { + synchronized(lock) { + if (generation == startGeneration) _lastError.value = message + } + } + + override fun set(setting: EpisodeSpoilerSetting, enabled: Boolean) { + val startGeneration: Int + val request: Long + val identity: String + val authority: AuthScopeSnapshot? + synchronized(lock) { + val current = _state.value + if (!current.isSupported) return + identity = stateIdentity ?: return + authority = stateAuthority + startGeneration = generation + request = ++requestCounter + latestRequest[setting] = request + mutationEpoch += 1 + _state.value = current.with(setting, enabled) + } + scope.launch { + val result = writeLock.withLock { + if (generation != startGeneration) return@launch + repository.setProfileValue(setting.key, JsonPrimitive(enabled), authority) + } + val error = when (result) { + is ApiResult.Success -> null + is ApiResult.Error -> result.message.ifBlank { SAVE_FAILED_MESSAGE } + is ApiResult.NetworkError -> result.exception.message ?: SAVE_FAILED_MESSAGE + } + val snapshot = synchronized(lock) { + if (generation != startGeneration) return@launch + if (error == null) confirmed = confirmed.with(setting, enabled) + if (latestRequest[setting] == request) { + latestRequest.remove(setting) + mutationEpoch += 1 + // Saved: re-assert the value. Failed: return to what the + // server last confirmed. + val shown = if (error == null) enabled else confirmed.value(setting) + _state.value = _state.value.with(setting, shown) + } + _lastError.value = error + _saveError.value = error?.let(::saveErrorText) + confirmed + } + if (error == null) synchronized(lock) { + if (generation == startGeneration && stateIdentity == identity) cache.write(identity, snapshot) + } + } + } + + override fun clear() { + synchronized(lock) { resetLocked() } + } + + private fun resetLocked() { + generation += 1 + mutationEpoch += 1 + hasHydrated = false + stateIdentity = null + stateAuthority = null + _saveError.value = null + _state.value = EpisodeSpoilerState() + confirmed = EpisodeSpoilerState() + latestRequest.clear() + _lastError.value = null + } + + private suspend fun currentIdentity(): String? { + val profileId = getActiveProfileId()?.takeIf { it.isNotBlank() } ?: return null + return "${getServerUrl().orEmpty()}|$profileId" + } + + internal companion object { + const val PREFS_NAME = "silo_episode_spoilers" + const val SAVE_FAILED_MESSAGE = "Could not save the setting." + + /** Same wording as the title art and skip-interval save errors. */ + fun saveErrorText(message: String): String = "Couldn't save spoiler settings: $message" + + /** Only a JSON boolean `true` turns a switch on; anything else is off. */ + fun decode(effective: Map, setting: EpisodeSpoilerSetting): Boolean = + decodeValue(effective[setting.key]?.value) + + private fun decodeValue(value: JsonElement?): Boolean { + val primitive = value as? JsonPrimitive ?: return false + return !primitive.isString && primitive.booleanOrNull == true + } + + /** Test seam: no Android Context, in-memory cache. */ + internal fun forTest( + repository: SettingsRepository, + scope: CoroutineScope, + getActiveProfileId: suspend () -> String? = { "profile-1" }, + getServerUrl: suspend () -> String? = { "https://server.test" }, + captureAuthority: suspend () -> AuthScopeSnapshot? = { null }, + cache: EpisodeSpoilerCache = InMemoryEpisodeSpoilerCache(), + identityChanges: Flow = emptyFlow(), + ): DefaultEpisodeSpoilerStore = DefaultEpisodeSpoilerStore( + repository = repository, + scope = scope, + getActiveProfileId = getActiveProfileId, + getServerUrl = getServerUrl, + captureAuthority = captureAuthority, + cache = cache, + identityChanges = identityChanges, + ) + } +} + +/** + * Last-known answer per server and profile, so a cold start hides spoilers + * before the network answers instead of flashing them first. + */ +internal interface EpisodeSpoilerCache { + fun read(identity: String): EpisodeSpoilerState? + fun write(identity: String, state: EpisodeSpoilerState) +} + +internal class InMemoryEpisodeSpoilerCache : EpisodeSpoilerCache { + private val entries = mutableMapOf() + override fun read(identity: String): EpisodeSpoilerState? = synchronized(entries) { entries[identity] } + override fun write(identity: String, state: EpisodeSpoilerState) { + if (state.support == EpisodeSpoilerSupport.Unknown) return + synchronized(entries) { entries[identity] = state } + } +} + +private class SharedPreferencesEpisodeSpoilerCache( + private val prefs: SharedPreferences, +) : EpisodeSpoilerCache { + + override fun read(identity: String): EpisodeSpoilerState? { + val prefix = prefix(identity) + val support = when (prefs.getString("$prefix.support", null)) { + SUPPORTED -> EpisodeSpoilerSupport.Supported + UNSUPPORTED -> EpisodeSpoilerSupport.Unsupported + else -> return null + } + return EpisodeSpoilerState( + support = support, + hideImages = prefs.getBoolean("$prefix.images", false), + hideOverviews = prefs.getBoolean("$prefix.overviews", false), + ) + } + + override fun write(identity: String, state: EpisodeSpoilerState) { + val support = when (state.support) { + EpisodeSpoilerSupport.Supported -> SUPPORTED + EpisodeSpoilerSupport.Unsupported -> UNSUPPORTED + EpisodeSpoilerSupport.Unknown -> return + } + val prefix = prefix(identity) + prefs.edit { + putString("$prefix.support", support) + putBoolean("$prefix.images", state.hideImages) + putBoolean("$prefix.overviews", state.hideOverviews) + } + } + + private fun prefix(identity: String): String = + "es_" + java.security.MessageDigest.getInstance("SHA-256") + .digest(identity.toByteArray(Charsets.UTF_8)) + .joinToString(separator = "") { "%02x".format(it) } + .take(24) + + private companion object { + const val SUPPORTED = "supported" + const val UNSUPPORTED = "unsupported" + } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/OverlayPrefsStore.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/OverlayPrefsStore.kt index 3fe2941a9b..09124097f1 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/OverlayPrefsStore.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/OverlayPrefsStore.kt @@ -19,6 +19,8 @@ import kotlinx.coroutines.sync.withLock import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull /** * Cached card-overlay configuration for the signed-in profile. Mirrors @@ -40,9 +42,11 @@ import kotlinx.serialization.json.JsonNull */ interface OverlayPrefsStore { /** - * `true` when the server allows overlays at all. An admin can flip - * this off globally; when `false`, cards should not render overlays - * even if the user has prefs configured. + * `true` when cards should render overlays at all. The profile's + * `ui.card_overlays_enabled` choice wins in either direction; a profile + * that has not chosen inherits the server-wide `overlays.enabled` + * default from `GET /settings/overlay-config`. When `false`, cards + * render no overlays even if the profile has prefs configured. */ val enabled: StateFlow @@ -96,6 +100,12 @@ class DefaultOverlayPrefsStore( @Volatile private var adminDefaultsRaw: String? = null + // The two inputs to [enabled], cached separately so a failed read of + // one keeps the last answer for that half without discarding the other. + // Both change only under [stateLock]. + private var serverEnabled: Boolean = true + private var profileEnabled: Boolean? = null + // The last state confirmed by a successful canonical read or write. An // optimistic edit rolls back here when its PUT fails, including when the // network is unavailable and a follow-up refresh would fail as well. @@ -150,18 +160,19 @@ class DefaultOverlayPrefsStore( } /** - * Re-fetch both the admin config and the canonical profile value, then recompute - * [prefs]. + * Re-fetch both the admin config and the canonical profile values, then + * recompute [prefs] and [enabled]. * * Failure semantics mirror iOS: * - A canonical null/default means "not set yet" and renders from admin * defaults or registry defaults. * - Any other transport error on either endpoint leaves * [hasHydrated] false so the next [hydrateIfNeeded] retries. This is - * critical for the admin kill-switch: if `/overlay-config` errors - * but the user setting resolves, we MUST NOT mark hydrated, or - * [enabled] is stuck at `true` and the admin's "disable globally" - * toggle is silently ignored for the session. + * critical for the server-wide overlay default: if `/overlay-config` + * errors but the user settings resolve, we MUST NOT mark hydrated, or + * [enabled] is stuck at `true` for a profile that has not chosen and + * the admin's "off for everyone" default is silently ignored for the + * session. */ override suspend fun refresh() = refreshLock.withLock { val refreshState = synchronized(stateLock) { @@ -194,9 +205,14 @@ class DefaultOverlayPrefsStore( } var userValue: JsonElement? = null + var userEnabled: Boolean? = null var userFetchFailed = false var userError: String? = null - when (val result = repository.getEffectiveValues(listOf(OVERLAY_SETTING_KEY))) { + when ( + val result = repository.getEffectiveValues( + listOf(OVERLAY_SETTING_KEY, OVERLAYS_ENABLED_SETTING_KEY), + ) + ) { is ApiResult.Success -> { val entry = result.data[OVERLAY_SETTING_KEY] if (entry == null) { @@ -208,6 +224,13 @@ class DefaultOverlayPrefsStore( ) { userValue = entry.value } + // A server that predates the key omits it, which reads + // the same as a profile that has not chosen. + userEnabled = result.data[OVERLAYS_ENABLED_SETTING_KEY] + ?.takeIf { it.source == SettingScope.PROFILE.wire } + ?.value + ?.let { it as? JsonPrimitive } + ?.booleanOrNull } is ApiResult.Error -> { userError = result.message @@ -238,7 +261,7 @@ class DefaultOverlayPrefsStore( synchronized(stateLock) { // Clear crosses a session boundary, so none of the old refresh // may land. Profile edits and reset only invalidate the - // user-derived half: the independent admin kill-switch and + // user-derived half: the independent server-wide default and // baseline remain valid and must still update. if (sessionStateEpoch == refreshState.sessionStateEpoch) { val userResponseIsCurrent = @@ -247,7 +270,7 @@ class DefaultOverlayPrefsStore( // sentinel `resolvedEnabled = true` is only valid when the // fetch actually succeeded. if (!configFetchFailed) { - _enabled.value = resolvedEnabled + serverEnabled = resolvedEnabled adminDefaultsRaw = resolvedAdminDefaults // When the current confirmed state has no user value, // the admin baseline is independently authoritative. @@ -268,6 +291,14 @@ class DefaultOverlayPrefsStore( } } } + // This store never writes ui.card_overlays_enabled, so a + // local document edit cannot make this answer stale. + if (!userFetchFailed) { + profileEnabled = userEnabled + } + // An explicit profile choice overrides the server-wide + // default in either direction, as on web. + _enabled.value = profileEnabled ?: serverEnabled if (userResponseIsCurrent) { _lastError.value = userError ?: configError @@ -511,6 +542,8 @@ class DefaultOverlayPrefsStore( pendingSnapshot = null val activeWrite = pendingWrite pendingWrite = null + serverEnabled = true + profileEnabled = null _enabled.value = true _prefs.value = OverlaySchema.buildDefaults() confirmedPrefs = _prefs.value @@ -541,5 +574,6 @@ class DefaultOverlayPrefsStore( companion object { const val OVERLAY_SETTING_KEY = SettingKeys.UI_CARD_OVERLAYS + const val OVERLAYS_ENABLED_SETTING_KEY = SettingKeys.UI_CARD_OVERLAYS_ENABLED } } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/PlayerSettingsStore.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/PlayerSettingsStore.kt index f5ece42585..18e2a1c2f5 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/PlayerSettingsStore.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/PlayerSettingsStore.kt @@ -75,6 +75,13 @@ interface PlayerSettingsStore { val letterboxExpansionFlow: Flow get() = flowOf(LetterboxExpansion.Default) + /** + * See [org.prairieserver.prairie.model.settings.PlaybackSettingsKeys.TrueBlackBars]. + * Defaulted for the same reason as [letterboxExpansionFlow]. + */ + val trueBlackBarsFlow: Flow + get() = flowOf(false) + /** Per-profile preference for restricting downloads to unmetered (Wi-Fi) * networks. Default true. Consumed by [DownloadEnqueuer] at enqueue * time to set the WorkManager NetworkType constraint. */ @@ -126,9 +133,26 @@ interface PlayerSettingsStore { val subtitleMatchesDeviceFlow: Flow /** iOS AppNavPreferences.showAudiobooks parity — audiobook surfaces are opt-in. */ val showAudiobooksFlow: Flow + + /** + * The server-stored keys this device holds its own value for, in the + * active profile. A control whose key is absent follows the profile (or + * the default); see [PlaybackSettingsKeys.hasDeviceOverride][org.prairieserver.prairie.model.settings.PlaybackSettingsKeys.hasDeviceOverride]. + */ + val deviceOverrideKeysFlow: Flow> + get() = flowOf(emptySet()) /** [subtitleAppearanceFlow] with the match-device override applied. */ val effectiveSubtitleAppearanceFlow: Flow + /** + * False only when the active server is known to run a settings manifest + * older than [SubtitleAppearance.TEXT_OPACITY_MIN_MANIFEST_REVISION], so it + * would discard [SubtitleAppearance.textOpacity]. An unknown revision + * counts as supported: the flusher holds the value until it is known. + */ + val subtitleTextOpacitySupportedFlow: Flow + get() = flowOf(true) + // Setters suspend fun setIntroSkipMode(value: IntroSkipMode) @@ -148,6 +172,7 @@ interface PlayerSettingsStore { suspend fun setPictureInPictureEnabled(value: Boolean) suspend fun setForceHdrPassthrough(value: Boolean) suspend fun setLetterboxExpansion(value: String) = Unit + suspend fun setTrueBlackBars(value: Boolean) = Unit suspend fun setDownloadsWifiOnly(value: Boolean) suspend fun setKeepWatchedDownloads(value: Boolean) suspend fun setDefaultDownloadQuality(value: String) @@ -179,6 +204,23 @@ interface PlayerSettingsStore { suspend fun setSubtitleAppearance(value: SubtitleAppearance) + /** + * Applies [transform] atomically against the current stored appearance, + * read inside the same DataStore transaction that writes the result. + * Unlike a caller reading [subtitleAppearanceFlow] and then calling + * [setSubtitleAppearance] separately, no write from another caller can + * land in the gap between the read and the write — two edits committing + * around the same time (e.g. two fields as a sheet dismisses) each see + * the other's result instead of racing on a shared pre-transaction read. + * + * The default falls back to the non-atomic read-then-write for fakes + * that only need to capture the resulting value; [AndroidPlayerSettingsStore] + * overrides this with the real atomic transaction. + */ + suspend fun updateSubtitleAppearance(transform: (SubtitleAppearance) -> SubtitleAppearance) { + setSubtitleAppearance(transform(subtitleAppearanceFlow.first())) + } + /** * Project the granular, client-local `subtitle.*` fields into the * composite `playback.subtitle_appearance` and enqueue it. @@ -213,20 +255,23 @@ interface PlayerSettingsStore { suspend fun setShowAudiobooks(enabled: Boolean) /** - * Clear the server-side device override for one key. Local DataStore - * is repopulated from the cascade (user → global → default) on the - * next refresh. + * Go back to the profile's value for one setting on this device: clears + * the device-scoped value for [key] (and any key its control writes with + * it), then refreshes so the profile's value, or the default, applies. */ suspend fun resetDeviceSetting(key: String) /** - * Return this device's playback settings to their defaults — the user's - * "Reset playback settings" action. Clears every server-side device - * override (as iOS `PlayerSettings.resetAllDeviceSettings()` does) and the - * local-only playback keys that have no server row to clear, since those - * would otherwise survive an action whose whole promise is the defaults. + * The user's "Use Profile Settings" action. Clears every server-side + * device override (as iOS `PlayerSettings.resetAllDeviceSettings()` does) + * and resets the local-only playback keys, which have no profile value, to + * their defaults. + * + * Returns true when every clear reached the server and the server's + * answer no longer resolves any setting from this device; false when some + * are still queued (offline) or the server could not confirm it. */ - suspend fun resetAllDeviceSettings() + suspend fun resetAllDeviceSettings(): Boolean /** * Cancel any in-flight debounce, drain pending writes, and suspend diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/SeekIntervalStore.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/SeekIntervalStore.kt index 34bd25ff55..be0023d889 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/SeekIntervalStore.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/SeekIntervalStore.kt @@ -394,11 +394,7 @@ private class SharedPreferencesSeekIntervalCache( } } - private fun prefix(identity: String): String = - "si_" + java.security.MessageDigest.getInstance("SHA-256") - .digest(identity.toByteArray(Charsets.UTF_8)) - .joinToString(separator = "") { "%02x".format(it) } - .take(24) + private fun prefix(identity: String): String = settingsCachePrefix("si_", identity) private companion object { const val SUPPORTED = "supported" diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/ServerDrivenConfigRefresher.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/ServerDrivenConfigRefresher.kt index 26b05efe39..082ffa4b2d 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/ServerDrivenConfigRefresher.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/ServerDrivenConfigRefresher.kt @@ -1,6 +1,7 @@ package org.prairieserver.prairie.common.settings import android.os.SystemClock +import org.prairieserver.prairie.repository.HiddenLibrariesStore /** * Best-effort refresh coordinator for server-owned client configuration. @@ -17,6 +18,9 @@ class ServerDrivenConfigRefresher( private val playerSettingsStore: PlayerSettingsStore, private val hasAuthenticatedProfile: suspend () -> Boolean, private val seekIntervalStore: SeekIntervalStore? = null, + private val episodeSpoilerStore: EpisodeSpoilerStore? = null, + private val titleArtStore: TitleArtStore? = null, + private val hiddenLibrariesStore: HiddenLibrariesStore? = null, private val nowMs: () -> Long = { SystemClock.elapsedRealtime() }, ) { @Volatile @@ -40,6 +44,9 @@ class ServerDrivenConfigRefresher( libraryPlaybackPrefsStore.refresh() playerSettingsStore.refreshFromServer() seekIntervalStore?.refresh() + episodeSpoilerStore?.refresh() + titleArtStore?.refresh() + hiddenLibrariesStore?.refresh() lastRefreshAtMs = now return true } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/ServerSettingsFlusher.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/ServerSettingsFlusher.kt index 40ec8118ee..cbadc8f2b1 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/ServerSettingsFlusher.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/ServerSettingsFlusher.kt @@ -4,6 +4,7 @@ import org.prairieserver.prairie.common.diagnostics.PrairieLog import org.prairieserver.prairie.model.diagnostics.DiagnosticsLogCategory import org.prairieserver.prairie.model.settings.SettingKeys import org.prairieserver.prairie.model.settings.SettingScopeIdentity +import org.prairieserver.prairie.model.settings.SubtitleAppearance import org.prairieserver.prairie.network.AuthScopeSnapshot import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.api.SettingsApi @@ -95,6 +96,13 @@ class DefaultServerSettingsFlusher( */ private val getServerUrl: suspend () -> String? = { null }, private val getAuthScope: (suspend () -> AuthScopeSnapshot?)? = null, + /** + * The connected server's settings manifest revision, for writes whose + * accepted shape depends on it. Share the app's instance so the settings + * UI and the flusher agree on what the server supports. + */ + private val contractRevision: SettingsContractRevision = + SettingsContractRevision({ settingsApi.getContractCapabilities() }, getServerUrl), ) : ServerSettingsFlusher { private val lock = Any() @@ -275,11 +283,13 @@ class DefaultServerSettingsFlusher( PrairieLog.w(CATEGORY, TAG, "dropping $key: ${op.value} does not encode as the contract type") return false } + val wire = gateForServerRevision(key, encoded, op.serverUrl) + ?: return failed("put", key, "settings manifest revision unknown", retry = true) return when ( val result = settingsApi.putValue( key = key, scope = SettingScopeIdentity.profileDevice(), - value = encoded, + value = wire, profileId = profileId, authority = op.authority, ) @@ -294,6 +304,26 @@ class DefaultServerSettingsFlusher( } } + /** + * [encoded] in the shape the server at [serverUrl] accepts, or null when + * that depends on a manifest revision not known yet. + * + * Decided here, at send time, rather than at enqueue: a value queued + * before the revision was known has to reach a revision-14 server whole, + * because a PUT replaces the stored object and a stripped one would reset + * the textOpacity stored there. While the revision is unknown the write is + * held — kept queued and retried like a transient failure — because both + * alternatives lose data: sent whole, a server below 14 rejects it as a + * contract error, the op is dropped and the next refresh reverts every + * field in it; sent stripped, it can reset a revision-14 server's value. + */ + private suspend fun gateForServerRevision(key: String, encoded: JsonElement, serverUrl: String): JsonElement? { + if (key != SettingKeys.PLAYBACK_SUBTITLE_APPEARANCE) return encoded + if (encoded !is JsonObject || SubtitleAppearance.TEXT_OPACITY_FIELD !in encoded) return encoded + val revision = contractRevision.revisionFor(serverUrl) ?: return null + return SubtitleAppearance.wireObjectForRevision(encoded, revision) + } + private suspend fun flushDelete(profileId: String, key: String, op: PendingOp.Delete): Boolean { return when ( val result = settingsApi.deleteValue( diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/SettingsCacheKeys.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/SettingsCacheKeys.kt new file mode 100644 index 0000000000..d639f53ddb --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/SettingsCacheKeys.kt @@ -0,0 +1,14 @@ +package org.prairieserver.prairie.common.settings + +/** + * SharedPreferences key prefix for one identity's cached settings answer: + * [tag] plus the first 24 hex digits of the identity's SHA-256, so server URLs + * and profile ids never appear in key names. + */ +internal fun settingsCachePrefix(tag: String, identity: String): String = + tag + sha256Hex(identity).take(24) + +private fun sha256Hex(s: String): String = + java.security.MessageDigest.getInstance("SHA-256") + .digest(s.toByteArray(Charsets.UTF_8)) + .joinToString(separator = "") { "%02x".format(it) } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/SettingsContractRevision.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/SettingsContractRevision.kt new file mode 100644 index 0000000000..d9b1ae3a34 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/SettingsContractRevision.kt @@ -0,0 +1,75 @@ +package org.prairieserver.prairie.common.settings + +import org.prairieserver.prairie.model.settings.SettingsContractCapabilities +import org.prairieserver.prairie.network.ApiResult +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * The settings manifest revision of the connected server, read from the + * contract capabilities endpoint and cached per server URL. + * + * The settings flusher asks for it before sending a write whose shape depends + * on the revision, and the settings UI hides controls the server would + * discard. Both share one instance so they agree on what the server supports. + */ +class SettingsContractRevision( + private val fetchCapabilities: suspend () -> ApiResult, + /** The server requests currently address; null when it cannot be read. */ + private val getServerUrl: suspend () -> String? = { null }, +) { + data class Known(val serverUrl: String, val manifestRevision: Int) + + private val mutex = Mutex() + private val _known = MutableStateFlow(null) + + /** The last revision a probe answered, with the server that answered it. */ + val known: StateFlow = _known.asStateFlow() + + /** + * The cached revision for [serverUrl], probing when none is cached. + * Null means unknown: the probe failed, or [serverUrl] is not the server + * requests would reach, so any answer would describe a different server. + */ + suspend fun revisionFor(serverUrl: String): Int? { + cachedFor(serverUrl)?.let { return it } + return mutex.withLock { + cachedFor(serverUrl) ?: probe(serverUrl) + } + } + + /** + * Re-read the revision for [serverUrl] even when one is cached, so a + * server upgraded while the app runs is noticed. A failed probe keeps the + * cached value. + */ + suspend fun refresh(serverUrl: String): Int? = mutex.withLock { + probe(serverUrl) ?: cachedFor(serverUrl) + } + + private fun cachedFor(serverUrl: String): Int? = + _known.value?.takeIf { it.serverUrl == serverUrl }?.manifestRevision + + private suspend fun probe(serverUrl: String): Int? { + if (!isActive(serverUrl)) return null + val revision = when (val result = fetchCapabilities()) { + is ApiResult.Success -> result.data.manifestRevision + // No capabilities route: the server predates every revision gate. + is ApiResult.Error -> if (result.code == 404) 0 else null + is ApiResult.NetworkError -> null + } ?: return null + // The request goes to whichever server is active when it is sent, so a + // switch during the fetch means the answer describes another server. + if (!isActive(serverUrl)) return null + _known.value = Known(serverUrl, revision) + return revision + } + + private suspend fun isActive(serverUrl: String): Boolean { + val active = runCatching { getServerUrl() }.getOrNull() + return active == null || active == serverUrl + } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/TitleArtLocals.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/TitleArtLocals.kt new file mode 100644 index 0000000000..420f285ad4 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/TitleArtLocals.kt @@ -0,0 +1,54 @@ +package org.prairieserver.prairie.common.settings + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import org.prairieserver.prairie.domain.settings.TitleArtController + +/** + * Whether title surfaces may name a title with its logo artwork + * (`ui.title_art`). When false they always show the text title. + * + * Published once near each app shell by [ProvideTitleArt]; anything rendered + * outside it keeps the contract default (logos on). Not static: the value + * flips once on a cold start (default, then the cached or server answer), and + * only the title surfaces that read it should recompose. + */ +val LocalShowTitleArt: ProvidableCompositionLocal = + compositionLocalOf { TitleArtController.DEFAULT_SHOW_TITLE_ART } + +/** + * The logo a title surface may draw for [logoUrl]: null when "Show title art" + * is off or there is no logo, so the caller falls back to the text title. + * Every surface that draws a logo goes through here; prefetch reads + * [LocalShowTitleArt] directly. + */ +@Composable +@ReadOnlyComposable +fun titleLogoUrl(logoUrl: String?): String? { + val show = LocalShowTitleArt.current + return logoUrl?.takeIf { show && it.isNotBlank() } +} + +/** + * Hydrates [store] for [sessionKey] (the active profile id; null before sign-in) + * and publishes its value via [LocalShowTitleArt] for [content]. Same shape as + * `ProvideCardPresentation`. + */ +@Composable +fun ProvideTitleArt( + store: TitleArtStore, + sessionKey: Any? = null, + content: @Composable () -> Unit, +) { + LaunchedEffect(store, sessionKey) { + if (sessionKey != null) store.hydrateIfNeeded() + } + val state by store.state.collectAsState() + CompositionLocalProvider(LocalShowTitleArt provides state.showTitleArt, content = content) +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/TitleArtStore.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/TitleArtStore.kt new file mode 100644 index 0000000000..06aef11214 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/TitleArtStore.kt @@ -0,0 +1,561 @@ +package org.prairieserver.prairie.common.settings + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import org.prairieserver.prairie.domain.settings.TitleArtController +import org.prairieserver.prairie.domain.settings.TitleArtPreference +import org.prairieserver.prairie.network.AndroidServerRegistry +import org.prairieserver.prairie.network.AuthScopeSnapshot +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** Whether the connected server knows `ui.title_art` (settings revision 16). */ +enum class TitleArtSupport { + /** Not answered yet (cold start before the probe, nothing cached). */ + Unknown, + Supported, + + /** Definitive: the server predates the key. Nothing is written. */ + Unsupported, + + /** The probe failed and nothing earlier is known. */ + Unavailable, +} + +data class TitleArtState( + val support: TitleArtSupport = TitleArtSupport.Unknown, + val preference: TitleArtPreference = TitleArtPreference(), + /** + * The server answered for this profile since the last identity change. A + * cached answer alone may be stale, and the main switch's scope comes from + * [appliesToAllDevices], so the switches wait for this. + */ + val isConfirmed: Boolean = false, +) { + /** + * What title surfaces render. Anything short of a supporting server keeps + * the logo behaviour every client had before the setting existed. + */ + val showTitleArt: Boolean + get() = if (support == TitleArtSupport.Supported) { + preference.showTitleArt + } else { + TitleArtController.DEFAULT_SHOW_TITLE_ART + } + + val appliesToAllDevices: Boolean get() = preference.appliesToAllDevices + + /** Only a server that answered for the key gets the settings controls. */ + val isSupported: Boolean get() = support == TitleArtSupport.Supported + + /** The switches accept changes only once the server has confirmed [preference]. */ + val canEdit: Boolean get() = isSupported && isConfirmed +} + +/** + * The signed-in profile's "Show title art" choice for this device — the one + * source every title surface (phone and TV detail heroes, the TV marquee, logo + * prefetch) and both settings screens read, so a change made in settings + * reaches an open detail page without a restart. + * + * Refresh edges: profile or server change ([identityChanges]), the shell's + * session key, and foreground/reconnect via [ServerDrivenConfigRefresher]. + */ +interface TitleArtStore { + val state: StateFlow + + /** + * What the settings screens show after a failed save, e.g. "Couldn't save + * title art: Server error". Cleared by the next successful save, a fresh + * read when Settings opens, and an identity change. + */ + val saveError: StateFlow + + /** Idempotent first load; seeds from the last-known answer first. */ + suspend fun hydrateIfNeeded() + + /** + * Re-probe capabilities and re-resolve the effective value. Joins a load + * already in flight for this identity instead of starting another. + */ + suspend fun refresh() + + /** + * The main switch: applied locally at once, then written at `profile` + * while the value applies to all devices, else at `profile_device`. + * Restores the last confirmed state if the write fails. Ignored until + * [TitleArtState.canEdit]. + */ + fun setShowTitleArt(show: Boolean) + + /** + * "Apply to all devices": on writes the current value at `profile`; off + * pins it at `profile_device` and then clears the `profile` value. + */ + fun setAppliesToAllDevices(enabled: Boolean) + + /** Session boundary (sign-out, profile switch): drop state. */ + fun clear() +} + +class DefaultTitleArtStore private constructor( + private val controller: TitleArtController, + private val scope: CoroutineScope, + private val getAuthScope: suspend () -> AuthScopeSnapshot?, + private val cache: TitleArtCache, + identityChanges: Flow, +) : TitleArtStore { + + constructor( + context: Context, + controller: TitleArtController, + scope: CoroutineScope, + getAuthScope: suspend () -> AuthScopeSnapshot?, + identityChanges: Flow = emptyFlow(), + ) : this( + controller = controller, + scope = scope, + getAuthScope = getAuthScope, + cache = SharedPreferencesTitleArtCache( + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE), + ), + identityChanges = identityChanges, + ) + + private val _state = MutableStateFlow(TitleArtState()) + private val _saveError = MutableStateFlow(null) + override val state: StateFlow = _state.asStateFlow() + override val saveError: StateFlow = _saveError.asStateFlow() + + private val lock = Any() + + /** Bumped at every identity boundary; stale work compares and bails. */ + @Volatile + private var generation = 0 + + /** Bumped by every local mutation, so a refresh that started earlier + * cannot paint its older answer over a newer choice. */ + private var mutationEpoch = 0L + + @Volatile + private var hasHydrated = false + + /** The last state the server confirmed; a failed write restores it. */ + private var confirmed = TitleArtState() + + /** The profile the committed state was read for; writes must match it. */ + private var confirmedAuthority: AuthScopeSnapshot? = null + + /** The cache key the committed state belongs to. */ + private var confirmedIdentity: String? = null + + /** + * Every request numbered at or below this was queued behind a write that + * failed. Each one was built on the failed write's unsaved state, so none + * is sent; the store returns to the confirmed state and re-reads. + */ + private var droppedThrough = 0L + + /** Only the latest local request may decide what is shown when it settles. */ + private var requestCounter = 0L + private var pendingWrites = 0 + + /** + * Refreshes run without a lock held across the network, so a stalled + * request for one server never delays another server's answer. Each takes + * a sequence number and only a newer answer than the last committed one + * may land. + */ + private var refreshSequence = 0L + private var committedRefresh = 0L + + /** A refresh answered while a write was queued; re-read once they drain. */ + private var refreshAfterWrites = false + + /** + * The load in flight for the current identity. Startup asks from several + * places at once (the identity collector, the shell, the foreground + * refresher); they share one capabilities and effective-values round trip. + */ + private var inFlightLoad: Deferred? = null + + /** + * Keeps one identity's writes in the order the user made them. Replaced at + * every identity boundary, so a write still on the wire for the previous + * server or profile never holds back the next one's saves. + */ + private var writeLock = Mutex() + + init { + scope.launch { + // collectLatest: a new identity invalidates the old state at once + // and cancels the previous identity's hydration instead of queueing + // behind it. + identityChanges.collectLatest { + synchronized(lock) { resetLocked() } + hydrateIfNeeded() + } + } + } + + override suspend fun hydrateIfNeeded() { + if (hasHydrated) return + load() + } + + override suspend fun refresh() = load() + + private suspend fun load() { + // Lazy, so the load starts outside the lock (Main.immediate would + // otherwise run it inline while the lock is held). + val load = synchronized(lock) { + inFlightLoad?.takeUnless { it.isCompleted } + ?: scope.async(start = CoroutineStart.LAZY) { refresh(keepError = false) } + .also { inFlightLoad = it } + } + load.await() + } + + /** [keepError]: a re-read after writes keeps their outcome (a failure stays reported). */ + private suspend fun refresh(keepError: Boolean) { + // Capture the generation before the suspending authority read: a + // reset while it runs must not pair the previous profile's authority + // with the new generation. The cache key comes from the same snapshot + // the load is pinned to, so the answer is always cached for the + // profile it was read for. + val startGeneration = synchronized(lock) { generation } + val authority = getAuthScope() ?: return + val identity = authority.cacheIdentity() ?: return + val startEpoch: Long + val sequence: Long + synchronized(lock) { + if (generation != startGeneration) return + startEpoch = mutationEpoch + sequence = ++refreshSequence + } + seedFromCache(identity, startGeneration) + val resolved = when (val result = controller.load(authority)) { + is TitleArtController.LoadResult.Supported -> + TitleArtState(TitleArtSupport.Supported, result.preference, isConfirmed = true) + TitleArtController.LoadResult.Unsupported -> + TitleArtState(TitleArtSupport.Unsupported, isConfirmed = true) + is TitleArtController.LoadResult.Failed -> { + synchronized(lock) { + if (generation != startGeneration) return@synchronized + if (_state.value.support == TitleArtSupport.Unknown) { + _state.value = TitleArtState(TitleArtSupport.Unavailable) + } + } + return + } + } + val committed = synchronized(lock) { + if (generation != startGeneration || sequence <= committedRefresh) return@synchronized false + // A queued or in-flight write makes this answer stale by the time + // it lands: it was read before the write, and committing it would + // repaint the old value and route the next toggle to the old + // scope. Drop it and re-read once the writes drain. + if (pendingWrites > 0) { + refreshAfterWrites = true + return@synchronized false + } + if (mutationEpoch != startEpoch) return@synchronized false + committedRefresh = sequence + confirmedAuthority = authority + confirmedIdentity = identity + _state.value = resolved + confirmed = resolved + if (!keepError) _saveError.value = null + hasHydrated = true + true + } + if (committed) cache.write(identity, resolved) + } + + /** Apply the last-known answer while nothing better is on screen. */ + private fun seedFromCache(identity: String, startGeneration: Int) { + if (hasHydrated) return + val cached = cache.read(identity) ?: return + synchronized(lock) { + val current = _state.value.support + val waiting = current == TitleArtSupport.Unknown || current == TitleArtSupport.Unavailable + if (!hasHydrated && generation == startGeneration && waiting) { + _state.value = cached + confirmed = cached + confirmedIdentity = identity + } + } + } + + override fun setShowTitleArt(show: Boolean) { + mutate( + next = { it.copy(showTitleArt = show) }, + write = { pref, authority -> controller.setShowTitleArt(show, pref.appliesToAllDevices, authority) }, + ) + } + + override fun setAppliesToAllDevices(enabled: Boolean) { + mutate( + next = { it.copy(appliesToAllDevices = enabled) }, + write = { pref, authority -> controller.setAppliesToAllDevices(enabled, pref.showTitleArt, authority) }, + ) + } + + /** + * Paints [next] of the preference on screen, then sends [write] with the + * preference as it was *before* this change (which scope the main switch + * addresses, and which value the companion switch carries, both come from + * what the user was looking at when they chose). + */ + private fun mutate( + next: (TitleArtPreference) -> TitleArtPreference, + write: suspend (TitleArtPreference, AuthScopeSnapshot) -> TitleArtController.WriteResult, + ) { + val startGeneration: Int + val request: Long + val before: TitleArtPreference + val optimistic: TitleArtPreference + val writes: Mutex + synchronized(lock) { + val current = _state.value + // Never write to a server that has not confirmed the key, nor + // pick a scope from a cached answer the server has not re-read. + if (!current.canEdit) return + before = current.preference + optimistic = next(before) + if (optimistic == before) return + startGeneration = generation + writes = writeLock + request = ++requestCounter + mutationEpoch += 1 + pendingWrites += 1 + _state.value = current.copy(preference = optimistic) + } + // The store owns the write, so leaving the screen cannot strand an + // unsaved value on screen. + scope.launch { + // One profile for the whole change: every request it makes (up to + // a PUT, a DELETE and a re-read) is pinned to this snapshot, so a + // profile switch part-way through fails the rest instead of + // landing them on the new profile. + val authority = getAuthScope() + val result = writes.withLock { + val readFor = synchronized(lock) { + if (generation != startGeneration || request <= droppedThrough) return@withLock null + confirmedAuthority + } + if (authority == null || readFor == null || !authority.isSameProfileAs(readFor)) { + TitleArtController.WriteResult.Failed(IDENTITY_CHANGED) + } else { + write(before, authority) + } + } + var refreshNow = false + val persisted = synchronized(lock) { + if (generation != startGeneration) return@synchronized false + pendingWrites = (pendingWrites - 1).coerceAtLeast(0) + if (pendingWrites == 0 && refreshAfterWrites) { + refreshAfterWrites = false + refreshNow = true + } + when (result) { + // Dropped: queued behind a failed write. + null -> false + is TitleArtController.WriteResult.Saved -> { + confirmed = TitleArtState( + TitleArtSupport.Supported, + result.resolved ?: optimistic, + isConfirmed = true, + ) + _saveError.value = null + if (requestCounter == request) { + mutationEpoch += 1 + _state.value = confirmed + } + // Cache only what the server confirmed by re-reading. + result.resolved != null + } + is TitleArtController.WriteResult.Failed -> { + _saveError.value = saveErrorText(result.message) + // Every change queued behind this one was built on its + // unsaved state (which scope to write, which value to + // carry). Drop them all, show the confirmed state, and + // re-read the server once nothing is in flight. + droppedThrough = requestCounter + mutationEpoch += 1 + _state.value = confirmed + if (pendingWrites == 0) { + refreshNow = true + } else { + refreshAfterWrites = true + } + false + } + } + } + if (persisted) persistConfirmed(startGeneration) + if (refreshNow) refresh(keepError = true) + } + } + + /** Caches under the identity the confirmed state was read for, never a re-read one. */ + private fun persistConfirmed(startGeneration: Int) { + val (identity, snapshot) = synchronized(lock) { + if (generation != startGeneration) return + (confirmedIdentity ?: return) to confirmed + } + cache.write(identity, snapshot) + } + + override fun clear() { + synchronized(lock) { resetLocked() } + } + + private fun resetLocked() { + generation += 1 + mutationEpoch += 1 + hasHydrated = false + _state.value = TitleArtState() + confirmed = TitleArtState() + confirmedAuthority = null + confirmedIdentity = null + pendingWrites = 0 + refreshAfterWrites = false + // A load still running for the previous identity bails on the + // generation check; the next caller starts a fresh one. + inFlightLoad = null + // The previous identity's write may still be on the wire; it keeps its + // own lock and its completion bails on the generation check. + writeLock = Mutex() + _saveError.value = null + } + + internal companion object { + const val PREFS_NAME = "silo_title_art" + const val IDENTITY_CHANGED = "The acting account or profile changed." + + /** Same wording as the skip-interval save error. */ + fun saveErrorText(message: String?): String = + "Couldn't save title art" + (message?.takeIf { it.isNotBlank() }?.let { ": $it" } ?: ".") + + /** The cache key for this snapshot's server and profile; null without a profile. */ + private fun AuthScopeSnapshot.cacheIdentity(): String? = + profileId?.takeIf { it.isNotBlank() }?.let { "$serverUrl|$it" } + + /** + * Same server and profile, and the same sign-in when both are the + * saved account. Not [AuthScopeSnapshot.isSameIdentityAs]: its + * identity generation also moves when a remote-playback overlay starts + * or ends, which leaves the profile a toggle addresses unchanged. An + * overlay's snapshot carries no credential epoch (it is always 0), so + * the epoch is compared only between two saved-account snapshots, and + * the server ids are matched the way the overlay handoff matches them. + * A real profile or server switch resets the store before the write + * runs. + */ + private fun AuthScopeSnapshot.isSameProfileAs(other: AuthScopeSnapshot): Boolean { + if (profileId != other.profileId) return false + if (!AndroidServerRegistry.serverIdsMatch(serverId, other.serverId)) return false + val bothSavedAccount = credentialGenerationId == null && other.credentialGenerationId == null + return !bothSavedAccount || credentialEpoch == other.credentialEpoch + } + + /** Test seam: no Android Context, in-memory cache. */ + internal fun forTest( + controller: TitleArtController, + scope: CoroutineScope, + getActiveProfileId: suspend () -> String? = { "profile-1" }, + getServerUrl: suspend () -> String? = { "https://server.test" }, + getAuthScope: (suspend () -> AuthScopeSnapshot?)? = null, + cache: TitleArtCache = InMemoryTitleArtCache(), + identityChanges: Flow = emptyFlow(), + ): DefaultTitleArtStore = DefaultTitleArtStore( + controller = controller, + scope = scope, + getAuthScope = getAuthScope ?: { + val url = getServerUrl().orEmpty() + AuthScopeSnapshot( + serverId = url, + profileId = getActiveProfileId(), + serverUrl = url, + profileToken = "token-${getActiveProfileId()}", + ) + }, + cache = cache, + identityChanges = identityChanges, + ) + } +} + +/** + * Last-known answer per server and profile, so a cold start paints the + * profile's choice (the TV marquee is on screen immediately) instead of + * flashing logos until the network answers. + */ +internal interface TitleArtCache { + fun read(identity: String): TitleArtState? + fun write(identity: String, state: TitleArtState) +} + +internal class InMemoryTitleArtCache : TitleArtCache { + private val entries = mutableMapOf() + override fun read(identity: String): TitleArtState? = synchronized(entries) { entries[identity] } + override fun write(identity: String, state: TitleArtState) { + if (state.support != TitleArtSupport.Supported && state.support != TitleArtSupport.Unsupported) return + synchronized(entries) { entries[identity] = state.copy(isConfirmed = false) } + } +} + +private class SharedPreferencesTitleArtCache( + private val prefs: SharedPreferences, +) : TitleArtCache { + + override fun read(identity: String): TitleArtState? { + val prefix = prefix(identity) + val support = when (prefs.getString("$prefix.support", null)) { + SUPPORTED -> TitleArtSupport.Supported + UNSUPPORTED -> TitleArtSupport.Unsupported + else -> return null + } + return TitleArtState( + support = support, + preference = TitleArtPreference( + showTitleArt = prefs.getBoolean("$prefix.show", TitleArtController.DEFAULT_SHOW_TITLE_ART), + appliesToAllDevices = prefs.getBoolean("$prefix.all_devices", false), + ), + ) + } + + override fun write(identity: String, state: TitleArtState) { + val support = when (state.support) { + TitleArtSupport.Supported -> SUPPORTED + TitleArtSupport.Unsupported -> UNSUPPORTED + TitleArtSupport.Unknown, TitleArtSupport.Unavailable -> return + } + val prefix = prefix(identity) + prefs.edit { + putString("$prefix.support", support) + putBoolean("$prefix.show", state.preference.showTitleArt) + putBoolean("$prefix.all_devices", state.preference.appliesToAllDevices) + } + } + + private fun prefix(identity: String): String = settingsCachePrefix("ta_", identity) + + private companion object { + const val SUPPORTED = "supported" + const val UNSUPPORTED = "unsupported" + } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/UseProfileSettingsCopy.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/UseProfileSettingsCopy.kt new file mode 100644 index 0000000000..ef84204bb0 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/UseProfileSettingsCopy.kt @@ -0,0 +1,51 @@ +package org.prairieserver.prairie.common.settings + +/** + * The words for going back to the profile's playback settings, shared by the + * phone and TV settings so the two say the same thing. + */ +object UseProfileSettingsCopy { + /** + * The first option of every picker for a setting the profile also holds, + * and the action under such a switch. Choosing it clears this device's + * value. + */ + const val OPTION = "Use profile setting" + + /** The row that does it for one switch. */ + const val ROW = "Use Profile Setting" + + /** The row that does it for every playback setting at once. */ + const val RESET_ALL_ROW = "Use Profile Settings" + + const val CONFIRM_TITLE = "Use your profile's settings on this device?" + + // Truthful about the local-only settings (picture-in-picture, Force HDR + // Passthrough, rewind on resume…): they have no profile value, so the + // action returns them to their defaults. + const val CONFIRM_MESSAGE = + "This removes the settings changed on this device. Settings that only apply to this " + + "device go back to their defaults." + + const val CONFIRM_BUTTON = "Use Profile Settings" + + const val FOOTER = + "Goes back to your profile's playback settings on this device. Settings that only apply " + + "to this device go back to their defaults." + + /** Accessibility label for the row under [setting]'s switch. */ + fun rowDescription(setting: String): String = "Use profile setting for $setting" + + /** + * The notice after the reset. [landed] is false whenever the server's + * answer didn't confirm it: offline, a refused delete, or a profile switch + * mid-reset. The queue is in memory and drops what it can't retry, so the + * text promises nothing about later; running the reset again is safe. + */ + fun notice(landed: Boolean): String = + if (landed) { + "Now using your profile's settings." + } else { + "Couldn't confirm the change with the server. Try again later." + } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/startup/StartupWarmup.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/startup/StartupWarmup.kt index 223ac3df30..e80540ee6f 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/startup/StartupWarmup.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/startup/StartupWarmup.kt @@ -46,6 +46,8 @@ data class StartupArtworkPlan( * immediately. */ val warmFirstRowHeroArt: Boolean = false, + /** With [warmFirstRowHeroArt]: also warm the first item's logo. */ + val warmFirstRowLogo: Boolean = warmFirstRowHeroArt, ) { companion object { /** @@ -91,6 +93,12 @@ suspend fun warmAuthenticatedStartup( identityTransitions: IdentityTransitionBarrier, serverUrl: String?, artworkPlan: StartupArtworkPlan, + /** + * Whether Home will draw title logos (`ui.title_art`). Asked only when the + * artwork is warmed, after Home has loaded; false skips the logo fetch. + * Must not suspend on the network: it gates every first-row warm. + */ + showTitleArt: () -> Boolean = { true }, ) { val homeGeneration = identityTransitions.generation.value val homeOwner = sectionRepository.captureHomeAuthority() @@ -118,7 +126,14 @@ suspend fun warmAuthenticatedStartup( if (homeOwner != null) warmStartupHomeSections( sectionRepository, homeCache, homeOwner, stillCurrent = { homeGeneration == identityTransitions.generation.value }, - ) { sections, mayWarm -> warmHomeArtwork(context, sections, artworkPlan, mayWarm) } + ) { sections, mayWarm -> + val plan = if (artworkPlan.warmFirstRowHeroArt && !showTitleArt()) { + artworkPlan.copy(warmFirstRowLogo = false) + } else { + artworkPlan + } + warmHomeArtwork(context, sections, plan, mayWarm) + } } Unit }, @@ -202,7 +217,7 @@ private suspend fun warmHomeArtwork( val contentSections = sections.filter { it.items.isNotEmpty() } contentSections.firstOrNull()?.let { firstRow -> val episodeRow = firstRow.rendersEpisodeStills() - if (plan.warmFirstRowHeroArt) { + if (plan.warmFirstRowHeroArt && plan.warmFirstRowLogo) { append(firstRow.items.firstOrNull()?.logoUrl, plan.logoWidthPx, plan.logoHeightPx) } for (item in firstRow.items) { diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/util/LanguageNames.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/LanguageNames.kt similarity index 98% rename from androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/util/LanguageNames.kt rename to android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/LanguageNames.kt index 8822818edc..d2532a6c4a 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/util/LanguageNames.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/LanguageNames.kt @@ -1,4 +1,4 @@ -package org.prairieserver.prairie.android.ui.util +package org.prairieserver.prairie.common.ui /** * ISO 639 language code → English display name, mirroring the web player's diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/RatingEntry.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/RatingEntry.kt new file mode 100644 index 0000000000..c3df1043db --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/RatingEntry.kt @@ -0,0 +1,105 @@ +package org.prairieserver.prairie.common.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.isSpecified +import androidx.compose.ui.unit.sp +import org.prairieserver.prairie.common.R +import org.prairieserver.prairie.model.catalog.DisplayRating +import org.prairieserver.prairie.model.catalog.ExternalRatings + +/** + * One external rating as its source's mark and its score: `IMDb 8.5`, + * `RT 93%`. The mark is the source's name as plain text, never a star or the + * source's artwork, except TMDB, whose approved logo its terms allow. The + * score is [DisplayRating.display] as the server formatted it. Screen readers + * hear "IMDb 8.5". + * + * [style] is the score's text style and should carry a color and font size; + * the mark is drawn from it semibold and dimmed, at [markFontSize] or a + * little smaller than the score, and the TMDB logo is sized to about the + * score's cap height. + * + * Callers lay entries out themselves; [WholeTokenRow] keeps them on one line. + * Do not add a FlowRow-based row here: FlowRow is experimental, and this + * module compiles against an older Compose foundation than the apps ship, so + * the call fails at runtime with NoSuchMethodError. + */ +@Composable +fun RatingEntry( + rating: DisplayRating, + style: TextStyle, + modifier: Modifier = Modifier, + markFontSize: TextUnit = TextUnit.Unspecified, +) { + val fontSize = style.fontSize.takeIf { it.isSpecified } ?: DefaultScoreSize + val scoreStyle = style.merge( + TextStyle( + fontSize = fontSize, + fontWeight = FontWeight.Bold, + fontFeatureSettings = "tnum", + ), + ) + Row( + modifier = modifier.clearAndSetSemantics { + contentDescription = "${rating.name} ${rating.display}" + }, + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (rating.source == ExternalRatings.SOURCE_TMDB) { + val logoHeight = with(LocalDensity.current) { (fontSize * TmdbLogoCapHeight).toDp() } + Image( + painter = painterResource(R.drawable.tmdb_logo), + contentDescription = rating.name, + contentScale = ContentScale.Fit, + modifier = Modifier.size(width = logoHeight * TmdbLogoAspectRatio, height = logoHeight), + ) + } else { + BasicText( + text = rating.name, + style = style.merge( + TextStyle( + fontSize = markFontSize.takeIf { it.isSpecified } ?: (fontSize * MarkScale), + fontWeight = FontWeight.SemiBold, + ), + ), + maxLines = 1, + softWrap = false, + modifier = Modifier.alpha(MarkAlpha), + ) + } + BasicText( + text = rating.display, + style = scoreStyle, + maxLines = 1, + softWrap = false, + ) + } +} + +private val DefaultScoreSize = 14.sp +private const val MarkScale = 0.87f +private const val MarkAlpha = 0.75f + +/** About a cap height of the score text, like the web client's logo. */ +private const val TmdbLogoCapHeight = 0.66f + +/** The logo's own proportions (its viewBox), kept unmodified. */ +private const val TmdbLogoAspectRatio = 273.42f / 35.52f diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/ShuffleLauncher.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/ShuffleLauncher.kt new file mode 100644 index 0000000000..b8c95d08b6 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/ShuffleLauncher.kt @@ -0,0 +1,66 @@ +package org.prairieserver.prairie.common.ui + +import android.content.Context +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.prairieserver.prairie.model.feature.ShuffleFeatureStore +import org.prairieserver.prairie.model.feature.ShuffleStart +import org.prairieserver.prairie.model.shuffle.Shuffle +import org.prairieserver.prairie.model.shuffle.ShuffleCapability +import org.prairieserver.prairie.model.shuffle.ShuffleScopeKind + +/** + * Starts shuffles from an entry point: offers a scope kind only when the + * server's capability allows it, ignores presses while a start is in flight, + * and reports a failure as a toast. + */ +class ShuffleLauncher internal constructor( + private val store: ShuffleFeatureStore, + private val scope: CoroutineScope, + private val context: Context, + private val onStarted: State<(Shuffle) -> Unit>, + private val capability: State, +) { + var isStarting by mutableStateOf(false) + private set + + fun supports(kind: ShuffleScopeKind): Boolean = capability.value.supports(kind) + + fun start(kind: ShuffleScopeKind, id: String) { + if (isStarting || id.isBlank()) return + isStarting = true + scope.launch { + try { + when (val result = store.start(kind, id)) { + is ShuffleStart.Started -> onStarted.value(result.shuffle) + is ShuffleStart.Failed -> Toast.makeText(context, result.message, Toast.LENGTH_SHORT).show() + } + } finally { + isStarting = false + } + } + } +} + +/** A [ShuffleLauncher] that calls [onStarted] with each shuffle it starts. */ +@Composable +fun rememberShuffleLauncher(store: ShuffleFeatureStore, onStarted: (Shuffle) -> Unit): ShuffleLauncher { + val scope = rememberCoroutineScope() + val context = LocalContext.current.applicationContext + val latestOnStarted = rememberUpdatedState(onStarted) + val capability = store.capability.collectAsState() + return remember(store, scope, context) { + ShuffleLauncher(store, scope, context, latestOnStarted, capability) + } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/ViewerAccessEffects.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/ViewerAccessEffects.kt new file mode 100644 index 0000000000..f8f3c52575 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/ViewerAccessEffects.kt @@ -0,0 +1,49 @@ +package org.prairieserver.prairie.common.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import org.prairieserver.prairie.network.AccessChangeCursor +import org.prairieserver.prairie.network.AccessChangeSignals + +/** + * Runs [onChanged] each time the server reports that the viewer's access + * changed (access group, permissions, or playback-quality override) while this + * composable is on screen, or, with a [AccessChangeCursor], since it was last + * on screen. Screens use it to refetch libraries, search results, + * details, and collections the new policy may have added or removed. + * + * Inert against a server that never reports access changes. + */ +@Composable +fun OnViewerAccessChanged( + signals: AccessChangeSignals, + /** + * The retained ViewModel's cursor. With it, a change reported while this + * screen was out of composition (another destination in front, another + * tab showing) runs [onChanged] as soon as the screen returns. + */ + cursor: AccessChangeCursor? = null, + onChanged: () -> Unit, +) { + val latest by rememberUpdatedState(onChanged) + LaunchedEffect(signals, cursor) { + val changes = if (cursor != null) signals.changes(cursor) else signals.changes + changes.collect { latest() } + } +} + +/** + * A key that moves on each reported access change, for `produceState` and + * `LaunchedEffect` loaders that should re-run under the new policy. + */ +@Composable +fun rememberViewerAccessKey(signals: AccessChangeSignals): Int { + var key by remember(signals) { mutableIntStateOf(0) } + OnViewerAccessChanged(signals) { key++ } + return key +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/WholeTokenRow.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/WholeTokenRow.kt new file mode 100644 index 0000000000..02d53fb1d9 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/WholeTokenRow.kt @@ -0,0 +1,123 @@ +package org.prairieserver.prairie.common.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.ParentDataModifier +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp + +/** + * A single-line row that shows as many whole children as fit, in order, and + * drops the rest. A child is never moved to a second line, and is never cut + * partway unless it opts in with [WholeTokenRowScope.shrinkable]. The row is + * as wide as the children it shows, so a centering parent centers them. + * Children are vertically centered. + * + * Children marked [WholeTokenRowScope.separator] (a "·") are not tokens: the + * row shows one only between two shown tokens, so dropping a token never + * leaves a separator at either end or two in a row. + */ +@Composable +fun WholeTokenRow( + spacing: Dp, + modifier: Modifier = Modifier, + content: @Composable WholeTokenRowScope.() -> Unit, +) { + Layout(content = { WholeTokenRowScope.content() }, modifier = modifier) { measurables, constraints -> + val gap = spacing.roundToPx() + val flags = measurables.map { it.parentData as? TokenFlags ?: TokenFlags() } + val widths = measurables.map { it.maxIntrinsicWidth(Constraints.Infinity) } + + // The separator written right after token [index], if any. A shown + // token keeps it only when another token is shown after it. + fun separatorAfter(index: Int): Int? = (index + 1).takeIf { it < measurables.size && flags[it].separator } + + // Width a token adds after [previous]: the gap, plus previous's separator and its gap. + fun joinWidth(previous: Int?): Int = if (previous == null) { + 0 + } else { + gap + (separatorAfter(previous)?.let { widths[it] + gap } ?: 0) + } + + fun widthOf(tokens: List): Int = + tokens.withIndex().sumOf { (k, index) -> joinWidth(tokens.getOrNull(k - 1)) + widths[index] } + + // Too wide: drop the tokens marked dropFirst before any other, the + // highest rank first and, within a rank, the last first. + val kept = measurables.indices.filterNot { flags[it].separator }.toMutableList() + while (widthOf(kept) > constraints.maxWidth) { + val rank = kept.maxOfOrNull { flags[it].dropRank }?.takeIf { it > 0 } ?: break + kept.remove(kept.last { flags[it].dropRank == rank }) + } + + // Then keep the leading tokens that fit. A shrinkable token takes the + // width left and handles its own overflow; any other stops the row. + val shown = mutableListOf>() + var width = 0 + var previous: Int? = null + for (index in kept) { + val start = width + joinWidth(previous) + val left = constraints.maxWidth - start + val fits = widths[index] <= left + if (!fits && !(flags[index].shrinkable && left > 0)) break + previous?.let(::separatorAfter)?.let { shown += it to widths[it] } + shown += index to if (fits) widths[index] else left + if (!fits) break + width = start + widths[index] + previous = index + } + + val placeables = shown.map { (index, maxWidth) -> + measurables[index].measure(Constraints(maxWidth = maxWidth, maxHeight = constraints.maxHeight)) + } + val rowWidth = placeables.sumOf { it.width } + gap * (placeables.size - 1).coerceAtLeast(0) + val height = (placeables.maxOfOrNull { it.height } ?: 0) + .coerceIn(constraints.minHeight, constraints.maxHeight) + layout(rowWidth.coerceIn(constraints.minWidth, constraints.maxWidth), height) { + var x = 0 + placeables.forEach { placeable -> + placeable.placeRelative(x, (height - placeable.height) / 2) + x += placeable.width + gap + } + } + } +} + +object WholeTokenRowScope { + /** + * When the row is too wide, drop this token before any unmarked one, + * starting from the end. Tokens with a higher [rank] drop before those + * with a lower one. + */ + fun Modifier.dropFirst(rank: Int = 1): Modifier = then(TokenFlags(dropRank = rank.coerceAtLeast(1))) + + /** + * When this token does not fit whole, give it the width left instead of + * dropping it. The token must end its own overflow, usually with an + * ellipsis. + */ + fun Modifier.shrinkable(): Modifier = then(TokenFlags(shrinkable = true)) + + /** + * This child separates the tokens either side of it, and shows only when + * both of them do. + */ + fun Modifier.separator(): Modifier = then(TokenFlags(separator = true)) +} + +private data class TokenFlags( + val dropRank: Int = 0, + val shrinkable: Boolean = false, + val separator: Boolean = false, +) : ParentDataModifier { + override fun Density.modifyParentData(parentData: Any?): Any { + val current = parentData as? TokenFlags ?: TokenFlags() + return TokenFlags( + dropRank = maxOf(current.dropRank, dropRank), + shrinkable = current.shrinkable || shrinkable, + separator = current.separator || separator, + ) + } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/DefaultArtwork.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/DefaultArtwork.kt new file mode 100644 index 0000000000..99164c87ef --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/DefaultArtwork.kt @@ -0,0 +1,134 @@ +package org.prairieserver.prairie.common.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.MenuBook +import androidx.compose.material.icons.outlined.Headphones +import androidx.compose.material.icons.outlined.Movie +import androidx.compose.material.icons.outlined.Tv +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.prairieserver.prairie.common.ui.marquee.MarqueeColors +import org.prairieserver.prairie.model.catalog.isAudiobookItemType +import org.prairieserver.prairie.model.catalog.isBookDisplayType +import org.prairieserver.prairie.model.catalog.isPodcastItemType +import org.prairieserver.prairie.model.catalog.isTvItemType +import kotlin.math.max + +/** Which mark [DefaultArtwork] centres on its glow. */ +enum class DefaultArtworkKind(internal val mark: ImageVector?) { + Video(Icons.Outlined.Movie), + Tv(Icons.Outlined.Tv), + /** Audiobooks and podcasts. */ + Audiobook(Icons.Outlined.Headphones), + Book(Icons.AutoMirrored.Outlined.MenuBook), + + /** The glow alone, for cards that centre their own play button over + * the artwork; the mark's edges would show around it. */ + GlowOnly(null), + ; + + companion object { + /** Series, seasons and episodes get the TV mark, audiobooks and + * podcasts the headphones, ebooks, comics and manga the book; + * anything else, including an unknown type, the film. */ + fun forItemType(type: String?): DefaultArtworkKind = when { + isTvItemType(type) -> Tv + isAudiobookItemType(type) || isPodcastItemType(type) -> Audiobook + isBookDisplayType(type) -> Book + else -> Video + } + } +} + +/** + * Largest the centred mark grows. Phone and tablet keep the default; the TV + * theme raises it so the mark holds up on a big poster across the room. + */ +val LocalDefaultArtworkMarkMaxSize: ProvidableCompositionLocal = + staticCompositionLocalOf { 40.dp } + +private val DefaultArtworkBase = Color(0xFF16171C) +private val DefaultArtworkMarkTint = ColorFilter.tint(Color.White.copy(alpha = 0.13f)) +private val DefaultArtworkMarkMinSize = 14.dp +private const val DefaultArtworkMarkWidthFraction = 0.24f + +/** + * What a poster, cover or still shows when the title has no artwork: a faint + * glow in the Prairie brand colours with a small mark for the kind of title. The + * Apple and web clients draw the same artwork, so keep these values in step + * with them. + * + * [contentDescription] stands in for the missing image's own, so a card whose + * caption is hidden keeps an accessible name. The mark is decoration only. + */ +@Composable +fun DefaultArtwork( + kind: DefaultArtworkKind, + modifier: Modifier = Modifier, + contentDescription: String? = null, +) { + val described = if (contentDescription != null) { + Modifier.semantics { + this.contentDescription = contentDescription + role = Role.Image + } + } else { + Modifier + } + val mark = kind.mark?.let { rememberVectorPainter(it) } + val markMaxSize = LocalDefaultArtworkMarkMaxSize.current + Box( + // Drawn in one pass, no subcomposition: these sit in lazy rows. + modifier = modifier.then(described).drawWithCache { + // Sized from the longer side so a poster and a still get the same + // glow, only cropped differently. + val side = max(size.width, size.height) + val glows = if (side > 0f) { + listOf( + glow(MarqueeColors.BrandBlue, alpha = 0.176f, center = Offset.Zero, radius = side * 0.75f), + glow(MarqueeColors.BrandOrange, alpha = 0.144f, center = Offset(size.width, size.height), radius = side * 0.6f), + glow(MarqueeColors.BrandRed, alpha = 0.112f, center = Offset(size.width * 0.7f, size.height * 0.3f), radius = side * 0.5f), + ) + } else { + emptyList() + } + val minMark = DefaultArtworkMarkMinSize.toPx() + val markSide = (size.width * DefaultArtworkMarkWidthFraction) + .coerceIn(minMark, max(minMark, markMaxSize.toPx())) + onDrawBehind { + drawRect(DefaultArtworkBase) + glows.forEach { drawRect(it) } + if (mark != null && size.width > 0f && size.height > 0f) { + translate((size.width - markSide) / 2f, (size.height - markSide) / 2f) { + with(mark) { draw(Size(markSide, markSide), colorFilter = DefaultArtworkMarkTint) } + } + } + } + }, + ) +} + +private fun glow(color: Color, alpha: Float, center: Offset, radius: Float): Brush = + Brush.radialGradient( + colors = listOf(color.copy(alpha = alpha), color.copy(alpha = 0f)), + center = center, + radius = radius, + ) diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/MachineTranslatedLabel.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/MachineTranslatedLabel.kt new file mode 100644 index 0000000000..09777679af --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/MachineTranslatedLabel.kt @@ -0,0 +1,59 @@ +package org.prairieserver.prairie.common.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicText +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Translate +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** The words every machine-translation marker uses, shown or spoken. */ +const val MachineTranslatedDescription = "Translated by AI" + +/** + * Marks text the server reports as machine-translated (the + * `machine_translated_fields` of a detail, season, episode or card). The + * compact form is an icon for dense rows such as episode cards; it keeps the + * words for accessibility services. Never focusable, so it adds no D-pad stop. + */ +@Composable +fun MachineTranslatedLabel( + color: Color, + modifier: Modifier = Modifier, + compact: Boolean = false, + fontSize: TextUnit = 12.sp, + iconSize: Dp = 12.dp, +) { + Row( + modifier = modifier.clearAndSetSemantics { contentDescription = MachineTranslatedDescription }, + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + imageVector = Icons.Outlined.Translate, + contentDescription = null, + colorFilter = ColorFilter.tint(color), + modifier = Modifier.size(iconSize), + ) + if (!compact) { + BasicText( + text = MachineTranslatedDescription, + style = TextStyle(color = color, fontSize = fontSize), + maxLines = 1, + ) + } + } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/SpoilerImage.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/SpoilerImage.kt new file mode 100644 index 0000000000..23b3aea684 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/SpoilerImage.kt @@ -0,0 +1,106 @@ +package org.prairieserver.prairie.common.ui.components + +import android.os.Build +import androidx.annotation.ChecksSdkIntAtLeast +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp + +/** + * [ThumbhashImage] that can hide its picture behind a blur, for the still of + * an episode the profile has not started (spoiler protection). The frame, and + * anything the caller draws over it (badges, progress, play button), is + * unchanged; only the pixels are obscured. + * + * `Modifier.blur` does nothing below API 31 (Android 11 TVs such as the + * NVIDIA Shield), and a sharp still there would defeat the setting. So on + * those devices a hidden image shows only the decoded ThumbHash, which is + * already a blurry colour field, or a neutral box when there is no hash. The + * real still is never loaded in that case. + */ +@Composable +fun SpoilerImage( + url: String?, + thumbhash: String?, + hidden: Boolean, + contentDescription: String?, + modifier: Modifier = Modifier, + contentScale: ContentScale = ContentScale.Crop, + decodeSizePx: Int? = null, + crossfadeMillis: Int = 300, + onSuccess: (() -> Unit)? = null, + onError: (() -> Unit)? = null, + colorFilter: ColorFilter? = null, + defaultArtwork: DefaultArtworkKind? = null, +) { + if (!hidden) { + ThumbhashImage( + url = url, + thumbhash = thumbhash, + contentDescription = contentDescription, + modifier = modifier, + contentScale = contentScale, + decodeSizePx = decodeSizePx, + crossfadeMillis = crossfadeMillis, + onSuccess = onSuccess, + onError = onError, + colorFilter = colorFilter, + defaultArtwork = defaultArtwork, + ) + return + } + if (!canBlurSpoilers || url.isNullOrBlank()) { + ThumbhashImage( + url = null, + thumbhash = thumbhash, + contentDescription = contentDescription, + modifier = modifier, + contentScale = contentScale, + colorFilter = colorFilter, + defaultArtwork = defaultArtwork, + ) + return + } + // Clip so the scaled, blurred layer stays inside the caller's frame. + Box(modifier = modifier.clipToBounds()) { + ThumbhashImage( + url = url, + thumbhash = thumbhash, + contentDescription = contentDescription, + contentScale = contentScale, + // The detail is destroyed by the blur, so a small decode is enough. + decodeSizePx = SpoilerDecodeSizePx, + crossfadeMillis = crossfadeMillis, + onSuccess = onSuccess, + onError = onError, + colorFilter = colorFilter, + defaultArtwork = defaultArtwork, + modifier = Modifier + .fillMaxSize() + // Scaled past the edges so the blur samples real pixels + // instead of smearing the border inward. + .graphicsLayer { + scaleX = SpoilerScale + scaleY = SpoilerScale + } + .blur(SpoilerBlurRadius, BlurredEdgeTreatment.Rectangle), + ) + } +} + +/** True where `Modifier.blur` renders (API 31+). */ +@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S) +val canBlurSpoilers: Boolean + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + +private const val SpoilerScale = 1.18f +private val SpoilerBlurRadius = 24.dp +private const val SpoilerDecodeSizePx = 256 diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/ThumbhashImage.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/ThumbhashImage.kt index 63cceb6b92..d8f200bbdd 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/ThumbhashImage.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/ThumbhashImage.kt @@ -90,8 +90,9 @@ private fun decodeThumbhashPainter(hash: String): BitmapPainter? = * * Decodes [thumbhash] (a tiny base64-encoded ThumbHash) into a small bitmap and * shows it immediately, crossfading to the full network image as it loads — so - * posters/backdrops blur up instead of popping in from a flat color. Falls back - * to a neutral placeholder when no thumbhash is supplied or decoding fails. + * posters/backdrops blur up instead of popping in from a flat color. The + * ThumbHash stays up when the image fails to load. Falls back to a neutral + * placeholder when no thumbhash is supplied or decoding fails. * * @param url The remote image URL to load. * @param thumbhash Base64-encoded ThumbHash for the instant blurred preview. @@ -112,6 +113,10 @@ private fun decodeThumbhashPainter(hash: String): BitmapPainter? = * by the URL would miss the cache every time. Must still be unique per image. * @param onError Optional signal that the fetch or decode failed, so the caller * can retire the URL and fall back rather than leave an empty box. + * @param defaultArtwork For posters, covers and stills: when the image is + * missing or fails to load and there is no ThumbHash, show [DefaultArtwork] + * with this kind's mark instead of the flat placeholder. Null keeps the + * flat placeholder. */ @Composable fun ThumbhashImage( @@ -130,6 +135,7 @@ fun ThumbhashImage( onError: (() -> Unit)? = null, /** Applied to the loaded image only — used by the blurred page backdrop. */ colorFilter: ColorFilter? = null, + defaultArtwork: DefaultArtworkKind? = null, ) { val context = LocalContext.current val deferPresentationWhile = LocalImagePresentationDeferral.current @@ -140,6 +146,9 @@ fun ThumbhashImage( val cachedPlaceholder = remember(thumbhash) { thumbhash?.takeIf { it.isNotBlank() }?.let { ThumbhashPainterCache.get(it) } } + // Set when a nonblank ThumbHash fails to decode, so the image can fall back + // to the default artwork rather than wait on a placeholder that never comes. + var thumbhashUnusable by remember(thumbhash) { mutableStateOf(false) } // Plain `val` (not `by`) so the null-checked `placeholder` smart-casts below. val placeholder = produceState(initialValue = cachedPlaceholder, thumbhash, cachedPlaceholder) { if (cachedPlaceholder != null) return@produceState @@ -148,9 +157,10 @@ fun ThumbhashImage( return@produceState } val decoded = withContext(Dispatchers.Default) { decodeThumbhashPainter(hash) } - if (decoded != null) ThumbhashPainterCache.put(hash, decoded) + if (decoded != null) ThumbhashPainterCache.put(hash, decoded) else thumbhashUnusable = true value = decoded }.value + val hasUsableThumbhash = !thumbhash.isNullOrBlank() && !thumbhashUnusable if (url.isNullOrBlank()) { when { @@ -160,6 +170,10 @@ fun ThumbhashImage( contentScale = contentScale, modifier = modifier, ) + // Waits on a ThumbHash still decoding, so it doesn't flash the + // default artwork first. + defaultArtwork != null && !hasUsableThumbhash -> + DefaultArtwork(defaultArtwork, modifier = modifier, contentDescription = contentDescription) !transparent -> Box(modifier = modifier.background(DefaultPlaceholderColor)) } return @@ -175,10 +189,17 @@ fun ThumbhashImage( } var failedCount by remember(url, cacheKey) { mutableStateOf(0) } val current = candidates[failedCount.coerceIn(0, candidates.lastIndex)] + + // A failed load keeps the ThumbHash; only without one does a poster fall + // back to the default artwork. "Failed" means every format sibling failed. + var failed by remember(url) { mutableStateOf(false) } + val failedArtwork = defaultArtwork?.takeIf { failed && !hasUsableThumbhash } + val onLoadError: () -> Unit = { if (failedCount < candidates.lastIndex) { failedCount += 1 } else { + failed = true onError?.invoke() } } @@ -200,12 +221,19 @@ fun ThumbhashImage( } if (deferPresentationWhile == null) { + if (failedArtwork != null) { + DefaultArtwork(failedArtwork, modifier = modifier, contentDescription = contentDescription) + return + } AsyncImage( model = model, contentDescription = contentDescription, contentScale = contentScale, alignment = alignment, placeholder = placeholder, + // A failed load (offline, expired URL) keeps the blur-up rather + // than dropping to an empty box. + error = placeholder, colorFilter = colorFilter, onSuccess = { onSuccess?.invoke() }, onError = { onLoadError() }, @@ -258,6 +286,7 @@ fun ThumbhashImage( contentScale = contentScale, modifier = Modifier.fillMaxSize(), ) + failedArtwork != null -> DefaultArtwork(failedArtwork, modifier = Modifier.fillMaxSize()) !transparent -> Box( modifier = Modifier .fillMaxSize() @@ -274,6 +303,7 @@ fun ThumbhashImage( // Matches the cover above, so the swap at the gate is pixel-identical // when a thumbhash exists. placeholder = placeholder, + error = placeholder, colorFilter = colorFilter, onSuccess = { state -> fullImageReady = true diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/marquee/MarqueeBackdrop.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/marquee/MarqueeBackdrop.kt new file mode 100644 index 0000000000..c665975334 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/marquee/MarqueeBackdrop.kt @@ -0,0 +1,348 @@ +package org.prairieserver.prairie.common.ui.marquee + +import android.app.ActivityManager +import android.content.Context +import android.provider.Settings +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameMillis +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.ImageShader +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.sin + +/** Prairie's mark colors and the first-run ink, matching the Apple apps. */ +object MarqueeColors { + val BrandBlue = Color(0xFF0034FB) + val BrandRed = Color(0xFFF50B4F) + val BrandOrange = Color(0xFFFD7403) + val Ink = Color(0xFFEDEDED) + val InkSecondary = Ink.copy(alpha = 0.62f) + val InkTertiary = Ink.copy(alpha = 0.40f) + val Error = Color(0xFFFF6961) + val Live = Color(0xFF30D158) + val Warning = Color(0xFFF4C869) + val Hairline = Color.White.copy(alpha = 0.14f) + val Separator = Color.White.copy(alpha = 0.08f) + val GlassFill = Color.White.copy(alpha = 0.08f) +} + +/** True when the system asks for no animation (animator duration scale 0). */ +@Composable +fun rememberReduceMotion(): Boolean { + val context = LocalContext.current + return remember(context) { + Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f + } +} + +/** + * Brand light plus the server or profile tint. Place it once behind the + * first-run navigation and keep screens transparent over it. + * + * @param mirrored TV copy sits on the left under the darkest scrim, so the + * light pools on the right, behind the card. + */ +@Composable +fun MarqueeBackdrop(modifier: Modifier = Modifier, mirrored: Boolean = false) { + val context = LocalContext.current + val reduceMotion = rememberReduceMotion() + val drifts = remember(context, reduceMotion) { !reduceMotion && !isLowRam(context) } + + // Moving forward reads as the light moving, not a crossfade. + val stage = remember { Animatable(MarqueeScene.stage) } + LaunchedEffect(MarqueeScene.stage) { + stage.animateTo(MarqueeScene.stage, tween(if (reduceMotion) 150 else 1400)) + } + val tint by animateColorAsState( + targetValue = MarqueeScene.personalTint ?: MarqueeScene.accent ?: Color.Transparent, + animationSpec = tween(600), + label = "marqueeTint", + ) + + var phaseSeconds by remember { mutableFloatStateOf(0f) } + if (drifts) { + LaunchedEffect(Unit) { + val start = withFrameMillis { it } + var last = 0L + while (true) { + withFrameMillis { now -> + // About 30 frames a second is plenty for light this slow. + if (now - last >= 33) { + last = now + phaseSeconds = (now - start) / 1000f + } + } + } + } + } + + val light = remember { BrandLight() } + val grain = remember { ShaderBrush(ImageShader(grainTile(), TileMode.Repeated, TileMode.Repeated)) } + + Box(modifier.fillMaxSize()) { + Canvas(Modifier.fillMaxSize()) { + drawRect(Color.Black) + val image = light.render(stage.value, phaseSeconds) + if (mirrored) { + scale(scaleX = -1f, scaleY = 1f) { + drawImage(image, dstSize = IntSize(size.width.toInt(), size.height.toInt()), filterQuality = FilterQuality.Low) + } + } else { + drawImage(image, dstOffset = IntOffset.Zero, dstSize = IntSize(size.width.toInt(), size.height.toInt()), filterQuality = FilterQuality.Low) + } + if (tint.alpha > 0f) { + drawRect( + Brush.radialGradient( + colors = listOf(tint.copy(alpha = tint.alpha * 0.32f), tint.copy(alpha = 0f)), + center = Offset(size.width * 0.5f, size.height * 0.16f), + radius = max(size.width, size.height) * 0.7f, + ), + ) + } + // Fine noise hides banding in near-black ramps and gives the light + // a filmic texture. + drawRect(grain, alpha = 0.045f) + } + } +} + +private fun isLowRam(context: Context): Boolean = + (context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager)?.isLowRamDevice == true + +/** + * The mark's three colors as slow pools of light over black: one cool pool + * while choosing a server, all three once it answers, and a warm room that + * reaches lower behind the profiles. A 3x3 color mesh (as on Apple) drawn + * into a small bitmap and scaled up with filtering. + */ +private class BrandLight { + private val bitmap = android.graphics.Bitmap.createBitmap(W, H, android.graphics.Bitmap.Config.ARGB_8888) + private val image: ImageBitmap = bitmap.asImageBitmap() + private val pixels = IntArray(W * H) + + /** The nine mesh colors for this frame, as r, g, b in 0..1. */ + private val mesh = FloatArray(27) + + fun render(stage: Float, phase: Float): ImageBitmap { + val a = sin(phase / 13f) + val b = cos(phase / 19f) + val middle = staged(floatArrayOf(0.42f, 0.5f, 0.6f), stage) + val centerX = staged(floatArrayOf(0.3f, 0.55f, 0.5f), stage) + // Nine Color lerps a frame; the per-pixel blend below is plain floats. + for (i in 0 until 9) { + val color = stagedColor(i, stage) + mesh[i * 3] = color.red + mesh[i * 3 + 1] = color.green + mesh[i * 3 + 2] = color.blue + } + + // Mesh control points that move: the middle row and the middle column. + val topX = 0.5f + 0.12f * a + val bottomX = 0.5f - 0.1f * b + val leftY = middle + 0.06f * b + val rightY = middle - 0.06f * a + val cX = centerX + 0.1f * b + val cY = middle - 0.08f * a + + for (py in 0 until H) { + val y = (py + 0.5f) / H + for (px in 0 until W) { + val x = (px + 0.5f) / W + // Where the middle row and column cross this pixel. + val rowY = if (x < cX) lerp(leftY, cY, x / cX) else lerp(cY, rightY, (x - cX) / (1f - cX)) + val colX = if (y < rowY) lerp(topX, cX, y / rowY) else lerp(cX, bottomX, (y - rowY) / (1f - rowY)) + val u = if (x < colX) 0.5f * x / colX else 0.5f + 0.5f * (x - colX) / (1f - colX) + val v = if (y < rowY) 0.5f * y / rowY else 0.5f + 0.5f * (y - rowY) / (1f - rowY) + pixels[py * W + px] = sample(u.coerceIn(0f, 1f), v.coerceIn(0f, 1f)) + } + } + bitmap.setPixels(pixels, 0, W, 0, 0, W, H) + bitmap.prepareToDraw() + return image + } + + /** Smoothed bilinear blend of the 3x3 mesh at (u, v) in 0..1, as opaque ARGB. */ + private fun sample(u: Float, v: Float): Int { + val gx = (u * 2f).coerceAtMost(1.9999f) + val gy = (v * 2f).coerceAtMost(1.9999f) + val cx = gx.toInt() + val cy = gy.toInt() + val fx = smooth(gx - cx) + val fy = smooth(gy - cy) + val topLeft = (cy * 3 + cx) * 3 + val bottomLeft = topLeft + 9 + var argb = 0xFF shl 24 + for (channel in 0 until 3) { + val top = lerp(mesh[topLeft + channel], mesh[topLeft + 3 + channel], fx) + val bottom = lerp(mesh[bottomLeft + channel], mesh[bottomLeft + 3 + channel], fx) + val value = (lerp(top, bottom, fy) * 255f + 0.5f).toInt().coerceIn(0, 255) + argb = argb or (value shl (16 - channel * 8)) + } + return argb + } + + private fun smooth(t: Float) = t * t * (3f - 2f * t) + + private fun stagedColor(index: Int, stage: Float): Color { + val s = stage.coerceIn(0f, 2f) + val i = s.toInt().coerceAtMost(1) + return lerp(PALETTES[i][index], PALETTES[i + 1][index], s - i) + } + + private fun staged(values: FloatArray, stage: Float): Float { + val s = stage.coerceIn(0f, 2f) + val i = s.toInt().coerceAtMost(1) + return lerp(values[i], values[i + 1], s - i) + } + + private fun lerp(a: Float, b: Float, t: Float) = a + (b - a) * t + + companion object { + const val W = 48 + const val H = 32 + + private fun Color.dimmed(amount: Float) = lerp(this, Color.Black, amount) + private val blue = MarqueeColors.BrandBlue + private val red = MarqueeColors.BrandRed + private val orange = MarqueeColors.BrandOrange + private val black = Color.Black + + /** Row-major 3x3 mesh colors per stage. */ + val PALETTES = arrayOf( + arrayOf( + blue.dimmed(0.46f), blue.dimmed(0.7f), red.dimmed(0.8f), + blue.dimmed(0.8f), blue.dimmed(0.86f), red.dimmed(0.92f), + black, black, black, + ), + arrayOf( + blue.dimmed(0.5f), red.dimmed(0.58f), orange.dimmed(0.58f), + blue.dimmed(0.76f), red.dimmed(0.74f), orange.dimmed(0.82f), + black, black, black, + ), + arrayOf( + red.dimmed(0.56f), orange.dimmed(0.52f), blue.dimmed(0.56f), + orange.dimmed(0.7f), red.dimmed(0.66f), blue.dimmed(0.72f), + blue.dimmed(0.9f), red.dimmed(0.9f), orange.dimmed(0.92f), + ), + ) + } +} + +/** 96x96 premultiplied white noise, tiled over the light. */ +private fun grainTile(): ImageBitmap { + val side = 96 + val pixels = IntArray(side * side) + var state = 0x9E3779B9.toInt() + for (i in pixels.indices) { + state = state * 1_664_525 + 1_013_904_223 + val value = (state ushr 24) and 0xFF + pixels[i] = (value shl 24) or 0x00FFFFFF + } + return android.graphics.Bitmap.createBitmap(pixels, side, side, android.graphics.Bitmap.Config.ARGB_8888).asImageBitmap() +} + +/** Darkening each screen lays over the backdrop so text always sits on at least 86% black. */ +enum class MarqueeScrimStyle { + /** Phone: content anchored to the bottom. */ + Bottom, + + /** Same, darker higher up, for screens with more controls. */ + BottomDeep, + + /** Profiles and PIN: lighter, ambient. */ + Ambient, + + /** TV: copy on the left, the card on the right. */ + Leading, +} + +/** + * @param frostStart where an extra soft darkening band begins behind the + * controls, as a fraction of the height; null for none. + */ +@Composable +fun MarqueeScrim(style: MarqueeScrimStyle, modifier: Modifier = Modifier, frostStart: Float? = null) { + Canvas(modifier.fillMaxSize()) { + when (style) { + MarqueeScrimStyle.Bottom -> drawRect( + Brush.verticalGradient( + 0f to Color.Black.copy(alpha = 0.62f), + 0.15f to Color.Black.copy(alpha = 0.08f), + 0.34f to Color.Black.copy(alpha = 0.25f), + 0.58f to Color.Black.copy(alpha = 0.86f), + 0.8f to Color.Black, + ), + ) + MarqueeScrimStyle.BottomDeep -> drawRect( + Brush.verticalGradient( + 0f to Color.Black.copy(alpha = 0.62f), + 0.14f to Color.Black.copy(alpha = 0.2f), + 0.32f to Color.Black.copy(alpha = 0.55f), + 0.5f to Color.Black.copy(alpha = 0.92f), + 0.7f to Color.Black, + ), + ) + MarqueeScrimStyle.Ambient -> drawRect( + Brush.verticalGradient( + 0f to Color.Black.copy(alpha = 0.35f), + 0.25f to Color.Black.copy(alpha = 0.2f), + 0.55f to Color.Black.copy(alpha = 0.75f), + 0.78f to Color.Black, + ), + ) + MarqueeScrimStyle.Leading -> { + drawRect( + Brush.horizontalGradient( + 0f to Color.Black.copy(alpha = 0.8f), + 0.3f to Color.Black.copy(alpha = 0.55f), + 0.58f to Color.Black.copy(alpha = 0.2f), + 0.8f to Color.Black.copy(alpha = 0.1f), + 1f to Color.Black.copy(alpha = 0.3f), + ), + ) + drawRect( + Brush.verticalGradient( + 0.65f to Color.Transparent, + 1f to Color.Black.copy(alpha = 0.5f), + ), + ) + } + } + if (frostStart != null) { + val end = (frostStart + 130.dpToFraction(size.height, density)).coerceAtMost(1f) + drawRect( + Brush.verticalGradient( + frostStart to Color.Transparent, + end to Color.Black.copy(alpha = 0.35f), + ), + ) + } + } +} + +private fun Int.dpToFraction(height: Float, density: Float): Float = if (height <= 0f) 0f else this * density / height diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/marquee/MarqueeProfileAvatar.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/marquee/MarqueeProfileAvatar.kt new file mode 100644 index 0000000000..df298c4639 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/marquee/MarqueeProfileAvatar.kt @@ -0,0 +1,189 @@ +package org.prairieserver.prairie.common.ui.marquee + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.draw.drawBehind +import org.prairieserver.prairie.common.ui.components.ThumbhashImage +import org.prairieserver.prairie.common.ui.components.avatarRef +import org.prairieserver.prairie.common.ui.components.isEmojiAvatar +import org.prairieserver.prairie.common.ui.components.profileAvatarDisplayText +import org.prairieserver.prairie.common.ui.components.rememberProfileAvatarImage +import org.prairieserver.prairie.model.profile.Profile + +/** + * Warm tile colors, one per profile id (DJB2), matching the Apple apps' + * `ProfileTilePalette` so a profile has the same color everywhere. + */ +object ProfileTilePalette { + private val colors = listOf( + Color(red = 0.850f, green = 0.460f, blue = 0.380f), // coral + Color(red = 0.400f, green = 0.560f, blue = 0.720f), // slate blue + Color(red = 0.690f, green = 0.540f, blue = 0.400f), // warm tan + Color(red = 0.460f, green = 0.620f, blue = 0.520f), // sage + Color(red = 0.780f, green = 0.480f, blue = 0.520f), // dusty rose + Color(red = 0.560f, green = 0.480f, blue = 0.720f), // lavender + Color(red = 0.360f, green = 0.580f, blue = 0.620f), // teal + Color(red = 0.780f, green = 0.640f, blue = 0.380f), // amber + ) + + fun tint(profileId: String): Color { + var h = 5381UL + for (byte in profileId.encodeToByteArray()) { + h = ((h shl 5) + h) + byte.toUByte().toULong() + } + return colors[(h % colors.size.toULong()).toInt()] + } +} + +/** + * One person's round avatar, like the cast rows on detail pages: their + * image, emoji or initial on their tint, with lock and KIDS badges riding on + * the circle. Badges and text scale with [size]. + * + * @param baseSize the size the badge metrics are designed for (92 phone, 110 TV). + */ +@Composable +fun MarqueeProfileAvatar( + profile: Profile, + size: Dp, + modifier: Modifier = Modifier, + baseSize: Dp = 92.dp, + showsBadges: Boolean = true, +) { + val scale = size / baseSize + val tint = ProfileTilePalette.tint(profile.id) + val avatar = profile.avatarRef() + val image = rememberProfileAvatarImage(avatar) + Box(modifier.size(size)) { + Box( + Modifier + .size(size) + .clip(CircleShape) + .drawBehind { + drawRect( + Brush.radialGradient( + colors = listOf(lerp(tint, Color.White, 0.35f), tint, lerp(tint, Color.Black, 0.45f)), + center = Offset(this.size.width * 0.3f, this.size.height * 0.25f), + radius = this.size.width * 0.8f, + ), + ) + } + .border(1.dp, Color.White.copy(alpha = 0.14f), CircleShape), + contentAlignment = Alignment.Center, + ) { + when { + image != null -> ThumbhashImage( + url = image.url, + thumbhash = null, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + transparent = true, + cacheKey = image.cacheKey, + onError = image.onLoadFailed, + ) + isEmojiAvatar(avatar) -> BasicText( + text = avatar.avatar.orEmpty().trim(), + style = TextStyle(fontSize = (46f * scale).sp), + ) + else -> BasicText( + text = profileAvatarDisplayText(avatar = avatar, name = profile.name), + style = TextStyle( + color = Color.White.copy(alpha = 0.95f), + fontSize = (38f * scale).sp, + fontWeight = FontWeight.Bold, + ), + ) + } + } + if (showsBadges && profile.hasPin) { + val lock = 30.dp * scale + Box( + Modifier + .align(Alignment.BottomEnd) + .offset(x = lock * 0.08f, y = lock * 0.08f) + .size(lock) + .clip(CircleShape) + .background(Color(0xFF1C1C1E)) + .border(lock * 0.07f, Color.Black, CircleShape), + contentAlignment = Alignment.Center, + ) { + LockGlyph(lock * 0.42f) + } + } + if (showsBadges && profile.isChild) { + val font = 11f * scale + Box( + Modifier + .align(Alignment.TopEnd) + .offset(x = (font * 0.5f).dp, y = (-font * 0.2f).dp) + .height((font * 2f).dp) + .clip(CircleShape) + .background(Color.White) + .padding(horizontal = (font * 0.75f).dp), + contentAlignment = Alignment.Center, + ) { + BasicText( + "KIDS", + style = TextStyle(color = Color.Black, fontSize = font.sp, fontWeight = FontWeight.Black, letterSpacing = 0.4.sp), + ) + } + } + } +} + +/** A small padlock drawn with shapes, so this module needs no icon set. */ +@Composable +private fun LockGlyph(size: Dp) { + val ink = MarqueeColors.Ink.copy(alpha = 0.75f) + Box( + Modifier + .size(size) + .drawBehind { + val w = this.size.width + val h = this.size.height + val stroke = w * 0.16f + // Shackle. + drawArc( + color = ink, + startAngle = 180f, + sweepAngle = 180f, + useCenter = false, + topLeft = Offset(w * 0.24f, h * 0.04f), + size = androidx.compose.ui.geometry.Size(w * 0.52f, h * 0.6f), + style = androidx.compose.ui.graphics.drawscope.Stroke(width = stroke), + ) + drawRect(ink, topLeft = Offset(w * 0.24f, h * 0.34f), size = androidx.compose.ui.geometry.Size(stroke, h * 0.12f)) + drawRect(ink, topLeft = Offset(w * 0.76f - stroke, h * 0.34f), size = androidx.compose.ui.geometry.Size(stroke, h * 0.12f)) + // Body. + drawRoundRect( + color = ink, + topLeft = Offset(w * 0.1f, h * 0.44f), + size = androidx.compose.ui.geometry.Size(w * 0.8f, h * 0.56f), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(w * 0.12f), + ) + }, + ) +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/marquee/MarqueeScene.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/marquee/MarqueeScene.kt new file mode 100644 index 0000000000..e7e5b04075 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/marquee/MarqueeScene.kt @@ -0,0 +1,79 @@ +package org.prairieserver.prairie.common.ui.marquee + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import org.koin.dsl.module + +/** + * Where the first-run flow is, shared by every first-run screen. One + * [MarqueeBackdrop] sits behind the whole flow and survives screen changes; + * screens describe the flow through this object and the backdrop moves + * between those states instead of cutting. + */ +object MarqueeScene { + enum class Focus { + /** Server and sign-in screens. */ + Account, + + /** Profile selection and PIN. */ + Profiles, + } + + /** False until a server is confirmed. */ + var serverConfirmed by mutableStateOf(false) + private set + + /** The confirmed server's accent, clamped to a dark, low-saturation tint. */ + var accent by mutableStateOf(null) + private set + + var focus by mutableStateOf(Focus.Account) + + /** Overrides [accent] on screens about one person (PIN, a pressed profile). */ + var personalTint by mutableStateOf(null) + + /** 0 choosing a server, 1 signing in, 2 choosing a profile. */ + val stage: Float + get() = when { + !serverConfirmed -> 0f + focus == Focus.Profiles -> 2f + else -> 1f + } + + /** Applies a confirmed server's branding. Called from the cache first, then after the server answers. */ + fun showServer(branding: ServerBranding?) { + serverConfirmed = true + accent = branding?.accentColor?.let(::clampedAccent) + } + + /** Back to the pre-connect state (sign out, change server). */ + fun showGeneric() { + serverConfirmed = false + accent = null + focus = Focus.Account + personalTint = null + } + + /** + * Admin-chosen accents can be anything; keep the tint dark enough that + * form text over it stays legible. + */ + fun clampedAccent(hex: String): Color? { + val trimmed = hex.trim().removePrefix("#") + if (!Regex("^[0-9A-Fa-f]{6}$").matches(trimmed)) return null + val rgb = trimmed.toInt(16) + val hsv = FloatArray(3) + android.graphics.Color.RGBToHSV((rgb shr 16) and 0xFF, (rgb shr 8) and 0xFF, rgb and 0xFF, hsv) + hsv[1] = hsv[1].coerceAtMost(0.7f) + hsv[2] = hsv[2].coerceIn(0.45f, 0.8f) + return Color(android.graphics.Color.HSVToColor(hsv)) + } +} + +/** Branding cache and loader for the first-run screens on phone and TV. */ +val marqueeModule = module { + single { ServerBrandingCache(get()) } + single { ServerBrandingLoader(get(), get()) } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/marquee/MarqueeServerMark.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/marquee/MarqueeServerMark.kt new file mode 100644 index 0000000000..2f87c63f00 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/marquee/MarqueeServerMark.kt @@ -0,0 +1,74 @@ +package org.prairieserver.prairie.common.ui.marquee + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +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 +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage + +/** + * The server's mark: its branded `mark_url` image when there is one, else its + * initial on a warm tile, else a plain dark tile. + */ +@Composable +fun MarqueeServerMark(name: String?, imageUrl: String?, size: Dp, modifier: Modifier = Modifier) { + val shape = RoundedCornerShape(size * 0.25f) + var imageFailed by remember(imageUrl) { mutableStateOf(imageUrl == null) } + Box( + modifier = modifier + .size(size) + .clip(shape) + .border(1.dp, Color.White.copy(alpha = 0.18f), shape), + contentAlignment = Alignment.Center, + ) { + val initial = name?.trim()?.firstOrNull()?.uppercase() + if (initial != null) { + Box( + Modifier + .size(size) + .background(Brush.linearGradient(listOf(Color(0xFFEAA65C), Color(0xFFA8521D), Color(0xFF5E290D)))), + contentAlignment = Alignment.Center, + ) { + BasicText( + text = initial, + style = TextStyle( + color = Color(0xFFFFF6EA), + fontSize = (size.value * 0.54f).sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Serif, + ), + ) + } + } else { + Box(Modifier.size(size).background(Brush.verticalGradient(listOf(Color(0xFF2B2D33), Color(0xFF15171C))))) + } + if (!imageFailed) { + AsyncImage( + model = imageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + onError = { imageFailed = true }, + modifier = Modifier.size(size), + ) + } + } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/marquee/MarqueeShake.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/marquee/MarqueeShake.kt new file mode 100644 index 0000000000..1feb86d7ed --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/marquee/MarqueeShake.kt @@ -0,0 +1,36 @@ +package org.prairieserver.prairie.common.ui.marquee + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.keyframes +import androidx.compose.foundation.layout.offset +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.unit.IntOffset + +/** + * A short sideways shake each time [trigger] changes (a rejected password or + * PIN). The value it starts with doesn't shake, so returning to a screen + * doesn't replay an old failure. Under reduced motion the error's color + * carries it alone. + */ +fun Modifier.marqueeShake(trigger: Int): Modifier = composed { + val reduceMotion = rememberReduceMotion() + val offset = remember { Animatable(0f) } + val initial = remember { trigger } + LaunchedEffect(trigger) { + if (trigger == initial || reduceMotion) return@LaunchedEffect + offset.animateTo( + 0f, + keyframes { + durationMillis = 370 + -8f at 70 + 8f at 140 + -6f at 210 + 4f at 280 + }, + ) + } + offset { IntOffset((offset.value * density).toInt(), 0) } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/marquee/ServerBranding.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/marquee/ServerBranding.kt new file mode 100644 index 0000000000..a667741dfe --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/marquee/ServerBranding.kt @@ -0,0 +1,115 @@ +package org.prairieserver.prairie.common.ui.marquee + +import android.content.Context +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json +import org.prairieserver.prairie.network.AndroidServerRegistry +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.api.BrandingApi +import org.prairieserver.prairie.network.api.BrandingStatus +import java.net.URI + +/** + * A server's public branding (`GET /api/v2/theme/branding`) with its asset + * paths resolved against the server. Read before sign-in, so the first-run + * screens can name the server and tint the backdrop to its accent. + */ +@Serializable +data class ServerBranding( + val serverName: String? = null, + val loginSubtitle: String? = null, + val accentColor: String? = null, + val markUrl: String? = null, + val wordmarkUrl: String? = null, +) { + companion object { + fun from(document: BrandingStatus, serverUrl: String) = ServerBranding( + serverName = document.serverName.usable(), + loginSubtitle = document.loginSubtitle.usable(), + accentColor = document.accentColor.usable(), + markUrl = resolveAsset(document.markUrl, serverUrl), + wordmarkUrl = resolveAsset(document.wordmarkUrl, serverUrl), + ) + + /** + * Server asset paths are site-relative. Only same-origin http(s) URLs + * are accepted, so branding can't point the app at another host. + */ + fun resolveAsset(value: String?, serverUrl: String): String? { + val raw = value?.trim()?.takeIf { it.isNotEmpty() } ?: return null + val base = runCatching { URI(serverUrl.trimEnd('/') + "/") }.getOrNull() ?: return null + val resolved = runCatching { base.resolve(raw.removePrefix("/")) }.getOrNull() ?: return null + val origin = origin(resolved) ?: return null + return if (origin == origin(base)) resolved.toString() else null + } + + /** Scheme, host and port with the default port filled in, so `https://h` and `https://h:443` match. */ + private fun origin(uri: URI): String? { + val scheme = uri.scheme?.lowercase() ?: return null + val host = uri.host?.lowercase() ?: return null + val port = uri.port.takeIf { it > 0 } ?: when (scheme) { + "https" -> 443 + "http" -> 80 + else -> return null + } + return "$scheme://$host:$port" + } + + /** `host` or `host:port`, for a server's address line. */ + fun hostLabel(serverUrl: String): String { + val uri = runCatching { URI(serverUrl) }.getOrNull() ?: return serverUrl + val host = uri.host ?: return serverUrl + return if (uri.port > 0) "$host:${uri.port}" else host + } + + private fun String?.usable(): String? = this?.trim()?.takeIf { it.isNotEmpty() } + } +} + +/** + * The last branding each server showed, so returning to sign-in or the + * profile picker shows that server's name and accent at once instead of + * flashing the generic look first. + */ +class ServerBrandingCache(context: Context) { + private val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + private val serializer = MapSerializer(String.serializer(), ServerBranding.serializer()) + private val json = Json { ignoreUnknownKeys = true } + + fun branding(serverUrl: String): ServerBranding? = readAll()[key(serverUrl)] + + fun store(serverUrl: String, branding: ServerBranding) { + val all = readAll().toMutableMap() + all[key(serverUrl)] = branding + prefs.edit().putString(KEY, json.encodeToString(serializer, all)).apply() + } + + private fun readAll(): Map { + val raw = prefs.getString(KEY, null) ?: return emptyMap() + return runCatching { json.decodeFromString(serializer, raw) }.getOrDefault(emptyMap()) + } + + private fun key(serverUrl: String) = AndroidServerRegistry.normalizeUrl(serverUrl) + + private companion object { + const val PREFS = "marquee_server_branding" + const val KEY = "branding.v1" + } +} + +/** + * Reads a server's branding and saves it. A failure is no branding: the + * screens fall back to the plain brand light and the saved name. + */ +class ServerBrandingLoader( + private val api: BrandingApi, + val cache: ServerBrandingCache, +) { + suspend fun load(serverUrl: String): ServerBranding? { + if (serverUrl.isBlank()) return null + val document = (api.getBranding(serverUrl) as? ApiResult.Success)?.data ?: return null + return ServerBranding.from(document, serverUrl).also { cache.store(serverUrl, it) } + } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/marquee/SignInHandoff.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/marquee/SignInHandoff.kt new file mode 100644 index 0000000000..8584490187 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/marquee/SignInHandoff.kt @@ -0,0 +1,16 @@ +package org.prairieserver.prairie.common.ui.marquee + +/** + * What a finished sign-in tells the profile picker that follows it. + */ +object SignInHandoff { + /** + * Set by a completed sign-in, including setup from a nearby phone: a + * household with exactly one profile and no PIN goes straight to Home + * instead of a one-person picker. The picker clears it on first read. + */ + @Volatile + var skipsSingleProfilePicker: Boolean = false + + fun consumeSkipsSingleProfilePicker(): Boolean = skipsSingleProfilePicker.also { skipsSingleProfilePicker = false } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/marquee/SignInServer.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/marquee/SignInServer.kt new file mode 100644 index 0000000000..d3e05ab5b8 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/marquee/SignInServer.kt @@ -0,0 +1,51 @@ +package org.prairieserver.prairie.common.ui.marquee + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue + +/** + * The server a first-run screen is about: its name, address and branding. + * Branding shows from the cache at once and refreshes from the server. + */ +@Stable +class SignInServer(val serverUrl: String, initial: ServerBranding?, private val savedName: String?) { + var branding by mutableStateOf(initial) + internal set + + /** Branded name, then the name saved at connect, then the host. */ + val name: String + get() = branding?.serverName ?: savedName?.trim()?.takeIf { it.isNotEmpty() } ?: hostLabel + + val hostLabel: String get() = ServerBranding.hostLabel(serverUrl) + + val isSecure: Boolean get() = serverUrl.startsWith("https://", ignoreCase = true) +} + +/** + * Tints the backdrop for [serverUrl] from the saved branding, then reads + * fresh branding. With [tintsBackdrop] false the screen only reads the name + * (the profile picker tints for a person instead of the server). + */ +@Composable +fun rememberSignInServer( + serverUrl: String, + savedName: String?, + loader: ServerBrandingLoader, + tintsBackdrop: Boolean = true, +): SignInServer { + val server = remember(serverUrl) { SignInServer(serverUrl, loader.cache.branding(serverUrl), savedName) } + LaunchedEffect(serverUrl) { + if (serverUrl.isBlank()) return@LaunchedEffect + if (tintsBackdrop) MarqueeScene.showServer(server.branding) + // Cancelled with the screen, so a late answer can't retint the next one. + val fresh = loader.load(serverUrl) ?: return@LaunchedEffect + server.branding = fresh + if (tintsBackdrop) MarqueeScene.showServer(fresh) + } + return server +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/watchparty/WatchPartyExperiment.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/watchparty/WatchPartyExperiment.kt new file mode 100644 index 0000000000..fcbdad70d3 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/watchparty/WatchPartyExperiment.kt @@ -0,0 +1,43 @@ +package org.prairieserver.prairie.common.watchparty + +import android.content.SharedPreferences +import org.prairieserver.prairie.model.feature.WatchPartyExposure +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * The Settings → Experimental → Watch Party toggle. It is device-local (never + * synced with the server's settings) and, until the user chooses, follows + * [defaultEnabled]: on in debug builds, off in release builds. Turning it off + * hides every entry point, and [onDisabled] leaves any active party. + */ +class WatchPartyExperiment( + private val read: () -> Boolean?, + private val write: (Boolean) -> Unit, + private val defaultEnabled: Boolean, + private val onDisabled: () -> Unit, +) : WatchPartyExposure { + constructor(prefs: SharedPreferences, defaultEnabled: Boolean, onDisabled: () -> Unit) : this( + read = { if (prefs.contains(KEY)) prefs.getBoolean(KEY, false) else null }, + write = { value -> prefs.edit().putBoolean(KEY, value).apply() }, + defaultEnabled = defaultEnabled, + onDisabled = onDisabled, + ) + + private val _enabled = MutableStateFlow(read() ?: defaultEnabled) + override val enabled: StateFlow = _enabled.asStateFlow() + + fun setEnabled(value: Boolean) { + write(value) + val was = _enabled.value + _enabled.value = value + if (was && !value) onDisabled() + } + + companion object { + /** Preferences file for device-local experiments. */ + const val PREFS_NAME = "silo_experimental" + private const val KEY = "watch_party" + } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/watchparty/WatchPartyServerMatch.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/watchparty/WatchPartyServerMatch.kt new file mode 100644 index 0000000000..df34616d07 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/watchparty/WatchPartyServerMatch.kt @@ -0,0 +1,33 @@ +package org.prairieserver.prairie.common.watchparty + +import java.net.URI + +/** + * Whether an invitation's server is the server this app is signed in to: + * the same scheme, host (ignoring case), port (an omitted default port equals + * the explicit one), and path once a trailing slash is trimmed. A LAN address + * and a public address for the same server do not match, so a join token is + * never sent to a deployment the user did not sign in to. Phone and TV share + * this so a link matches on both or on neither. + */ +fun watchPartyServerMatches(linkServerUrl: String, activeServerUrl: String?): Boolean { + val link = comparableServer(linkServerUrl) ?: return false + val active = activeServerUrl?.let(::comparableServer) ?: return false + return link == active +} + +private data class ComparableServer(val scheme: String, val host: String, val port: Int, val path: String) + +private fun comparableServer(url: String): ComparableServer? { + val uri = runCatching { URI(url.trim()) }.getOrNull() ?: return null + if (uri.isOpaque) return null + val scheme = uri.scheme?.lowercase()?.takeIf { it == "http" || it == "https" } ?: return null + if (uri.rawUserInfo != null || uri.rawQuery != null || uri.rawFragment != null) return null + val host = uri.host?.lowercase()?.takeIf { it.isNotEmpty() } ?: return null + val port = when { + uri.port != -1 -> uri.port + scheme == "https" -> 443 + else -> 80 + } + return ComparableServer(scheme, host, port, uri.rawPath.orEmpty().trimEnd('/')) +} diff --git a/android-shared/src/androidMain/res/drawable/tmdb_logo.xml b/android-shared/src/androidMain/res/drawable/tmdb_logo.xml new file mode 100644 index 0000000000..a71e3dc244 --- /dev/null +++ b/android-shared/src/androidMain/res/drawable/tmdb_logo.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/db/PrairieDatabaseMigrationTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/db/PrairieDatabaseMigrationTest.kt index b560d918fb..180bb48fe0 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/db/PrairieDatabaseMigrationTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/db/PrairieDatabaseMigrationTest.kt @@ -11,6 +11,43 @@ import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class PrairieDatabaseMigrationTest { + @Test + fun migration14To15PreservesDownloadsWithUnknownArtworkState() { + val name = "migration-14-to-15" + migrationHelper.createDatabase(name, 14).use { database -> + database.execSQL( + "INSERT INTO downloads (serverId, profileId, mediaFileId, recordId, contentId, title, mediaType, status, kind, " + + "fileSize, bytesSent, createdAt, updatedAtMs) VALUES ('s', 'p', 42, 'row', 'episode', 'Episode', 'tv', " + + "'completed', 'queued', 1024, 1024, '2026-10-01T00:00:00Z', 123)", + ) + database.execSQL("INSERT INTO content_item_state (serverId, profileId, contentId, watched, clientUpdatedAtMs) " + + "VALUES ('s', 'p', 'episode', 1, 1000)") + database.execSQL("INSERT INTO user_item_state (serverId, profileId, contentId, fileId, positionSeconds, audioFingerprint, clientUpdatedAtMs) " + + "VALUES ('s', 'p', 'episode', 42, 30, 'audio', 1000)") + } + migrationHelper.runMigrationsAndValidate(name, 15, true).use { database -> + database.query("SELECT watched, watchedUpdatedAtMs FROM content_item_state").use { cursor -> + assertEquals(true, cursor.moveToFirst()) + assertEquals(1, cursor.getInt(0)) + assertEquals(true, cursor.isNull(1)) + } + database.query("SELECT positionSeconds, audioFingerprint, positionUpdatedAtMs FROM user_item_state").use { cursor -> + assertEquals(true, cursor.moveToFirst()) + assertEquals(30.0, cursor.getDouble(0)) + assertEquals("audio", cursor.getString(1)) + assertEquals(true, cursor.isNull(2)) + } + database.query("SELECT recordId, status, posterIsEpisodeStill, episodeUserDataJson FROM downloads").use { cursor -> + assertEquals(true, cursor.moveToFirst()) + assertEquals("row", cursor.getString(0)) + assertEquals("completed", cursor.getString(1)) + assertEquals(true, cursor.isNull(2)) + assertEquals(true, cursor.isNull(3)) + assertEquals(false, cursor.moveToNext()) + } + } + } + @get:Rule val migrationHelper = MigrationTestHelper( @@ -144,6 +181,35 @@ class PrairieDatabaseMigrationTest { } } + @Test + fun migration13To14KeepsOfflineTracksAndAddsNullableArtworkColumns() { + val name = "migration-13-to-14" + migrationHelper.createDatabase(name, 13).use { database -> + database.execSQL( + "INSERT INTO downloads (serverId, profileId, mediaFileId, recordId, contentId, title, posterThumbhash, " + + "mediaType, status, kind, fileSize, bytesSent, createdAt, updatedAtMs, revision, offlineTracksJson) VALUES " + + "('s', 'p', 42, 'dl_1', 'ep_1', 'Example', 'THUMB', 'tv', 'completed', 'queued', 1024, 1024, " + + "'2026-09-29T00:00:00Z', 123, 7, '{}')", + ) + } + migrationHelper.runMigrationsAndValidate(name, 14, true).use { database -> + database.query( + "SELECT recordId, posterThumbhash, revision, offlineTracksJson, offlinePosterPath, " + + "offlineSeriesPosterPath, seriesPosterThumbhash FROM downloads", + ).use { cursor -> + assertEquals(true, cursor.moveToFirst()) + assertEquals("dl_1", cursor.getString(0)) + assertEquals("THUMB", cursor.getString(1)) + assertEquals(7, cursor.getInt(2)) + assertEquals("{}", cursor.getString(3)) + assertNull(cursor.getString(4)) + assertNull(cursor.getString(5)) + assertNull(cursor.getString(6)) + assertEquals(false, cursor.moveToNext()) + } + } + } + private companion object { const val DATABASE_NAME = "migration-7-to-8" } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/repository/RoomCatalogCacheRepositoryTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/repository/RoomCatalogCacheRepositoryTest.kt index 10690e50ac..550c46b3f4 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/repository/RoomCatalogCacheRepositoryTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/repository/RoomCatalogCacheRepositoryTest.kt @@ -144,4 +144,61 @@ class RoomCatalogCacheRepositoryTest { assertEquals(0L, oldProfileGeneration) assertNull(guardedRepo.getCachedLibraries()) } + @Test + fun languageInvalidationPurgesOnlyCurrentScopesDetailMetadata() = runTest { + val owner = scope!! + val detail = org.prairieserver.prairie.model.catalog.ItemDetail(contentId = "c1", type = "series", title = "Old language") + val seasons = org.prairieserver.prairie.model.catalog.SeasonsResponse() + val episodes = org.prairieserver.prairie.model.catalog.EpisodesResponse() + suspend fun populate() { + repo.cacheItemDetail("c1", detail) + repo.cacheSeasons("c1", seasons) + repo.cacheEpisodes("c1", 1, episodes) + repo.cacheLibraries(listOf(UserLibrary(id = 1, name = "Shows", type = "series"))) + } + populate() + scope = owner.copy(profileId = "p2") + populate() + scope = owner + repo.invalidateDetailMetadata() + assertNull(repo.getCachedItemDetail("c1")) + assertNull(repo.getCachedSeasons("c1")) + assertNull(repo.getCachedEpisodes("c1", 1)) + assertEquals("Shows", repo.getCachedLibraries()?.single()?.name) + scope = owner.copy(profileId = "p2") + assertEquals(detail, repo.getCachedItemDetail("c1")) + assertEquals(seasons, repo.getCachedSeasons("c1")) + assertEquals(episodes, repo.getCachedEpisodes("c1", 1)) + } + + @Test + fun languagePurgeAfterProfileSwitchDeletesTheWriterAndRetainsTheActiveProfilesMetadata() = runTest { + val owner = scope!! + val detail = org.prairieserver.prairie.model.catalog.ItemDetail(contentId = "c1", type = "series", title = "Cached") + val seasons = org.prairieserver.prairie.model.catalog.SeasonsResponse() + val episodes = org.prairieserver.prairie.model.catalog.EpisodesResponse() + suspend fun populate() { + repo.cacheItemDetail("c1", detail) + repo.cacheSeasons("c1", seasons) + repo.cacheEpisodes("c1", 1, episodes) + } + populate() + val language = org.prairieserver.prairie.domain.settings.CatalogLanguageRevision() + language.changed(org.prairieserver.prairie.repository.port.CatalogCacheScope(owner.serverId, owner.profileId!!)) + scope = owner.copy(profileId = "p2") + populate() + val client = io.ktor.client.HttpClient(io.ktor.client.engine.mock.MockEngine { error("No live request expected") }) + val catalog = org.prairieserver.prairie.repository.CatalogRepository( + org.prairieserver.prairie.network.api.CatalogApi(client), repo, catalogLanguage = language, + ) + catalog.invalidateLocalizedMetadata() + assertEquals(detail, repo.getCachedItemDetail("c1")) + assertEquals(seasons, repo.getCachedSeasons("c1")) + assertEquals(episodes, repo.getCachedEpisodes("c1", 1)) + scope = owner + assertNull(catalog.getCachedItemDetail("c1")) + assertNull(catalog.getCachedSeasons("c1")) + assertNull(catalog.getCachedEpisodes("c1", 1)) + } + } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/repository/RoomUserItemStateRepositoryTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/repository/RoomUserItemStateRepositoryTest.kt index 5914a9498f..ba3d121229 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/repository/RoomUserItemStateRepositoryTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/repository/RoomUserItemStateRepositoryTest.kt @@ -46,6 +46,78 @@ class RoomUserItemStateRepositoryTest { @AfterTest fun tearDown() = db.close() + @Test + fun remoteWatchReadsDeferOnlyToPendingOrNewerLocalWrites() = runTest { + val watched = repo.recordWatched("c1", true) + assertEquals(setOf("c1"), repo.contentIdsWithPendingOrNewerUserState(listOf("c1"), 2000L)) + repo.resolve(watched, WriteOutcome.SYNCED) + assertEquals(emptySet(), repo.contentIdsWithPendingOrNewerUserState(listOf("c1"), 2000L)) + assertEquals(setOf("c1"), repo.contentIdsWithPendingOrNewerUserState(listOf("c1"), 500L)) + repo.recordPosition("c2", 7, 5.0, 60.0) + assertEquals(setOf("c2"), repo.contentIdsWithPendingOrNewerUserState(listOf("c1", "c2"), 2000L)) + currentSnapshot = AuthScopeSnapshot("s1", "p2", "https://s1.example", "pt2") + assertEquals(emptySet(), repo.contentIdsWithPendingOrNewerUserState(listOf("c1", "c2"), 0L)) + } + + @Test + fun ratingsAndTrackChangesDoNotPromoteStaleWatchState() = runTest { + var time = 1000L + val repository = RoomUserItemStateRepository(db, { currentSnapshot }, now = { time }) + val watched = repository.recordWatched("episode", true) + repository.resolve(watched, WriteOutcome.SYNCED) + repository.recordPosition("episode", 7, 30.0, 60.0) + db.dirtyOperationDao().dueBatch("s1", "p1", 1000L, 10).forEach { + repository.resolve(OutboxHandle(it.id), WriteOutcome.SYNCED) + } + time = 3000L + val rating = repository.recordRating("episode", 5) + repository.recordAudioTrackSelection("episode", 7, "audio") + assertEquals(emptySet(), repository.contentIdsWithPendingOrNewerUserState(listOf("episode"), 2000L)) + repository.resolve(rating, WriteOutcome.SYNCED) + assertEquals(emptySet(), repository.contentIdsWithPendingOrNewerUserState(listOf("episode"), 2000L)) + val reset = repository.recordWatched("episode", false) + repository.resolve(reset, WriteOutcome.SYNCED) + assertEquals(setOf("episode"), repository.contentIdsWithPendingOrNewerUserState(listOf("episode"), 2000L)) + } + + @Test + fun trackChoicesDoNotPreventWatchedResetsOrRejectedResetRecovery() = runTest { + var time = 1000L + val repository = RoomUserItemStateRepository(db, { currentSnapshot }, now = { time }) + repository.recordPosition("episode", 7, 30.0, 60.0) + time = 3000L + repository.recordAudioTrackSelection("episode", 7, "audio") + // A watched write admitted at 2000 must clear the older progress, even + // though an unrelated track choice updated the row afterward. + db.userItemStateDao().clearPlaybackProgressBefore("s1", "p1", "episode", 2000L, 4000L) + assertNull(repository.localPlaybackProgress("episode")) + time = 5000L + repository.recordSubtitleTrackSelection("episode", 7, "subtitle") + db.userItemStateDao().restorePlaybackProgressIfUnchanged("s1", "p1", "episode", 7, 30.0, 3000L, 1000L, 4000L) + val row = db.userItemStateDao().get("s1", "p1", "episode", 7)!! + assertEquals(30.0, row.positionSeconds) + assertEquals("audio", row.audioFingerprint) + assertEquals("subtitle", row.subtitleFingerprint) + assertEquals(5000L, row.clientUpdatedAtMs) + assertEquals(1000L, row.positionUpdatedAtMs) + } + + @Test + fun rowsWrittenBeforeThePositionTimestampKeepTheirGuards() = runTest { + val repository = RoomUserItemStateRepository(db, { currentSnapshot }, now = { 1000L }) + repository.recordPosition("episode", 7, 30.0, 60.0) + val legacy = "UPDATE user_item_state SET positionUpdatedAtMs = NULL" + db.openHelper.writableDatabase.execSQL(legacy) + // A resume sample saved after the watched op was admitted survives its late acknowledgement. + db.userItemStateDao().clearPlaybackProgressBefore("s1", "p1", "episode", 500L, 2000L) + assertEquals(30.0, db.userItemStateDao().get("s1", "p1", "episode", 7)!!.positionSeconds) + // A reset made before the upgrade is still undone when its op is rejected. + db.userItemStateDao().clearPlaybackProgress("s1", "p1", "episode", 4000L) + db.openHelper.writableDatabase.execSQL(legacy) + db.userItemStateDao().restorePlaybackProgressIfUnchanged("s1", "p1", "episode", 7, 30.0, 1000L, null, 4000L) + assertEquals(30.0, db.userItemStateDao().get("s1", "p1", "episode", 7)!!.positionSeconds) + } + @Test fun recordWatchedWritesProjectionAndContentScopedOutboxOp() = runTest { val handle = repo.recordWatched("c1", watched = true) diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/sync/MembershipActivationTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/sync/MembershipActivationTest.kt index 47e2f95e86..d34cc27da3 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/sync/MembershipActivationTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/sync/MembershipActivationTest.kt @@ -56,6 +56,7 @@ class MembershipActivationTest { val app = org.koin.dsl.koinApplication(createEagerInstances = false) { modules(org.prairieserver.prairie.di.repositoryModule, org.koin.dsl.module { single { PersonalDataApi(client) } + single { SettingsApi(SettingsV2Api(client, tokens, ApiV2Gate.Unrestricted)) } single { port } single { barrier } }) @@ -175,7 +176,7 @@ class MembershipActivationTest { } }) val (repository, _) = repository(client) - val vm = HomeViewModel(SectionRepository(SectionApi(client, home = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted))), + val vm = HomeViewModel(SectionRepository(SectionApi(client, home = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted, decodeDispatcher = Dispatchers.Main))), org.prairieserver.prairie.domain.MediaActionsCoordinator(repository), identityTransitions = barrier) viewModels += vm vm.uiState.first { !it.isLoading } @@ -224,7 +225,7 @@ class MembershipActivationTest { } }) val (repository, _) = repository(client) - val vm = HomeViewModel(SectionRepository(SectionApi(client, home = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted))), + val vm = HomeViewModel(SectionRepository(SectionApi(client, home = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted, decodeDispatcher = Dispatchers.Main))), org.prairieserver.prairie.domain.MediaActionsCoordinator(repository), identityTransitions = barrier) viewModels += vm vm.uiState.first { !it.isLoading } @@ -285,7 +286,7 @@ class MembershipActivationTest { } }) val (repository, _) = repository(client) - val vm = HomeViewModel(SectionRepository(SectionApi(client, home = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted))), + val vm = HomeViewModel(SectionRepository(SectionApi(client, home = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted, decodeDispatcher = Dispatchers.Main))), org.prairieserver.prairie.domain.MediaActionsCoordinator(repository), identityTransitions = barrier) viewModels += vm vm.uiState.first { !it.isLoading } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/DownloadMetadataStoreTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/DownloadMetadataStoreTest.kt index 3760e846c1..551175fdf9 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/DownloadMetadataStoreTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/DownloadMetadataStoreTest.kt @@ -5,7 +5,14 @@ import androidx.test.core.app.ApplicationProvider import org.prairieserver.prairie.common.data.db.PrairieDatabase import org.prairieserver.prairie.model.download.DownloadRecord import org.prairieserver.prairie.model.download.DownloadSidecar +import org.prairieserver.prairie.model.catalog.LeafItemUserData +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import org.prairieserver.prairie.common.data.db.dao.DownloadArtworkRow import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import kotlin.test.AfterTest @@ -61,6 +68,18 @@ class DownloadMetadataStoreTest { assertEquals(sidecar, read) } + @Test + fun `episode artwork provenance and watch state survive Room storage`() = runTest { + for (isStill in listOf(true, false, null)) { + val sidecar = stubSidecar(7).copy( + posterIsEpisodeStill = isStill, + episodeUserData = LeafItemUserData(played = false, isInProgress = true, positionSeconds = 42.0), + ) + store.writeSidecar("srv1", "profA", sidecar) + assertEquals(sidecar, store.readSidecar("srv1", "profA", 7)) + } + } + @Test fun `writeSidecar upserts on re-write`() = runTest { store.writeSidecar("srv1", "profA", stubSidecar(7, contentId = "old")) @@ -200,6 +219,25 @@ class DownloadMetadataStoreTest { assertEquals(replacement, store.readSidecar("server", "profile", 7)) } + @Test fun `a conditional update leaves a replaced or deleted row as it is`() = runTest { + val old = stubSidecar(7) + val replacement = old.copy(record = old.record.copy(id = "replacement")) + store.writeSidecar("server", "profile", replacement) + assertFalse(store.updateSidecarIf("server", "profile", 7, matches = { it.record.id == old.record.id }) { + error("A replaced row must not be updated") + }) + assertEquals(replacement, store.readSidecar("server", "profile", 7)) + + assertTrue(store.updateSidecarIf("server", "profile", 7, matches = { it.record.id == "replacement" }) { + it.copy(updatedAtMs = 42) + }) + assertEquals(42, store.readSidecar("server", "profile", 7)?.updatedAtMs) + + store.deleteSidecar("server", "profile", 7) + assertFalse(store.updateSidecarIf("server", "profile", 7, matches = { true }) { it }) + assertNull(store.readSidecar("server", "profile", 7)) + } + @Test fun `matching cleanup removes metadata only after byte deletion succeeds`() = runTest { val row = stubSidecar(7) store.writeSidecar("server", "profile", row) @@ -221,4 +259,54 @@ class DownloadMetadataStoreTest { assertNotNull(store.readSidecar("server", "profile", 7)) } + + @Test + fun `savedArtworkChanges emits on artwork writes and not on other column writes`() = runBlocking { + val emissions = Channel>(Channel.UNLIMITED) + val collector = launch(Dispatchers.Default) { store.savedArtworkChanges().collect { emissions.send(it) } } + suspend fun next() = withTimeout(10_000) { emissions.receive() } + try { + assertEquals(emptyList(), next()) + + val downloading = stubSidecar(7).let { + it.copy(record = it.record.copy(status = "downloading", bytesSent = 10)) + } + // A new row and progress ticks carry no artwork. + store.writeSidecar("srv1", "profA", downloading) + store.writeSidecar("srv1", "profA", downloading.copy(record = downloading.record.copy(bytesSent = 20))) + val completed = stubSidecar(7).copy(offlinePosterPath = "/art/poster") + store.writeSidecar("srv1", "profA", completed) + assertEquals(listOf(DownloadArtworkRow("dl-7", null, "/art/poster", null, null)), next()) + + // Status and title rewrites keep the artwork as it was. + store.writeSidecar("srv1", "profA", completed.copy(title = "Renamed", updatedAtMs = 2L)) + store.writeSidecar("srv1", "profA", completed.copy(offlineSeriesPosterPath = "/art/series_poster")) + // Ordered invalidation: had the rename emitted, it would be received here. + assertEquals( + listOf(DownloadArtworkRow("dl-7", null, "/art/poster", "/art/series_poster", null)), + next(), + ) + } finally { + collector.cancel() + } + } + + @Test + fun `savedArtworkChanges emits when only the poster thumbhash is saved`() = runBlocking { + val emissions = Channel>(Channel.UNLIMITED) + val collector = launch(Dispatchers.Default) { store.savedArtworkChanges().collect { emissions.send(it) } } + suspend fun next() = withTimeout(10_000) { emissions.receive() } + try { + assertEquals(emptyList(), next()) + + // A completed movie queued without a hash, whose poster fetch failed: + // the capture saves only the manifest's ThumbHash. + val completed = stubSidecar(7) + store.writeSidecar("srv1", "profA", completed) + store.writeSidecar("srv1", "profA", completed.copy(posterThumbhash = "hash")) + assertEquals(listOf(DownloadArtworkRow("dl-7", "hash", null, null, null)), next()) + } finally { + collector.cancel() + } + } } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/DownloadSidecarArtworkTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/DownloadSidecarArtworkTest.kt new file mode 100644 index 0000000000..b9a3e34ad2 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/DownloadSidecarArtworkTest.kt @@ -0,0 +1,106 @@ +package org.prairieserver.prairie.common.downloads + +import org.prairieserver.prairie.model.download.DownloadRecord +import org.prairieserver.prairie.model.download.DownloadSidecar +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class DownloadSidecarArtworkTest { + + private val sidecar = DownloadSidecar( + record = DownloadRecord( + id = "dl_1", + contentId = "sr_1", + mediaFileId = 42, + kind = "queued", + status = "completed", + createdAt = "2026-09-29T00:00:00Z", + ), + title = "Example", + posterUrl = "https://prairie.example/api/v2/artwork/poster", + posterThumbhash = "EPISODE", + updatedAtMs = 1L, + ) + + @Test + fun savedArtworkSurvivesTheRoomRow() { + val withArtwork = sidecar.copy( + offlinePosterPath = "/data/download-assets/srv/prof/42/artwork/poster", + offlineSeriesPosterPath = "/data/download-assets/srv/prof/42/artwork/series_poster", + seriesPosterThumbhash = "SERIES", + ) + + val entity = withArtwork.toEntity("srv", "prof") + assertEquals("/data/download-assets/srv/prof/42/artwork/poster", entity.offlinePosterPath) + assertEquals("/data/download-assets/srv/prof/42/artwork/series_poster", entity.offlineSeriesPosterPath) + assertEquals("SERIES", entity.seriesPosterThumbhash) + assertEquals(withArtwork, entity.toSidecar()) + } + + @Test + fun rowsWithoutArtworkStillLoad() { + val restored = sidecar.toEntity("srv", "prof").toSidecar() + + assertNull(restored.offlinePosterPath) + assertNull(restored.offlineSeriesPosterPath) + assertNull(restored.seriesPosterThumbhash) + } + + @Test + fun movieTilesUseTheSavedPosterThenTheCatalogUrl() { + val movie = sidecar.copy(mediaType = "movie", posterThumbhash = "MOVIE", offlinePosterPath = "/art/poster") + + assertEquals(DownloadTileArtwork("file:///art/poster", "MOVIE", isEpisodeStill = false), movie.tileArtwork { true }) + // A file removed behind the app's back falls back to the catalog URL. + assertEquals( + DownloadTileArtwork("https://prairie.example/api/v2/artwork/poster", "MOVIE"), + movie.tileArtwork { false }, + ) + } + + @Test + fun episodeTilesUseTheSavedSeriesPosterButNeverTheEpisodeStill() { + val episode = sidecar.copy( + mediaType = "tv", + posterThumbhash = null, + offlinePosterPath = "/art/poster", + offlineSeriesPosterPath = "/art/series_poster", + seriesPosterThumbhash = "SERIES", + ) + + assertEquals(DownloadTileArtwork("file:///art/series_poster", "SERIES", isEpisodeStill = false), episode.tileArtwork { true }) + // The saved still exists, but the tile falls back to the catalog series poster. + assertEquals( + DownloadTileArtwork("https://prairie.example/api/v2/artwork/poster", "SERIES"), + episode.tileArtwork { it == "/art/poster" }, + ) + // Rows from before mediaType was stored count as episodes by series title. + val legacy = episode.copy(mediaType = "", seriesTitle = "Show", offlineSeriesPosterPath = null) + assertEquals( + DownloadTileArtwork("https://prairie.example/api/v2/artwork/poster", "SERIES"), + legacy.tileArtwork { true }, + ) + } + + @Test + fun groupTilesUseAnyMembersSavedSeriesPoster() { + val first = sidecar.copy(mediaType = "tv", offlinePosterPath = "/art/1/poster") + val second = sidecar.copy( + mediaType = "tv", + offlinePosterPath = "/art/2/poster", + offlineSeriesPosterPath = "/art/2/series_poster", + seriesPosterThumbhash = "SERIES", + ) + + assertEquals( + DownloadTileArtwork("file:///art/2/series_poster", "SERIES", isEpisodeStill = false), + listOf(first, second).tileArtwork { true }, + ) + // No series poster on disk: the catalog URL, not an episode still. + assertEquals( + DownloadTileArtwork("https://prairie.example/api/v2/artwork/poster", "SERIES"), + listOf(first, second).tileArtwork { !it.endsWith("series_poster") }, + ) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/DownloadSlotLocksTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/DownloadSlotLocksTest.kt new file mode 100644 index 0000000000..cbed0697a4 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/DownloadSlotLocksTest.kt @@ -0,0 +1,26 @@ +package org.prairieserver.prairie.common.downloads + +import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class DownloadSlotLocksTest { + + @Test + fun everyCallerSharesOneLockPerSlot() { + val capture = DownloadSlotLocks.of("server", "profile", 7) + assertTrue(capture.tryLock()) + try { + // A worker or view model looking the slot up separately gets the same lock. + assertSame(capture, DownloadSlotLocks.of("server", "profile", 7)) + assertFalse(DownloadSlotLocks.of("server", "profile", 7).tryLock()) + + val otherSlot = DownloadSlotLocks.of("server", "profile", 8) + assertTrue(otherSlot.tryLock()) + otherSlot.unlock() + } finally { + capture.unlock() + } + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/OfflineTrackAssetFetcherTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/OfflineTrackAssetFetcherTest.kt index a562eb4a18..8b18d669cc 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/OfflineTrackAssetFetcherTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/OfflineTrackAssetFetcherTest.kt @@ -5,12 +5,15 @@ import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respond import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.defaultRequest +import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf import kotlinx.coroutines.runBlocking import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder import java.io.File +import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull @@ -36,15 +39,42 @@ class OfflineTrackAssetFetcherTest { {"language": "eng", "format": "sup", "forced": true, "fetch_url": "/api/v2/downloads/dl_1/subtitles/embedded:4"}, {"language": "ger", "format": "sub", "fetch_url": "/api/v2/downloads/dl_1/subtitles/external:1"}, {"language": "spa", "format": "srt", "fetch_url": "https://elsewhere.example/sub.srt"} - ] + ], + "poster_thumbhash": "EPISODE", + "series_poster_thumbhash": "SERIES", + "artwork_urls": { + "poster": "/api/v2/downloads/dl_1/artwork/poster", + "backdrop": "/api/v2/downloads/dl_1/artwork/backdrop", + "series_poster": "/api/v2/downloads/dl_1/artwork/series_poster" + } + } + """.trimIndent() + + /** A movie manifest, or an episode manifest from a server without series posters. */ + private val movieManifest = """ + { + "download_id": "dl_1", + "poster_thumbhash": "MOVIE", + "artwork_urls": {"poster": "/api/v2/downloads/dl_1/artwork/poster"} } """.trimIndent() + private val posterBytes = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0x01) + private val seriesPosterBytes = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47) + private val requested = mutableListOf() private fun client( manifestStatus: HttpStatusCode = HttpStatusCode.OK, transientManifestFailures: Int = 0, + manifestBody: String = manifest, + artwork: (kind: String) -> Pair = { kind -> + when (kind) { + "poster" -> HttpStatusCode.OK to posterBytes + "series_poster" -> HttpStatusCode.OK to seriesPosterBytes + else -> HttpStatusCode.NotFound to ByteArray(0) + } + }, ) = HttpClient( MockEngine { request -> val path = request.url.encodedPath @@ -52,7 +82,11 @@ class OfflineTrackAssetFetcherTest { when { path.endsWith("/manifest") && requested.count { it.endsWith("/manifest") } <= transientManifestFailures -> respond("", HttpStatusCode.ServiceUnavailable) - path.endsWith("/manifest") -> respond(manifest, manifestStatus) + path.endsWith("/manifest") -> respond(manifestBody, manifestStatus) + path.contains("/artwork/") -> { + val (status, bytes) = artwork(path.substringAfterLast('/')) + respond(bytes, status) + } path.endsWith("/subtitles/embedded:2") -> respond("[Script Info]\nTitle: x\n") // The server failed to read the external sidecar. path.endsWith("/subtitles/external:0") -> respond("", HttpStatusCode.NotFound) @@ -65,13 +99,127 @@ class OfflineTrackAssetFetcherTest { defaultRequest { url("https://silo.example/") } } + /** A server whose stored subtitle 7 is retimed between [revision]s; it serves [cue] for it. */ + private class RetimingServer { + var revision = "r1" + var cue = "00:00:42,148 --> 00:00:44,517" + val client = HttpClient( + MockEngine { request -> + val path = request.url.encodedPath + when { + path.endsWith("/manifest") -> respond( + """{"subtitles": [{"language": "en", "format": "srt", + "fetch_url": "/api/v2/downloads/dl_1/subtitles/downloaded:7", "revision": "$revision"}]}""", + HttpStatusCode.OK, + ) + path.endsWith("/subtitles/downloaded:7") -> respond("1\n$cue\nFor a moment, everyone was silent.\n") + else -> respond("", HttpStatusCode.NotFound) + } + }, + ) { + install(HttpTimeout) + defaultRequest { url("https://prairie.example/") } + } + } + + /** + * A sidecar next to the media is served with its timing correction + * applied. When the server retimes it after the download, the manifest + * lists it at a new revision and a refresh replaces the saved file. + */ + @Test + fun refreshesASavedExternalSidecarTheServerRetimed() = runBlocking { + var revision = "r1" + var cue = "00:00:42,148 --> 00:00:44,517" + val subtitleFetches = mutableListOf() + val http = HttpClient( + MockEngine { request -> + val path = request.url.encodedPath + when { + path.endsWith("/manifest") -> respond( + """{"subtitles": [{"language": "en", "format": "srt", "external": true, + "fetch_url": "/api/v2/downloads/dl_1/subtitles/external:0", "revision": "$revision"}]}""", + HttpStatusCode.OK, + ) + path.endsWith("/subtitles/external:0") -> { + subtitleFetches += revision + respond("1\n$cue\nFor a moment, everyone was silent.\n") + } + else -> respond("", HttpStatusCode.NotFound) + } + }, + ) { + install(HttpTimeout) + defaultRequest { url("https://prairie.example/") } + } + val fetcher = OfflineTrackAssetFetcher(http, DownloadStorage(tmp.newFolder("filesDir"))) + val saved = assertNotNull(fetcher.fetch("dl_1", "srv", "prof", 42) {}).tracks + assertEquals("r1", saved.subtitles.single().revision) + + // Nothing changed on the server: nothing is fetched. + assertNull(fetcher.stageSubtitleRefresh("dl_1", saved) {}) + assertEquals(listOf("r1"), subtitleFetches) + + revision = "r2" + cue = "00:00:39,138 --> 00:00:41,507" + val staged = assertNotNull(fetcher.stageSubtitleRefresh("dl_1", saved) {}) + val refreshed = assertNotNull(staged.publish()) + staged.discard() + assertEquals("r2", refreshed.subtitles.single().revision) + assertEquals(saved.subtitles.single().path, refreshed.subtitles.single().path) + assertTrue(File(refreshed.subtitles.single().path).readText().contains(cue)) + http.close() + } + + @Test + fun aRetimedSidecarIsStagedAndReplacesTheSavedCopyOnlyWhenPublished() = runBlocking { + val server = RetimingServer() + val fetcher = OfflineTrackAssetFetcher(server.client, DownloadStorage(tmp.newFolder("filesDir"))) + val saved = assertNotNull(fetcher.fetch("dl_1", "srv", "prof", 42) {}).tracks + val file = File(saved.subtitles.single().path) + assertEquals("r1", saved.subtitles.single().revision) + assertNull(fetcher.stageSubtitleRefresh("dl_1", saved) {}, "nothing changed on the server") + + server.revision = "r2" + server.cue = "00:00:39,138 --> 00:00:41,507" + val staged = assertNotNull(fetcher.stageSubtitleRefresh("dl_1", saved) {}) + // Staging leaves the saved copy alone. + assertTrue(file.readText().contains("00:00:42,148")) + + val refreshed = assertNotNull(staged.publish()) + staged.discard() + assertEquals("r2", refreshed.subtitles.single().revision) + assertTrue(file.readText().contains("00:00:39,138")) + assertEquals(listOf(file.name), file.parentFile!!.list()!!.toList()) + server.client.close() + } + + @Test + fun aSavedCopyRewrittenAfterStagingKeepsItsNewContents() = runBlocking { + val server = RetimingServer() + val fetcher = OfflineTrackAssetFetcher(server.client, DownloadStorage(tmp.newFolder("filesDir"))) + val saved = assertNotNull(fetcher.fetch("dl_1", "srv", "prof", 42) {}).tracks + val file = File(saved.subtitles.single().path) + + server.revision = "r2" + val staged = assertNotNull(fetcher.stageSubtitleRefresh("dl_1", saved) {}) + // A new capture for the same file rewrites the copy before publishing. + file.writeText("1\n00:00:01,000 --> 00:00:02,000\nA new capture.\n") + + assertNull(staged.publish()) + staged.discard() + assertTrue(file.readText().contains("A new capture.")) + assertEquals(listOf(file.name), file.parentFile!!.list()!!.toList()) + server.client.close() + } + @Test fun capturesAudioTracksAndSavesEveryFetchableSidecar() = runBlocking { val storage = DownloadStorage(tmp.newFolder("filesDir")) val info = assertNotNull( OfflineTrackAssetFetcher(client(), storage).fetch("dl_1", "srv", "prof", 42) {}, - ) + ).tracks assertEquals(listOf("eng", "jpn"), info.audioTracks.map { it.language }) assertEquals(1, info.defaultAudioPosition()) @@ -91,13 +239,16 @@ class OfflineTrackAssetFetcherTest { @Test fun deletingTheDownloadRemovesItsSidecars() = runBlocking { val storage = DownloadStorage(tmp.newFolder("filesDir")) - val info = assertNotNull( + val assets = assertNotNull( OfflineTrackAssetFetcher(client(), storage).fetch("dl_1", "srv", "prof", 42) {}, ) + val artworkDirectory = assertNotNull(storage.offlineArtworkDirectory("srv", "prof", 42)) + assertTrue(artworkDirectory.isDirectory) storage.delete("srv", "prof", 42) - assertTrue(info.subtitles.none { File(it.path).exists() }) + assertTrue(assets.tracks.subtitles.none { File(it.path).exists() }) + assertFalse(artworkDirectory.exists()) } @Test @@ -117,12 +268,151 @@ class OfflineTrackAssetFetcherTest { val info = assertNotNull( OfflineTrackAssetFetcher(client(transientManifestFailures = 2), storage, manifestRetryDelayMs = 0) .fetch("dl_1", "srv", "prof", 42) {}, - ) + ).tracks assertEquals(3, requested.count { it.endsWith("/manifest") }) assertEquals(2, info.audioTracks.size) } + @Test + fun savesThePosterAndSeriesPosterFromTheSameManifest() = runBlocking { + val storage = DownloadStorage(tmp.newFolder("filesDir")) + + val artwork = assertNotNull( + OfflineTrackAssetFetcher(client(), storage).fetch("dl_1", "srv", "prof", 42) {}, + ).artwork + + val poster = File(assertNotNull(artwork.posterPath)) + val seriesPoster = File(assertNotNull(artwork.seriesPosterPath)) + assertContentEquals(posterBytes, poster.readBytes()) + assertContentEquals(seriesPosterBytes, seriesPoster.readBytes()) + val directory = storage.offlineArtworkDirectory("srv", "prof", 42) + assertEquals(directory, poster.parentFile) + assertEquals(directory, seriesPoster.parentFile) + assertTrue(directory!!.listFiles()!!.none { it.name.endsWith(".part") }) + assertEquals("EPISODE", artwork.posterThumbhash) + assertEquals("SERIES", artwork.seriesPosterThumbhash) + // One manifest request serves tracks and artwork; the backdrop is not fetched. + assertEquals(1, requested.count { it.endsWith("/manifest") }) + assertEquals( + listOf("/api/v2/downloads/dl_1/artwork/poster", "/api/v2/downloads/dl_1/artwork/series_poster"), + requested.filter { it.contains("/artwork/") }, + ) + } + + @Test + fun aMissingSeriesPosterIsSkipped() = runBlocking { + val storage = DownloadStorage(tmp.newFolder("filesDir")) + + val artwork = assertNotNull( + OfflineTrackAssetFetcher(client(manifestBody = movieManifest), storage).fetch("dl_1", "srv", "prof", 42) {}, + ).artwork + + assertNotNull(artwork.posterPath) + assertNull(artwork.seriesPosterPath) + assertEquals("MOVIE", artwork.posterThumbhash) + assertNull(artwork.seriesPosterThumbhash) + assertEquals(listOf("/api/v2/downloads/dl_1/artwork/poster"), requested.filter { it.contains("/artwork/") }) + } + + @Test + fun aFailedArtworkFetchKeepsTheRestOfTheCapture() = runBlocking { + val storage = DownloadStorage(tmp.newFolder("filesDir")) + val client = client( + artwork = { kind -> + if (kind == "poster") HttpStatusCode.NotFound to ByteArray(0) else HttpStatusCode.OK to seriesPosterBytes + }, + ) + + val assets = assertNotNull(OfflineTrackAssetFetcher(client, storage).fetch("dl_1", "srv", "prof", 42) {}) + + assertNull(assets.artwork.posterPath) + assertNotNull(assets.artwork.seriesPosterPath) + assertEquals(2, assets.tracks.audioTracks.size) + assertEquals(2, assets.tracks.subtitles.size) + val directory = storage.offlineArtworkDirectory("srv", "prof", 42)!! + assertFalse(File(directory, "poster").exists()) + assertFalse(File(directory, "poster.part").exists()) + } + + @Test + fun oversizedArtworkIsDiscarded() = runBlocking { + val storage = DownloadStorage(tmp.newFolder("filesDir")) + val tooLarge = ByteArray((OfflineTrackAssetFetcher.MAX_ARTWORK_BYTES + 1).toInt()) + val client = client( + manifestBody = movieManifest, + artwork = { HttpStatusCode.OK to tooLarge }, + ) + + val artwork = assertNotNull(OfflineTrackAssetFetcher(client, storage).fetch("dl_1", "srv", "prof", 42) {}).artwork + + assertNull(artwork.posterPath) + val directory = storage.offlineArtworkDirectory("srv", "prof", 42)!! + assertTrue(directory.listFiles().orEmpty().isEmpty()) + } + + @Test + fun foreignArtworkUrlsAreNotFollowed() = runBlocking { + val storage = DownloadStorage(tmp.newFolder("filesDir")) + val foreign = """ + {"artwork_urls": { + "poster": "https://elsewhere.example/poster.jpg", + "series_poster": "/api/v2/downloads/dl_1/artwork/poster" + }} + """.trimIndent() + + val artwork = assertNotNull( + OfflineTrackAssetFetcher(client(manifestBody = foreign), storage).fetch("dl_1", "srv", "prof", 42) {}, + ).artwork + + assertNull(artwork.posterPath) + // A series_poster URL that names a different kind is rejected too. + assertNull(artwork.seriesPosterPath) + assertTrue(requested.none { it.contains("/artwork/") || it.contains("poster.jpg") }) + } + + @Test + fun artworkRedirectsAreNotFollowed() = runBlocking { + val storage = DownloadStorage(tmp.newFolder("filesDir")) + val client = HttpClient( + MockEngine { request -> + val path = request.url.encodedPath + requested += path + when { + path.endsWith("/manifest") -> respond(movieManifest) + path.contains("/artwork/") -> respond( + "", + HttpStatusCode.Found, + headersOf(HttpHeaders.Location, "https://elsewhere.example/poster.jpg"), + ) + else -> respond(posterBytes) + } + }, + ) { + install(HttpTimeout) + defaultRequest { url("https://prairie.example/") } + } + + val artwork = assertNotNull(OfflineTrackAssetFetcher(client, storage).fetch("dl_1", "srv", "prof", 42) {}).artwork + + assertNull(artwork.posterPath) + assertTrue(requested.none { it.contains("poster.jpg") }) + } + + @Test + fun aRecaptureReplacesThePreviousArtwork() = runBlocking { + val storage = DownloadStorage(tmp.newFolder("filesDir")) + OfflineTrackAssetFetcher(client(), storage).fetch("dl_1", "srv", "prof", 42) {} + + val artwork = assertNotNull( + OfflineTrackAssetFetcher(client(manifestBody = movieManifest), storage).fetch("dl_1", "srv", "prof", 42) {}, + ).artwork + + assertNull(artwork.seriesPosterPath) + val directory = storage.offlineArtworkDirectory("srv", "prof", 42)!! + assertEquals(listOf("poster"), directory.listFiles()!!.map { it.name }) + } + @Test fun onlyVideoDownloadsCaptureTracks() { assertTrue(OfflineTrackAssetFetcher.appliesTo("movie")) diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/lan/PrairieCastTlsTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/lan/PrairieCastTlsTest.kt index f1401a8845..c428f617b7 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/lan/PrairieCastTlsTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/lan/PrairieCastTlsTest.kt @@ -1,11 +1,20 @@ package org.prairieserver.prairie.common.lan import java.net.ServerSocket +import java.net.Socket +import java.security.SecureRandom +import java.util.Collections import java.util.concurrent.CompletableFuture import java.util.concurrent.TimeUnit import kotlin.concurrent.thread import kotlin.test.Test import kotlin.test.assertContentEquals +import org.bouncycastle.tls.BasicTlsPSKIdentity +import org.bouncycastle.tls.CipherSuite +import org.bouncycastle.tls.PSKTlsClient +import org.bouncycastle.tls.ProtocolVersion +import org.bouncycastle.tls.TlsClientProtocol +import org.bouncycastle.tls.crypto.impl.bc.BcTlsCrypto class PrairieCastTlsTest { @Test @@ -37,6 +46,46 @@ class PrairieCastTlsTest { } } + /** + * BouncyCastle zeroes the PSK buffer after a TLS 1.2 handshake. The server + * once handed it a shared key array, so one TLS 1.2 client wiped the key + * for the process and every later handshake failed. + */ + @Test + fun tls12ClientCannotWipeTheReceiversKey() { + val listener = ServerSocket(0) + val servers = Collections.synchronizedList(mutableListOf()) + val worker = thread(isDaemon = true, name = "silocast-tls-test-server") { + repeat(3) { runCatching { servers += PrairieCastTls.accept(listener.accept()) } } + } + try { + repeat(2) { + val socket = Socket("127.0.0.1", listener.localPort) + val protocol = TlsClientProtocol(socket.getInputStream(), socket.getOutputStream()) + // Offers only TLS 1.2, so a completed handshake is a TLS 1.2 one. + protocol.connect(Tls12PskClient(BcTlsCrypto(SecureRandom()))) + protocol.close() + socket.close() + } + // An ordinary (TLS 1.3) client still gets through afterwards. + PrairieCastTls.connect("127.0.0.1", listener.localPort, 5_000).close() + } finally { + // The last server-side handshake can still be finishing. + worker.join(5_000) + listener.close() + synchronized(servers) { servers.toList() }.forEach { it.close() } + } + } + + private class Tls12PskClient(crypto: BcTlsCrypto) : PSKTlsClient( + crypto, + BasicTlsPSKIdentity("prairie-cast".toByteArray(), "prairie-cast-v1".toByteArray()), + ) { + override fun getSupportedCipherSuites(): IntArray = intArrayOf(CipherSuite.TLS_PSK_WITH_AES_128_GCM_SHA256) + + override fun getSupportedVersions(): Array = arrayOf(ProtocolVersion.TLSv12) + } + private fun java.io.InputStream.readBytes(count: Int): ByteArray { val result = ByteArray(count) var offset = 0 diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/network/EventsSocketAccessChangeTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/network/EventsSocketAccessChangeTest.kt new file mode 100644 index 0000000000..86a337b403 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/network/EventsSocketAccessChangeTest.kt @@ -0,0 +1,123 @@ +package org.prairieserver.prairie.common.network + +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.prairieserver.prairie.network.AccessChangeSignals +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.CleartextOriginConsent +import org.prairieserver.prairie.network.TokenManager +import org.prairieserver.prairie.network.TokenManagerImpl +import org.prairieserver.prairie.network.apiv2.ApiV2Gate +import org.prairieserver.prairie.network.apiv2.EventsSocketV2Api +import org.prairieserver.prairie.network.canonicalHttpOrigin +import org.prairieserver.prairie.network.createPrairieClient +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The events socket's access-change notice (a frame, then close code 4001) + * reaches [AccessChangeSignals] once per connection, and a server that never + * sends it leaves the signals untouched. + */ +class EventsSocketAccessChangeTest { + + @Test + fun `access_changed frame and 4001 close report one access change`() = runBlocking { + val frames = connectOnce { socket -> + socket.send("""{"type":"access_changed"}""") + socket.close(4001, "access_changed") + } + + assertEquals(1L, frames.signals.revision.value) + assertTrue(frames.received.any { it.contains("access_changed") }) + } + + @Test + fun `a 4001 close alone reports the access change`() = runBlocking { + val frames = connectOnce { socket -> socket.close(4001, "access_changed") } + + assertEquals(1L, frames.signals.revision.value) + } + + @Test + fun `an ordinary close reports nothing`() = runBlocking { + val frames = connectOnce { socket -> + socket.send("""{"type":"subscribed","channel":"catalog"}""") + socket.close(1000, "bye") + } + + assertEquals(0L, frames.signals.revision.value) + } + + private class Outcome(val signals: AccessChangeSignals, val received: List) + + /** [afterSubscribe] runs once the client's subscribe frame arrives, as the server's recheck would. */ + private suspend fun connectOnce(afterSubscribe: (WebSocket) -> Unit): Outcome { + val server = MockWebServer() + server.enqueue( + MockResponse() + .setResponseCode(200) + .addHeader("Content-Type", "application/json") + .setBody("""{"ticket":"opaque-proof","expires_in":20,"max_connection_seconds":300,"protocol":"prairie.events.v2"}"""), + ) + server.enqueue( + MockResponse() + .withWebSocketUpgrade( + object : WebSocketListener() { + override fun onMessage(webSocket: WebSocket, text: String) = afterSubscribe(webSocket) + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + webSocket.close(code, reason) + } + }, + ) + .addHeader("Sec-WebSocket-Protocol", "prairie.events.v2"), + ) + server.start() + val signals = AccessChangeSignals() + val tokens = scopedTokens(server.url("/").toString()) + val approvedOrigin = canonicalHttpOrigin(server.url("/").toString()) + val httpClient = createPrairieClient( + tokenManager = tokens, + cleartextOriginConsent = object : CleartextOriginConsent { + override suspend fun isApproved(origin: String): Boolean = origin == approvedOrigin + }, + accessChangeSignals = signals, + ) + try { + val received = withTimeout(10_000) { + EventsSocketV2Api(httpClient, tokens, ApiV2Gate.Unrestricted, signals) + .frames(listOf("catalog")) + .toList() + } + return Outcome(signals, received) + } finally { + httpClient.close() + server.shutdown() + } + } + + private suspend fun scopedTokens(serverUrl: String): TokenManager { + val delegate = TokenManagerImpl().apply { + setServerUrl(serverUrl) + saveTokens("ACCESS", "refresh", 3_600) + setProfileIdentity("profile-1", "PROFILE_TOKEN") + } + val active = AuthScopeSnapshot( + serverId = "server-1", + profileId = "profile-1", + serverUrl = serverUrl, + profileToken = "PROFILE_TOKEN", + ) + return object : TokenManager by delegate { + override suspend fun snapshotCurrentScope(): AuthScopeSnapshot = active + override suspend fun getAccessTokenForScope(scope: AuthScopeSnapshot): String? = + "ACCESS".takeIf { scope == active } + } + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/network/WatchPartyRequestReplayTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/network/WatchPartyRequestReplayTest.kt new file mode 100644 index 0000000000..7b44b087d4 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/network/WatchPartyRequestReplayTest.kt @@ -0,0 +1,78 @@ +package org.prairieserver.prairie.common.network + +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.CleartextOriginConsent +import org.prairieserver.prairie.network.TokenManager +import org.prairieserver.prairie.network.TokenManagerImpl +import org.prairieserver.prairie.network.api.DefaultWatchTogetherApi +import org.prairieserver.prairie.network.apiv2.ApiV2Gate +import org.prairieserver.prairie.network.canonicalHttpOrigin +import org.prairieserver.prairie.network.createPrairieClient +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +/** + * Start, select, and promote are non-retryable: a replay after another + * selection could start the wrong item. This pins what the real client does + * when the connection drops after the request was sent. + */ +class WatchPartyRequestReplayTest { + + private class ScopeTokens(private val scope: AuthScopeSnapshot, private val delegate: TokenManager = TokenManagerImpl()) : + TokenManager by delegate { + override suspend fun snapshotCurrentScope(): AuthScopeSnapshot = scope + override suspend fun getAccessTokenForScope(scope: AuthScopeSnapshot): String? = "ACCESS".takeIf { scope == this.scope } + } + + @Test + fun `a start whose pooled connection drops after sending is not resent`() = runBlocking { + val server = MockWebServer() + val room = """{"room":{"room_id":"room-1","phase":"lobby"},"room_access_token":"proof"}""" + // Warm the connection pool so the start reuses a kept-alive connection, + // the case where OkHttp treats a failure as a stale connection. + server.enqueue(MockResponse().setResponseCode(200).addHeader("Content-Type", "application/json").setBody(room)) + server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST)) + server.enqueue( + MockResponse().setResponseCode(200).addHeader("Content-Type", "application/json") + .setBody("""{"room":{"room_id":"room-1","phase":"playing"},"room_access_token":"proof"}"""), + ) + server.start() + val scope = AuthScopeSnapshot( + serverId = "server", + profileId = "profile", + serverUrl = server.url("/").toString().trimEnd('/'), + profileToken = null, + identityGeneration = 1, + credentialEpoch = 1, + ) + val serverOrigin = canonicalHttpOrigin(server.url("/").toString()) + val consent = object : CleartextOriginConsent { + override suspend fun isApproved(origin: String): Boolean = origin == serverOrigin + } + val client = createPrairieClient(tokenManager = ScopeTokens(scope), cleartextOriginConsent = consent) + val nonReplaying = createPrairieClient( + tokenManager = ScopeTokens(scope), + cleartextOriginConsent = consent, + retryOnConnectionFailure = false, + ) + try { + val api = DefaultWatchTogetherApi(client, ApiV2Gate.Unrestricted, nonReplayingClient = nonReplaying) + assertIs>(api.getRoom("room-1", "proof", scope)) + + val result = api.startPlayback("room-1", scope) + + assertIs(result) + assertEquals(2, server.requestCount, "the non-retryable start was replayed") + } finally { + client.close() + nonReplaying.close() + server.shutdown() + } + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/pairing/CompanionPairingCoordinatorTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/pairing/CompanionPairingCoordinatorTest.kt index 20ea1c84d2..a0b0a3c9db 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/pairing/CompanionPairingCoordinatorTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/pairing/CompanionPairingCoordinatorTest.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.test.runTest import org.prairieserver.prairie.model.auth.DeviceLoginDecisionResponse import org.prairieserver.prairie.model.auth.DeviceLoginLookupResponse import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.pairing.PairingEndpoint import org.prairieserver.prairie.pairing.PairingMessage import org.prairieserver.prairie.pairing.PairingReceiverState import org.prairieserver.prairie.pairing.PairingServerStatus @@ -70,7 +71,10 @@ private class ScriptedPhoneTransport : PairingTransport { private class FakeCompanionApprover( private val matchCodes: Map = mapOf("ABCD-0001" to "MATCH-1"), private val onApprove: suspend (String) -> Unit = {}, + private val account: String? = "alice", ) : CompanionDeviceLoginApprover { + override suspend fun accountName(server: CompanionPairingServer): String? = account + val lookups = mutableListOf() val approvals = mutableListOf() val denials = mutableListOf() @@ -256,4 +260,179 @@ class CompanionPairingCoordinatorTest { coordinator.status.value, ) } + + @Test + fun signedOutTvGetsOnlyItsServerWithIdentityAndNoChooser() = runTest { + val transport = ScriptedPhoneTransport() + val home = CompanionPairingServer(id = "srv-1", url = "https://lib.example", displayName = "Home", isActive = true) + val other = CompanionPairingServer(id = "srv-2", url = "https://other.example", displayName = "Other") + val identities = mapOf("srv-1" to "S-HOME", "srv-2" to "S-OTHER") + val endpoints = listOf(PairingEndpoint.of("https://lib.example", PairingEndpoint.Kind.Public)) + val coordinator = CompanionPairingCoordinator( + serverStore = FakeCompanionServerStore(CompanionPairingServerSnapshot("srv-1", listOf(home, other))), + deviceLoginApprover = FakeCompanionApprover( + matchCodes = mapOf("ABCD-0001" to "MATCH-1"), + onApprove = { transport.signIn("https://other.example") }, + ), + transportFactory = { transport }, + identitySource = object : CompanionServerIdentitySource { + override suspend fun identity(server: CompanionPairingServer) = identities[server.id] + override suspend fun endpoints(server: CompanionPairingServer) = endpoints + }, + ) + val target = CompanionPairingTarget( + deviceId = "tv-1", name = "Den TV", host = "127.0.0.1", port = 9999, + state = "login", serverIdentity = "S-OTHER", + ) + assertEquals(other, coordinator.serverForSignedOutTv(target)) + + var chooserShown = false + val result = coordinator.pair(target, chooseServers = { chooserShown = true; it }) + + assertEquals(CompanionPairingResult.Completed(serverCount = 1), result) + assertEquals(false, chooserShown) + val push = transport.sent.filterIsInstance().single() + assertEquals("https://other.example", push.serverURL) + assertEquals("S-OTHER", push.serverIdentity) + assertEquals(endpoints, push.endpoints) + } + + @Test + fun signedOutTvForAServerThisPhoneLacksIsNotOffered() = runTest { + val coordinator = CompanionPairingCoordinator( + serverStore = FakeCompanionServerStore( + CompanionPairingServerSnapshot("srv-1", listOf(CompanionPairingServer("srv-1", "https://lib.example", "Home"))), + ), + deviceLoginApprover = FakeCompanionApprover(), + transportFactory = { error("must not connect") }, + identitySource = object : CompanionServerIdentitySource { + override suspend fun identity(server: CompanionPairingServer) = "S-HOME" + override suspend fun endpoints(server: CompanionPairingServer) = null + }, + ) + val target = CompanionPairingTarget("tv-1", "Den TV", "127.0.0.1", 9999, state = "login", serverIdentity = "S-ELSEWHERE") + assertEquals(null, coordinator.serverForSignedOutTv(target)) + assertIs(coordinator.pair(target)) + // st filtering: login without srv, and unknown states, are never offered. + assertEquals(false, target.copy(serverIdentity = null).isOfferable) + assertEquals(false, target.copy(state = "future").isOfferable) + assertEquals(true, target.copy(state = null).isOfferable) + assertEquals(true, target.copy(state = "setup").isOfferable) + } + + @Test + fun tvFailureCodeBecomesReadablePhoneCopy() = runTest { + val transport = object : PairingTransport { + private val inbound = Channel(Channel.UNLIMITED) + override val incoming: Flow = inbound.consumeAsFlow() + init { + inbound.trySend(PairingMessage.Hello("Den TV", "tv-1", PairingReceiverState.Setup, listOf(1))) + } + override suspend fun send(message: PairingMessage) { + if (message is PairingMessage.PushServer) { + inbound.send(PairingMessage.DeviceStarted(message.serverURL, "ABCD-0001", "MATCH-1")) + } + } + suspend fun fail(url: String) { + inbound.send(PairingMessage.ServerResult(url, PairingServerStatus.Failed, "unreachable")) + } + override fun close() { inbound.close() } + } + val server = CompanionPairingServer("srv-1", "https://lib.example", "Home") + val coordinator = CompanionPairingCoordinator( + serverStore = FakeCompanionServerStore(CompanionPairingServerSnapshot("srv-1", listOf(server))), + deviceLoginApprover = FakeCompanionApprover(onApprove = { transport.fail(server.url) }), + transportFactory = { transport }, + ) + val result = coordinator.pair(CompanionPairingTarget("tv-1", "Den TV", "127.0.0.1", 9999)) + assertEquals( + CompanionPairingResult.Failed("Den TV can't reach Home. Check the TV's network connection."), + result, + ) + } + + /** The card can't name the account approving would sign the TV in as: nothing is approved. */ + @Test + fun anUnnamedAccountIsNeverApproved() = runTest { + val transport = ScriptedPhoneTransport() + val server = CompanionPairingServer(id = "srv-1", url = "https://lib.example", displayName = "Home") + val approver = FakeCompanionApprover(account = null) + val coordinator = CompanionPairingCoordinator( + serverStore = FakeCompanionServerStore(CompanionPairingServerSnapshot("srv-1", listOf(server))), + deviceLoginApprover = approver, + transportFactory = { transport }, + ) + + val result = coordinator.pair(target = CompanionPairingTarget("tv-1", "Living Room", "127.0.0.1", 9999)) + + assertEquals( + CompanionPairingResult.Failed("Couldn't confirm which account would sign in on Living Room. Try again."), + result, + ) + assertTrue(approver.approvals.isEmpty()) + assertTrue(approver.denials.isEmpty()) + assertTrue(transport.sent.any { it is PairingMessage.Cancel }) + } + + /** A TV that refuses the push before starting a sign-in fails the pairing at once, with its reason. */ + @Test + fun tvRefusingThePushBeforeStartingFailsAtOnceWithItsReason() = runTest { + val result = pairWithTvThat { inbound, push -> + inbound.send(PairingMessage.ServerResult(push.serverURL, PairingServerStatus.Failed, "identity_mismatch")) + } + assertEquals( + CompanionPairingResult.Failed("Den TV reached a different server at that address. Nothing was signed in."), + result, + ) + } + + @Test + fun tvDecliningThePushFailsAtOnceInsteadOfSpinning() = runTest { + // The TV user picks "Don't allow": the TV sends `cancel` and hangs up. + val result = pairWithTvThat { inbound, _ -> + inbound.send(PairingMessage.Cancel(reason = "consent_denied")) + inbound.close() + } + assertEquals(CompanionPairingResult.Failed("Den TV didn't allow it."), result) + } + + @Test + fun tvHangingUpMidPushFailsAtOnce() = runTest { + val result = pairWithTvThat { inbound, _ -> inbound.close() } + assertEquals(CompanionPairingResult.Failed("Den TV closed the connection. Try again."), result) + } + + @Test + fun silentTvTimesOutIntoAFailureNotAStuckCard() = runTest { + val coordinator = arrayOfNulls(1) + val result = pairWithTvThat(onCoordinator = { coordinator[0] = it }) { _, _ -> } + assertEquals(CompanionPairingResult.Failed("Den TV stopped responding. Try again."), result) + assertIs(coordinator[0]?.status?.value) + } + + /** Pairs with a TV whose reaction to the pushed server is [onPush]. */ + private suspend fun pairWithTvThat( + onCoordinator: (CompanionPairingCoordinator) -> Unit = {}, + onPush: suspend (Channel, PairingMessage.PushServer) -> Unit, + ): CompanionPairingResult { + val transport = object : PairingTransport { + private val inbound = Channel(Channel.UNLIMITED) + override val incoming: Flow = inbound.consumeAsFlow() + init { + inbound.trySend(PairingMessage.Hello("Den TV", "tv-1", PairingReceiverState.Setup, listOf(1))) + } + override suspend fun send(message: PairingMessage) { + if (message is PairingMessage.PushServer) onPush(inbound, message) + } + override fun close() { inbound.close() } + } + val server = CompanionPairingServer("srv-1", "https://lib.example", "Home") + val coordinator = CompanionPairingCoordinator( + serverStore = FakeCompanionServerStore(CompanionPairingServerSnapshot("srv-1", listOf(server))), + deviceLoginApprover = FakeCompanionApprover(), + transportFactory = { transport }, + ) + onCoordinator(coordinator) + return coordinator.pair(CompanionPairingTarget("tv-1", "Den TV", "127.0.0.1", 9999)) + } } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/pairing/PairingReceiverTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/pairing/PairingReceiverTest.kt index 85a7d13b7f..0a8cce00e9 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/pairing/PairingReceiverTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/pairing/PairingReceiverTest.kt @@ -2,6 +2,9 @@ package org.prairieserver.prairie.common.pairing import org.prairieserver.prairie.model.auth.DeviceLoginStartResponse import org.prairieserver.prairie.model.auth.DeviceLoginPollResponse +import org.prairieserver.prairie.network.api.ServerIdentityProbe +import org.prairieserver.prairie.pairing.PairingEndpoint +import org.prairieserver.prairie.pairing.PairingFailureCode import org.prairieserver.prairie.pairing.PairingMessage import org.prairieserver.prairie.pairing.PairingReceiverState import org.prairieserver.prairie.pairing.PairingServerStatus @@ -16,6 +19,7 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.yield import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.test.assertIs @@ -55,6 +59,8 @@ private class FakeAuthPort : PairingAuthPort { ) val committedSessions = mutableListOf() + val verifiedIds = mutableListOf() + var failPersist = false override suspend fun persistApprovedSession( serverUrl: String, @@ -63,7 +69,10 @@ private class FakeAuthPort : PairingAuthPort { refreshToken: String, expiresIn: Long, expectedIdentity: org.prairieserver.prairie.network.AccountSessionExpectation?, + verifiedServerId: String?, ) { + if (failPersist) throw IllegalStateException("disk full") + verifiedIds += verifiedServerId committedSessions += CommittedSession( serverUrl = serverUrl, serverName = serverName, @@ -145,12 +154,13 @@ class PairingReceiverTest { auth: FakeAuthPort, login: FakeDeviceLogin, state: PairingReceiverState = PairingReceiverState.Setup, + identities: Map = emptyMap(), ) = PairingReceiver( authPort = auth, deviceLogin = login, identityProvider = { PairingDeviceIdentity(name = "Test TV", deviceId = "device-1") }, - receiverStateProvider = { state }, - ) + identityProbe = { url -> identities[url] ?: ServerIdentityProbe.Unreachable }, + ).also { it.advertisement = PairingAdvertisement(state) } @Test fun helloSentFirstWithCurrentState() = runTest { @@ -212,7 +222,11 @@ class PairingReceiverTest { val status = recv.status.value assertIs(status) assertEquals("Srv", status.serverName) + // People compare the TV's sign-in code, never the match words. + assertEquals("USER-CODE", status.userCode) + // The words ride along for the "Older phones show ..." fallback line. assertEquals("MATCH-42", status.matchCode) + assertFalse(status.automatic) // Approve → ServerResult(signedIn). login.approve() @@ -264,10 +278,12 @@ class PairingReceiverTest { val result = transport.sent.filterIsInstance().single() assertEquals(PairingServerStatus.Failed, result.status) - assertEquals("denied-by-user", result.error) + // A structured code, never human text (iOS reads unknown values as auth_failed). + assertEquals("denied", result.error) val failed = recv.status.value assertIs(failed) assertEquals("https://srv.test", failed.serverName) + assertEquals(PairingFailureCode.Denied, failed.code) assertEquals(emptyList(), auth.committedSessions) transport.deliver(PairingMessage.Done) @@ -340,4 +356,225 @@ class PairingReceiverTest { transport.deliver(PairingMessage.Done) job.join() } + + private suspend fun kotlinx.coroutines.test.TestScope.settle() = repeat(20) { yield() } + + @Test + fun newerPushSupersedesTheOneInFlight() = runTest { + val transport = FakeTransport() + val auth = FakeAuthPort() + val login = FakeDeviceLogin() + val recv = receiver(auth, login) + val job = launch { recv.run(transport) } + yield() + transport.deliver(PairingMessage.PushServer(serverURL = "https://one.test", serverName = "One")) + settle() + recv.allowPendingServer() + settle() + assertEquals("https://one.test", login.beganWith?.serverUrl) + + transport.deliver(PairingMessage.PushServer(serverURL = "https://two.test", serverName = "Two")) + settle() + assertEquals("https://two.test", login.beganWith?.serverUrl) + val started = transport.sent.filterIsInstance() + assertEquals(listOf("https://one.test", "https://two.test"), started.map { it.serverURL }) + login.approve() + settle() + assertEquals(listOf("https://two.test"), auth.committedSessions.map { it.serverUrl }) + transport.deliver(PairingMessage.Done) + job.join() + } + + @Test + fun pushedIdentityIsVerifiedAndRecorded() = runTest { + val transport = FakeTransport() + val auth = FakeAuthPort() + val login = FakeDeviceLogin() + val recv = receiver(auth, login, identities = mapOf("https://srv.test" to ServerIdentityProbe.Identity("S1"))) + val job = launch { recv.run(transport) } + yield() + transport.deliver(PairingMessage.PushServer("https://srv.test", "Srv", serverIdentity = "S1")) + settle() + recv.allowPendingServer() + settle() + login.approve() + settle() + assertEquals(listOf("S1"), auth.verifiedIds) + transport.deliver(PairingMessage.Done) + job.join() + } + + @Test + fun differentIdentityAtThePushedAddressFailsWithIdentityMismatch() = runTest { + val transport = FakeTransport() + val auth = FakeAuthPort() + val login = FakeDeviceLogin() + val recv = receiver(auth, login, identities = mapOf("https://srv.test" to ServerIdentityProbe.Identity("OTHER"))) + val job = launch { recv.run(transport) } + yield() + transport.deliver(PairingMessage.PushServer("https://srv.test", "Srv", serverIdentity = "S1")) + settle() + recv.allowPendingServer() + settle() + assertEquals(null, login.beganWith, "never signs in at a different deployment") + val result = transport.sent.filterIsInstance().single() + assertEquals("identity_mismatch", result.error) + transport.deliver(PairingMessage.Done) + job.join() + assertEquals(PairingReceiverStatus.Failed("Srv", PairingFailureCode.IdentityMismatch), recv.status.value) + } + + @Test + fun unreachableAddressOffersAVerifiedAlternateAndUsesItOnlyWhenChosen() = runTest { + val transport = FakeTransport() + val auth = FakeAuthPort() + val login = FakeDeviceLogin() + val recv = receiver( + auth, + login, + identities = mapOf("https://alt.test" to ServerIdentityProbe.Identity("S1")), + ) + val job = launch { recv.run(transport) } + yield() + transport.deliver( + PairingMessage.PushServer( + "https://srv.test", + "Srv", + serverIdentity = "S1", + endpoints = listOf( + PairingEndpoint.of("https://srv.test", PairingEndpoint.Kind.Provider, "tailscale", "Tailscale"), + PairingEndpoint.of("https://alt.test", PairingEndpoint.Kind.Public), + ), + ), + ) + settle() + recv.allowPendingServer() + settle() + assertEquals( + PairingReceiverStatus.Unreachable("https://srv.test", "Srv", "Tailscale", "https://alt.test"), + recv.status.value, + ) + assertEquals(null, login.beganWith) + recv.useAlternateAddress() + settle() + assertEquals("https://alt.test", login.beganWith?.serverUrl) + // Frames still name the pushed address. + assertEquals("https://srv.test", transport.sent.filterIsInstance().single().serverURL) + transport.deliver(PairingMessage.Cancel("done")) + job.join() + } + + /** The sign-in screen's code, lent to the receiver in login mode. */ + private class FakeCodeSource : NearbySignInCodeSource { + val outcome = kotlinx.coroutines.CompletableDeferred() + var askedFor = mutableListOf() + override suspend fun codeForNearbyApproval() = FakeDeviceLogin.START_RESPONSE + override suspend fun nearbyApprovalOutcome(deviceCode: String): NearbySignInOutcome { + askedFor += deviceCode + return outcome.await() + } + } + + @Test + fun signedOutTvLendsItsOwnCodeAndStartsNoSecondRequest() = runTest { + val transport = FakeTransport() + val auth = FakeAuthPort() + val login = FakeDeviceLogin() + val source = FakeCodeSource() + val recv = receiver(auth, login) + recv.advertisement = PairingAdvertisement.login(serverIdentity = "S1", serverUrl = "http://192.168.1.5:8090", source = source) + val job = launch { recv.run(transport) } + yield() + assertEquals(PairingReceiverState.Login, (transport.sent.first() as PairingMessage.Hello).state) + transport.deliver(PairingMessage.PushServer("https://media.example.com", "Home", serverIdentity = "S1")) + settle() + recv.allowPendingServer() + settle() + val started = transport.sent.filterIsInstance().single() + assertEquals("USER-CODE", started.userCode) + assertEquals("MATCH-42", started.matchCode) + assertEquals("https://media.example.com", started.serverURL) + assertEquals(null, login.beganWith, "no second device request") + assertEquals(listOf("dev-code"), source.askedFor) + + source.outcome.complete(NearbySignInOutcome.SignedIn) + settle() + assertTrue(auth.committedSessions.isEmpty(), "the sign-in screen saves the session") + val result = transport.sent.filterIsInstance().single() + assertEquals(PairingServerStatus.SignedIn, result.status) + assertEquals("https://media.example.com", result.serverURL) + transport.deliver(PairingMessage.Done) + job.join() + } + + @Test + fun signedOutTvRefusesAnotherServerOrAPushWithoutIdentity() = runTest { + for (identity in listOf("S2", null)) { + val transport = FakeTransport() + val source = FakeCodeSource() + val recv = receiver(FakeAuthPort(), FakeDeviceLogin(), identities = mapOf("https://media.example.com" to ServerIdentityProbe.Identity("S1"))) + recv.advertisement = PairingAdvertisement.login(serverIdentity = "S1", serverUrl = "http://192.168.1.5:8090", source = source) + val job = launch { recv.run(transport) } + yield() + transport.deliver(PairingMessage.PushServer("https://media.example.com", "Home", serverIdentity = identity)) + settle() + recv.allowPendingServer() + settle() + val refused = transport.sent.filterIsInstance().single() + assertEquals("identity_mismatch", refused.error, "identity=$identity") + assertTrue(transport.sent.none { it is PairingMessage.DeviceStarted }) + assertTrue(source.askedFor.isEmpty()) + transport.deliver(PairingMessage.Done) + job.join() + } + } + + @Test + fun aLoneFailureStaysOnScreenUntilClosedThenAdvertisingResumes() = runTest { + val transport = FakeTransport() + val source = FakeCodeSource() + val recv = receiver(FakeAuthPort(), FakeDeviceLogin()) + recv.advertisement = PairingAdvertisement.login(serverIdentity = "S1", serverUrl = "http://192.168.1.5:8090", source = source) + recv.setAdvertising() + val job = launch { recv.run(transport) } + yield() + transport.deliver(PairingMessage.PushServer("https://media.example.com", "Home", serverIdentity = "S1")) + settle() + recv.allowPendingServer() + settle() + source.outcome.complete(NearbySignInOutcome.Failed(PairingFailureCode.Expired)) + settle() + transport.deliver(PairingMessage.Done) + job.join() + // What the advertiser sees when the connection ends: it keeps a + // Failed status rather than replacing it with Advertising. + assertEquals(PairingReceiverStatus.Failed("Home", PairingFailureCode.Expired), recv.status.value) + assertEquals(false, resumesAdvertisingAfterConnection(recv.status.value)) + assertEquals(true, resumesAdvertisingAfterConnection(PairingReceiverStatus.Idle)) + + recv.cancelActiveSession() + assertEquals(PairingReceiverStatus.Advertising, recv.status.value) + } + + @Test + fun failedCommitIsReportedInsteadOfStalling() = runTest { + val transport = FakeTransport() + val auth = FakeAuthPort().apply { failPersist = true } + val login = FakeDeviceLogin() + val recv = receiver(auth, login) + val job = launch { recv.run(transport) } + yield() + transport.deliver(PairingMessage.PushServer("https://srv.test", "Srv")) + settle() + recv.allowPendingServer() + settle() + login.approve() + settle() + val result = transport.sent.filterIsInstance().single() + assertEquals(PairingServerStatus.Failed, result.status) + assertEquals("auth_failed", result.error) + assertEquals(PairingReceiverStatus.Failed("Srv", PairingFailureCode.AuthFailed), recv.status.value) + transport.deliver(PairingMessage.Done) + job.join() + } } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/HdrModeSelectionTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/HdrModeSelectionTest.kt deleted file mode 100644 index c5b1ac1000..0000000000 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/HdrModeSelectionTest.kt +++ /dev/null @@ -1,33 +0,0 @@ -package org.prairieserver.prairie.common.player - -import kotlin.test.Test -import kotlin.test.assertEquals - -class HdrModeSelectionTest { - @Test - fun prefersExactContentHdrTypeWhenDisplaySupportsIt() { - val result = HdrModeSelection.choose( - contentHdr = HdrType.HDR10, - displaySupported = setOf(HdrType.HDR10, HdrType.DOLBY_VISION), - ) - assertEquals(HdrType.HDR10, result) - } - - @Test - fun fallsBackToSdrWhenDisplayLacksContentHdrType() { - val result = HdrModeSelection.choose( - contentHdr = HdrType.DOLBY_VISION, - displaySupported = setOf(HdrType.HDR10), - ) - assertEquals(HdrType.SDR, result) - } - - @Test - fun sdrContentStaysSdr() { - val result = HdrModeSelection.choose( - contentHdr = HdrType.SDR, - displaySupported = setOf(HdrType.HDR10), - ) - assertEquals(HdrType.SDR, result) - } -} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackPublicationSettlementIntegrationTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackPublicationSettlementIntegrationTest.kt index 1e0ce30819..b7891be9fd 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackPublicationSettlementIntegrationTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackPublicationSettlementIntegrationTest.kt @@ -279,7 +279,7 @@ class PlaybackPublicationSettlementIntegrationTest { // A stale B cleanup arriving after the preflight is harmless. assertFalse(harness.manager.rollbackUnpublishedVideoSession("session-b")) - assertFalse(harness.lifecycle.rollbackUnpublishedActiveSession("session-b")) + assertFalse(harness.lifecycle.settlePendingPublicationIfCurrent("session-b", confirm = false) { true }) assertEquals("session-a", harness.manager.activeSessionIdForTest()) assertEquals("session-a", harness.lifecycle.activeSessionId()) diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycleTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycleTest.kt index f249adc2d2..c67960a3a0 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycleTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycleTest.kt @@ -53,22 +53,20 @@ import kotlin.test.fail */ @OptIn(ExperimentalCoroutinesApi::class) class PlaybackSessionLifecycleTest { - @Test fun `sequenced final flush stays part-local and pending stop retains old ownership for terminal retry`() = runTest { + @Test fun `sequenced final flush stays part-local and a failed stop still ends the session`() = runTest { val manager = object : FakeSessionManager() { override fun isSequenced(sessionId: String) = true } manager.stopResult = ApiResult.Error(0, "stop_pending", "pending") val lifecycle = newLifecycle(manager) lifecycle.adoptActiveSession(defaultStartParams(), makeSession("part")) lifecycle.reportPosition(30.0, 400.0, true, "part", 630.0, 1000.0) - assertFalse(lifecycle.stop("part")) - assertEquals(30.0, manager.lastProgressPosition) - manager.stopResult = ApiResult.Success(Unit) + // The journal keeps the stop and retries it before the next start. assertTrue(lifecycle.stop("part")) - assertEquals(2, manager.stopCallCount) + assertEquals(1, manager.stopCallCount) assertEquals(30.0, manager.lastProgressPosition) assertTrue(lifecycle.state.value is SessionState.Idle) } - @Test fun `sequenced authority outage keeps session and stop pending without legacy progress`() = runTest { + @Test fun `sequenced authority outage ends the session without legacy progress`() = runTest { val manager = object : FakeSessionManager() { override fun isSequenced(sessionId: String) = true }.apply { @@ -84,7 +82,7 @@ class PlaybackSessionLifecycleTest { assertTrue(lifecycle.state.value is SessionState.Active) assertEquals(0, health.callCount) lifecycle.stop(expectedSessionId = "negotiated") - assertTrue(lifecycle.state.value is SessionState.Failed) + assertTrue(lifecycle.state.value is SessionState.Idle) assertTrue(personal.syncCalls.isEmpty()) assertEquals(1, manager.stopCallCount) assertEquals(2, manager.progressCallCount) @@ -261,7 +259,7 @@ class PlaybackSessionLifecycleTest { ), ) - assertTrue(lifecycle.confirmActiveSessionPublication("sess-first")) + assertTrue(lifecycle.settlePendingPublicationIfCurrent("sess-first", confirm = true) { true }) assertEquals( "sess-first", (lifecycle.state.value as SessionState.Active).session.sessionId, @@ -304,100 +302,6 @@ class PlaybackSessionLifecycleTest { assertEquals(listOf("sess-old"), stopped) } - @Test - fun `external session finalization returns before reporting finishes and stops afterward`() = runTest { - val reportEntered = CompletableDeferred() - val releaseReport = CompletableDeferred() - val stopCompleted = CompletableDeferred() - val calls = mutableListOf() - val sessionMgr = object : FakeSessionManager() { - override suspend fun reportProgress( - sessionId: String, - position: Double, - isPaused: Boolean, - ): ApiResult { - calls += "report:$sessionId:$position:$isPaused" - reportEntered.complete(Unit) - releaseReport.await() - return ApiResult.Success(Unit) - } - - override suspend fun stopSession(sessionId: String): ApiResult { - calls += "stop:$sessionId" - stopCompleted.complete(Unit) - return ApiResult.Success(Unit) - } - } - val lifecycle = newLifecycle(sessionMgr, scope = backgroundScope) - - lifecycle.reportAndStopExternalSessionAsync( - sessionId = "audiobook-session", - positionSeconds = 42.25, - isPaused = true, - ) - - reportEntered.await() - assertEquals( - listOf("report:audiobook-session:42.25:true"), - calls, - "the non-blocking caller must return while progress reporting is suspended", - ) - - releaseReport.complete(Unit) - stopCompleted.await() - - assertEquals( - listOf( - "report:audiobook-session:42.25:true", - "stop:audiobook-session", - ), - calls, - ) - } - - @Test - fun `duplicate external session finalization is coalesced`() = runTest { - val reportEntered = CompletableDeferred() - val releaseReport = CompletableDeferred() - val stopCompleted = CompletableDeferred() - val sessionMgr = object : FakeSessionManager() { - override suspend fun reportProgress( - sessionId: String, - position: Double, - isPaused: Boolean, - ): ApiResult { - progressCallCount++ - reportEntered.complete(Unit) - releaseReport.await() - return ApiResult.Success(Unit) - } - - override suspend fun stopSession(sessionId: String): ApiResult { - val result = super.stopSession(sessionId) - stopCompleted.complete(Unit) - return result - } - } - val lifecycle = newLifecycle(sessionMgr, scope = backgroundScope) - - repeat(2) { - lifecycle.reportAndStopExternalSessionAsync( - sessionId = "audiobook-session", - positionSeconds = 42.25, - isPaused = true, - ) - } - - reportEntered.await() - assertEquals(1, sessionMgr.progressCallCount) - - releaseReport.complete(Unit) - stopCompleted.await() - - assertEquals(1, sessionMgr.progressCallCount) - assertEquals(1, sessionMgr.stopCallCount) - } - @Test fun `cancelled adoption closes the allocated server session`() = runTest { val oldStopEntered = CompletableDeferred() diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManagerStagedReplanTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManagerStagedReplanTest.kt index df0994a2b1..172a0caa6d 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManagerStagedReplanTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManagerStagedReplanTest.kt @@ -1148,7 +1148,6 @@ class PlaybackSessionManagerStagedReplanTest { ) assertFalse(harness.manager.rollbackUnpublishedVideoSession("s1")) assertFalse(harness.manager.confirmVideoSessionPublication("s1")) - assertTrue(harness.manager.rollbackCurrentPendingVideoPublication()) assertEquals( emptyMap(), harness.stoppedSessions.groupingBy { it }.eachCount(), diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerAppearanceTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerAppearanceTest.kt index 89175ecb23..d7c3678c7e 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerAppearanceTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerAppearanceTest.kt @@ -38,6 +38,17 @@ class SubtitleManagerAppearanceTest { assertEquals(0xFF000000.toInt(), style.edgeColor) } + @Test + fun textOpacityScalesOnlyTheForegroundAlpha() { + val half = captionStyleFor(SubtitleAppearance.DEFAULT.copy(fontColor = "#ff0000", textOpacity = 50)) + // 50% of 255 truncates to 0x7F; the RGB and the edge color are untouched. + assertEquals(0x7FFF0000, half.foregroundColor) + assertEquals(0xFF000000.toInt(), half.edgeColor) + + val faintest = captionStyleFor(SubtitleAppearance.DEFAULT.copy(textOpacity = 1)) + assertEquals(0x02FFFFFF, faintest.foregroundColor) + } + @Test fun bottomSubtitlesUseTheReferenceSafeMargin() { val method = SubtitleManager::class.java.getDeclaredMethod( diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/audio/HdmiSinkDtsSupportTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/audio/HdmiSinkDtsSupportTest.kt new file mode 100644 index 0000000000..4ebfd7be5a --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/audio/HdmiSinkDtsSupportTest.kt @@ -0,0 +1,62 @@ +package org.prairieserver.prairie.common.player.audio + +import android.media.AudioFormat +import androidx.media3.common.MimeTypes +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class HdmiSinkDtsSupportTest { + private val pcm = AudioFormat.ENCODING_PCM_16BIT + private val iec = AudioFormat.ENCODING_IEC61937 + + /** LG 2025 TV: Dolby codecs listed, no DTS — the onn 4K Pro silent-DTS case. */ + @Test + fun dolbyOnlySinkDoesNotDecodeDts() { + val lgC5 = intArrayOf(pcm, AudioFormat.ENCODING_AC3, AudioFormat.ENCODING_E_AC3, AudioFormat.ENCODING_E_AC3_JOC, iec) + assertFalse(HdmiSinkDtsSupport.sinkAdvertisesDts(listOf(lgC5))) + } + + @Test + fun sinkListingDtsDecodesDts() { + val avr = intArrayOf(pcm, AudioFormat.ENCODING_AC3, AudioFormat.ENCODING_DTS, AudioFormat.ENCODING_DTS_HD) + assertTrue(HdmiSinkDtsSupport.sinkAdvertisesDts(listOf(avr))) + } + + /** API 34 DTS-HD MA (29) arrives as a literal on older platforms too. */ + @Test + fun dtsHdMaLiteralCountsAsDts() { + assertTrue(HdmiSinkDtsSupport.sinkAdvertisesDts(listOf(intArrayOf(pcm, AudioFormat.ENCODING_AC3, 29)))) + } + + /** PCM and IEC 61937 name no codec, so the platform keeps the decision. */ + @Test + fun undescribedSinkKeepsPlatformBehavior() { + assertTrue(HdmiSinkDtsSupport.sinkAdvertisesDts(listOf(intArrayOf(pcm, iec)))) + assertTrue(HdmiSinkDtsSupport.sinkAdvertisesDts(listOf(intArrayOf()))) + assertTrue(HdmiSinkDtsSupport.sinkAdvertisesDts(emptyList())) + } + + @Test + fun anyUndescribedSinkKeepsPlatformBehavior() { + val dolbyOnly = intArrayOf(pcm, AudioFormat.ENCODING_AC3) + assertTrue(HdmiSinkDtsSupport.sinkAdvertisesDts(listOf(dolbyOnly, intArrayOf(pcm)))) + } + + @Test + fun dtsGoesToFfmpegOnlyWhenTheSinkLacksDtsAndFfmpegDecodesIt() { + val ffmpegDecodes = { _: String -> true } + assertTrue(HdmiSinkDtsSupport.shouldDecodeWithFfmpeg(MimeTypes.AUDIO_DTS_HD, ffmpegDecodes) { false }) + assertTrue(HdmiSinkDtsSupport.shouldDecodeWithFfmpeg(MimeTypes.AUDIO_DTS, ffmpegDecodes) { false }) + assertFalse(HdmiSinkDtsSupport.shouldDecodeWithFfmpeg(MimeTypes.AUDIO_DTS_HD, ffmpegDecodes) { true }) + assertFalse(HdmiSinkDtsSupport.shouldDecodeWithFfmpeg(MimeTypes.AUDIO_DTS_HD, { false }) { false }) + } + + @Test + fun nonDtsCodecsNeverGoToFfmpeg() { + val ffmpegDecodes = { _: String -> true } + assertFalse(HdmiSinkDtsSupport.shouldDecodeWithFfmpeg(MimeTypes.AUDIO_TRUEHD, ffmpegDecodes) { false }) + assertFalse(HdmiSinkDtsSupport.shouldDecodeWithFfmpeg(MimeTypes.AUDIO_E_AC3, ffmpegDecodes) { false }) + assertFalse(HdmiSinkDtsSupport.shouldDecodeWithFfmpeg(null, ffmpegDecodes) { false }) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/audio/PassthroughSuppressionRegistryTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/audio/PassthroughSuppressionRegistryTest.kt index 704696209b..bb0091dffc 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/audio/PassthroughSuppressionRegistryTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/audio/PassthroughSuppressionRegistryTest.kt @@ -90,4 +90,27 @@ class PassthroughSuppressionRegistryTest { assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, sink.getFormatSupport(trueHdEightChannel)) assertFalse(sink.supportsFormat(trueHdEightChannel)) } + + @Test + fun forceDecodeMarksOnlyMatchingEncodedFormatUnsupported() { + val delegate = Proxy.newProxyInstance( + AudioSink::class.java.classLoader, + arrayOf(AudioSink::class.java), + ) { _, method, _ -> + when (method.name) { + "getFormatSupport" -> AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY + else -> error("Unexpected delegate call: ${method.name}") + } + } as AudioSink + val sink = PassthroughSuppressingAudioSink(delegate) { format -> + format.sampleMimeType == MimeTypes.AUDIO_DTS_HD + } + val dtsHd = Format.Builder().setSampleMimeType(MimeTypes.AUDIO_DTS_HD).setChannelCount(8).build() + val pcm = Format.Builder().setSampleMimeType(MimeTypes.AUDIO_RAW).setChannelCount(8).build() + + PassthroughSuppressionRegistry.beginAttempt("force-decode-attempt") + assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, sink.getFormatSupport(dtsHd)) + assertEquals(AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY, sink.getFormatSupport(pcm)) + assertTrue(sink.supportsFormat(pcm)) + } } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/subtitle/SidecarSubtitleMediaSourceTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/subtitle/SidecarSubtitleMediaSourceTest.kt index 90d467b91a..5e06955d2d 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/subtitle/SidecarSubtitleMediaSourceTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/subtitle/SidecarSubtitleMediaSourceTest.kt @@ -97,6 +97,29 @@ class SidecarSubtitleMediaSourceTest { assertEquals(3_000_000L, delegate.continueLoadingCalls.last().playbackPositionUs) } + /** + * A remount that reselects the same subtitle disables the sidecar's track + * and enables it again before the cancelled load lands. Its continue + * request then finds no pending reset, which a prepared + * ProgressiveMediaPeriod asserts on (a player crash). The wrapper restarts + * the load from the playhead instead. + */ + @Test + fun restartsFromThePlayheadWhenACancelledLoadHasNoPendingReset() { + val delegate = FakePeriod().apply { requirePendingReset = true } + val floor = SidecarPlaybackFloor() + val period = NonGatingSidecarPeriod(delegate, floor) + val upstream = RecordingCallback() + period.prepare(upstream, 0L) + floor.set(42_000_000L) + + delegate.callback!!.onContinueLoadingRequested(delegate) + + assertEquals(42_000_000L, delegate.lastSeekUs) + assertEquals(2, delegate.continueLoadingCalls.size) + assertSame(period, upstream.continueLoadingRequestedFrom.single()) + } + @Test fun publishesTheLivePositionAndSeeksAsTheFloor() { val delegate = FakePeriod() @@ -114,6 +137,106 @@ class SidecarSubtitleMediaSourceTest { assertEquals(1_000_000L, delegate.lastSeekUs) } + /** + * A feature-length `.sup` read in one go kept every caption image in the + * sample queue and ran a phone with a 192 MB heap out of memory. With a + * lookahead, a delegate that parks far enough ahead stays parked until + * the playback ticks show the playhead has closed the gap. + */ + @Test + fun leavesADelegateParkedWhileItIsFurtherAheadThanTheLookahead() { + val delegate = FakePeriod().apply { bufferedUs = 300_000_000L } + val floor = SidecarPlaybackFloor() + val period = NonGatingSidecarPeriod(delegate, floor, maxLookaheadUs = 120_000_000L) + val upstream = RecordingCallback() + period.prepare(upstream, 0L) + delegate.callback!!.onPrepared(delegate) + + delegate.callback!!.onContinueLoadingRequested(delegate) + assertTrue(delegate.continueLoadingCalls.isEmpty()) + // The merge is still told, as before. + assertSame(period, upstream.continueLoadingRequestedFrom.single()) + + period.discardBuffer(150_000_000L, false) // 150 s still ahead: keep holding + assertTrue(delegate.continueLoadingCalls.isEmpty()) + + period.discardBuffer(190_000_000L, false) // 110 s ahead: read on + assertEquals(1, delegate.continueLoadingCalls.size) + assertEquals(190_000_000L, delegate.continueLoadingCalls.single().playbackPositionUs) + + period.discardBuffer(191_000_000L, false) // no longer held: no second kick + assertEquals(1, delegate.continueLoadingCalls.size) + } + + @Test + fun reevaluateBufferAlsoResumesAHeldDelegate() { + val delegate = FakePeriod().apply { bufferedUs = 300_000_000L } + val period = NonGatingSidecarPeriod(delegate, SidecarPlaybackFloor(), maxLookaheadUs = 120_000_000L) + period.prepare(RecordingCallback(), 0L) + delegate.callback!!.onPrepared(delegate) + delegate.callback!!.onContinueLoadingRequested(delegate) + + period.reevaluateBuffer(200_000_000L) + + assertEquals(1, delegate.continueLoadingCalls.size) + } + + @Test + fun aSeekAlwaysRestartsAHeldDelegateAndTheHoldStartsOver() { + val delegate = FakePeriod().apply { bufferedUs = 300_000_000L } + val period = NonGatingSidecarPeriod(delegate, SidecarPlaybackFloor(), maxLookaheadUs = 120_000_000L) + period.prepare(RecordingCallback(), 0L) + delegate.callback!!.onPrepared(delegate) + delegate.callback!!.onContinueLoadingRequested(delegate) + assertTrue(delegate.continueLoadingCalls.isEmpty()) + + period.seekToUs(10_000_000L) + assertEquals(1, delegate.continueLoadingCalls.size) + + // The restarted read parks again within the lookahead of the new + // position: read on. Once it is too far ahead again, it holds again. + delegate.bufferedUs = 60_000_000L + delegate.callback!!.onContinueLoadingRequested(delegate) + assertEquals(2, delegate.continueLoadingCalls.size) + delegate.bufferedUs = 200_000_000L + delegate.callback!!.onContinueLoadingRequested(delegate) + assertEquals(2, delegate.continueLoadingCalls.size) + } + + @Test + fun aDelegateThatHasNotPreparedIsNeverHeld() { + val delegate = FakePeriod().apply { bufferedUs = 300_000_000L } + val period = NonGatingSidecarPeriod(delegate, SidecarPlaybackFloor(), maxLookaheadUs = 120_000_000L) + period.prepare(RecordingCallback(), 0L) + + delegate.callback!!.onContinueLoadingRequested(delegate) + + assertEquals(1, delegate.continueLoadingCalls.size) + } + + @Test + fun aFinishedDelegateIsNeverHeld() { + val delegate = FakePeriod().apply { bufferedUs = C.TIME_END_OF_SOURCE } + val period = NonGatingSidecarPeriod(delegate, SidecarPlaybackFloor(), maxLookaheadUs = 120_000_000L) + period.prepare(RecordingCallback(), 0L) + delegate.callback!!.onPrepared(delegate) + + delegate.callback!!.onContinueLoadingRequested(delegate) + + assertEquals(1, delegate.continueLoadingCalls.size) + } + + @Test + fun withoutALookaheadTheDelegateReadsTheWholeFile() { + val delegate = FakePeriod().apply { bufferedUs = 3_000_000_000L } + val period = NonGatingSidecarPeriod(delegate, SidecarPlaybackFloor()) + period.prepare(RecordingCallback(), 0L) + + delegate.callback!!.onContinueLoadingRequested(delegate) + + assertEquals(1, delegate.continueLoadingCalls.size) + } + @Test fun forwardsPreparedAsItself() { val delegate = FakePeriod() @@ -144,7 +267,11 @@ class SidecarSubtitleMediaSourceTest { var callback: MediaPeriod.Callback? = null var loading = false var lastSeekUs = C.TIME_UNSET + var bufferedUs = 0L val continueLoadingCalls = mutableListOf() + /** Models a prepared ProgressiveMediaPeriod: a new load needs a pending reset. */ + var requirePendingReset = false + private var pendingReset = false override fun prepare(callback: MediaPeriod.Callback, positionUs: Long) { this.callback = callback @@ -164,16 +291,19 @@ class SidecarSubtitleMediaSourceTest { override fun readDiscontinuity(): Long = C.TIME_UNSET override fun seekToUs(positionUs: Long): Long { lastSeekUs = positionUs + pendingReset = true return positionUs } override fun getAdjustedSeekPositionUs(positionUs: Long, seekParameters: SeekParameters): Long = positionUs - override fun getBufferedPositionUs(): Long = 0L + override fun getBufferedPositionUs(): Long = bufferedUs override fun getNextLoadPositionUs(): Long = 0L override fun continueLoading(loadingInfo: LoadingInfo): Boolean { continueLoadingCalls += loadingInfo + check(!requirePendingReset || pendingReset) + pendingReset = false return true } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/EpisodeSelectionHandoffTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/EpisodeSelectionHandoffTest.kt index b7d8aec311..9b32935a6e 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/EpisodeSelectionHandoffTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/EpisodeSelectionHandoffTest.kt @@ -111,44 +111,6 @@ class EpisodeSelectionHandoffTest { assertNull(resolveEpisodeSourceIntent(captureEpisodeSourceIntent(source), targets)) } - @Test - fun sourceIntentNeverSerializesTheOriginalFileId() { - val handoff = EpisodeSelectionHandoff( - source = captureEpisodeSourceIntent( - version(4242, "2160p", "hevc", hdr = true, container = "mkv").copy( - fileName = "source-file-name.mkv", - filePath = "/private/source-file-path.mkv", - ), - ), - subtitle = captureEpisodeSubtitleIntent( - selectedTrackIndex = 31, - subtitles = listOf( - PlayerSubtitleInfo( - index = 31, - language = "en", - codec = "srt", - label = "English", - source = "downloaded", - url = "/private/source-subtitle.srt", - downloadId = 3131, - mediaTrackId = "source-media-track-id", - ), - ), - ), - ) - - val encoded = encodeEpisodeSelectionHandoff(handoff) - - assertFalse(encoded.contains("4242")) - assertFalse(encoded.contains("31")) - assertFalse(encoded.contains("3131")) - assertFalse(encoded.contains("source-file-name")) - assertFalse(encoded.contains("source-file-path")) - assertFalse(encoded.contains("source-subtitle")) - assertFalse(encoded.contains("source-media-track-id")) - assertTrue(encoded.contains("2160p")) - } - @Test fun explicitSubtitleMatchesSemanticsAtADifferentTargetIndex() { val intent = captureEpisodeSubtitleIntent( @@ -331,29 +293,6 @@ class EpisodeSelectionHandoffTest { assertTrue(resolved.intentSpecified) } - @Test - fun malformedPayloadDecodesToNull() { - assertNull(decodeEpisodeSelectionHandoff("{not-json")) - } - - @Test - fun encodedPayloadRoundTripsSemanticIntentAndRestoresAutoDefault() { - val handoff = EpisodeSelectionHandoff( - source = captureEpisodeSourceIntent( - version(101, "2160p", "hevc", hdr = true, container = "mkv"), - ), - subtitle = EpisodeSubtitleIntent( - mode = EpisodeSubtitleMode.TRACK, - language = "en", - codecFamily = "subrip", - external = true, - ), - ) - - assertEquals(handoff, decodeEpisodeSelectionHandoff(encodeEpisodeSelectionHandoff(handoff))) - assertEquals(EpisodeSubtitleIntent.auto(), decodeEpisodeSelectionHandoff("{}")?.subtitle) - } - private fun version( fileId: Int, resolution: String, diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/MarkerReconciliationFenceTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/MarkerReconciliationFenceTest.kt new file mode 100644 index 0000000000..9c122b4b19 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/MarkerReconciliationFenceTest.kt @@ -0,0 +1,22 @@ +package org.prairieserver.prairie.common.player.video + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MarkerReconciliationFenceTest { + @Test fun newerEventRejectsPendingRead() { + val fence = MarkerReconciliationFence() + val read = fence.begin() + fence.invalidate() + assertFalse(fence.isCurrent(read)) + } + + @Test fun reconnectRejectsPreviousConnectionRead() { + val fence = MarkerReconciliationFence() + val first = fence.begin() + val reconnect = fence.begin() + assertFalse(fence.isCurrent(first)) + assertTrue(fence.isCurrent(reconnect)) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/MarkerSkipTargetTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/MarkerSkipTargetTest.kt new file mode 100644 index 0000000000..3069a0b974 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/MarkerSkipTargetTest.kt @@ -0,0 +1,26 @@ +package org.prairieserver.prairie.common.player.video + +import org.prairieserver.prairie.model.catalog.TimeRange +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class MarkerSkipTargetTest { + @Test fun currentRangeControlsTheSeekAndDeletion() { + val recap = TimeRange(40.0, 60.0) + val credits = TimeRange(150.0, 180.0) + assertNull(markerSkipTarget(39.9, recap, credits)) + assertEquals(MarkerSkipTarget(ManualMarkerKind.Recap, 60.0), markerSkipTarget(40.0, recap, credits)) + assertNull(markerSkipTarget(60.0, recap, credits)) + assertEquals(MarkerSkipTarget(ManualMarkerKind.Credits, 180.0), markerSkipTarget(160.0, recap, credits)) + assertNull(markerSkipTarget(160.0, recap, null)) + assertEquals(185.0, markerSkipTarget(160.0, recap, TimeRange(150.0,185.0))?.endSeconds) + assertEquals(ManualMarkerKind.Recap, markerSkipTarget(45.0, recap, TimeRange(30.0,70.0))?.kind) + } + @Test fun invalidRangesCannotOfferASeek() { + for (range in listOf(TimeRange(-1.0, 10.0), TimeRange(10.0, 10.0), TimeRange(20.0, 10.0), TimeRange(0.0, Double.POSITIVE_INFINITY))) { + assertNull(markerSkipTarget(5.0, range, range)) + } + assertNull(markerSkipTarget(Double.NaN, TimeRange(0.0,10.0), null)) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/PlaybackContainerPolicyTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/PlaybackContainerPolicyTest.kt index 1356455554..7f5809b0f9 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/PlaybackContainerPolicyTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/PlaybackContainerPolicyTest.kt @@ -1,16 +1,27 @@ package org.prairieserver.prairie.common.player.video import kotlin.test.Test +import kotlin.test.assertFalse import kotlin.test.assertTrue class PlaybackContainerPolicyTest { @Test fun media3ContainersIncludeValidatedOriginalFormats() { listOf( - "mkv", "matroska", "mp4", "m4v", "webm", "avi", "mov", "qt", "ts", "mpegts", "mpeg-ts", "m2ts", "mts", + "mkv", "matroska", "mp4", "m4v", "webm", "avi", "mov", "qt", "ts", "mpegts", "mpeg-ts", ) .forEach { container -> assertTrue(normalizedPlaybackContainer(container) in media3OriginalPlaybackContainers, "container=$container") } } + + @Test + fun media3ContainersExcludeBdavTransportStreams() { + // Media3's TsExtractor reads 188-byte packets only; Blu-ray/AVCHD + // m2ts/mts use 192-byte packets and must be remuxed by the server. + listOf("m2ts", "mts", ".M2TS", " MTS ") + .forEach { container -> + assertFalse(normalizedPlaybackContainer(container) in media3OriginalPlaybackContainers, "container=$container") + } + } } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/watchparty/WatchPartyPlayerSupportTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/watchparty/WatchPartyPlayerSupportTest.kt new file mode 100644 index 0000000000..221cb2b703 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/watchparty/WatchPartyPlayerSupportTest.kt @@ -0,0 +1,37 @@ +package org.prairieserver.prairie.common.player.watchparty + +import org.prairieserver.prairie.watchtogether.RoomPlaybackNotice +import org.prairieserver.prairie.watchtogether.RoomTransportIntent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WatchPartyPlayerSupportTest { + @Test + fun `seeks this screen issued stay ours until they expire`() { + var now = 0L + val tracker = IssuedSeekTracker { now } + tracker.note(30_000L) + assertFalse(tracker.isExternal(30_000L)) + assertFalse(tracker.isExternal(30_400L), "Media3 may land slightly off the target") + // The session reports the controller's seek a second time; still ours. + assertFalse(tracker.isExternal(30_400L)) + // A headset skip that lands nowhere near an issued target. + assertTrue(tracker.isExternal(40_000L)) + // Issued targets expire. + now = IssuedSeekTracker.EXPIRY_MS + 1 + assertTrue(tracker.isExternal(30_000L)) + } + + @Test + fun `room notices use plain copy`() { + assertEquals("Only the host can seek.", watchPartyNoticeText(RoomPlaybackNotice.Denied(RoomTransportIntent.Seek))) + assertEquals( + "Only the host can play or pause.", + watchPartyNoticeText(RoomPlaybackNotice.Denied(RoomTransportIntent.PlayPause)), + ) + assertEquals("Reconnecting to the party…", watchPartyNoticeText(RoomPlaybackNotice.Reconnecting)) + assertEquals("Couldn't reach the party. Try again.", watchPartyNoticeText(RoomPlaybackNotice.Undelivered)) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/profiles/ProfileLaunchPreferencesTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/profiles/ProfileLaunchPreferencesTest.kt new file mode 100644 index 0000000000..d4a17d7715 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/profiles/ProfileLaunchPreferencesTest.kt @@ -0,0 +1,254 @@ +package org.prairieserver.prairie.common.profiles + +import android.content.SharedPreferences +import org.prairieserver.prairie.common.player.ActivePlayerHolder +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Profile Selection timing rules, mirroring prairie-apple + * `ProfileLaunchPolicyTests`: when Who's Watching is required, and when the + * launcher's Watch Next row may show the active profile. + */ +class ProfileLaunchPreferencesTest { + + private val hour = 60 * 60 * 1000L + private val start = 1_000_000_000L + + private fun state(behavior: ProfileLaunchBehavior, backgroundedAt: Long? = null) = + ProfileLaunchState(behavior = behavior, backgroundedAtMs = backgroundedAt) + + @Test + fun `missing or unknown stored value means Automatic`() { + assertEquals(ProfileLaunchBehavior.Automatic, ProfileLaunchBehavior.fromRaw(null)) + assertEquals(ProfileLaunchBehavior.Automatic, ProfileLaunchBehavior.fromRaw("later")) + assertEquals(ProfileLaunchBehavior.EveryTime, ProfileLaunchBehavior.fromRaw("askEveryLaunch")) + } + + @Test + fun `Automatic never asks and never records an away interval`() { + val marked = state(ProfileLaunchBehavior.Automatic).markedBackgrounded(start) + assertNull(marked.backgroundedAtMs) + assertFalse(marked.requiresSelectionAtLaunch(start + 100 * hour)) + assertFalse(marked.requiresSelectionAfterBackground(start + 100 * hour)) + assertTrue(marked.allowsWatchNext(start + 100 * hour)) + } + + @Test + fun `Every Time asks at every launch and every real return, and hides Watch Next`() { + val everyTime = state(ProfileLaunchBehavior.EveryTime) + // A process that died without recording a background still asks. + assertTrue(everyTime.requiresSelectionAtLaunch(start)) + // A return needs a recorded background, so a brief pause never locks. + assertFalse(everyTime.requiresSelectionAfterBackground(start)) + assertTrue(everyTime.markedBackgrounded(start).requiresSelectionAfterBackground(start)) + assertFalse(everyTime.allowsWatchNext(start)) + } + + @Test + fun `the Watch Next expiry check is due when the away interval runs out`() { + assertEquals(start + hour, state(ProfileLaunchBehavior.AfterOneHour, backgroundedAt = start).awayExpiresAtMs) + assertEquals(start + 12 * hour, state(ProfileLaunchBehavior.AfterTwelveHours, backgroundedAt = start).awayExpiresAtMs) + // Nothing times out while Prairie is in use, or for the untimed choices. + assertNull(state(ProfileLaunchBehavior.AfterOneHour).awayExpiresAtMs) + assertNull(state(ProfileLaunchBehavior.EveryTime, backgroundedAt = start).awayExpiresAtMs) + assertNull(state(ProfileLaunchBehavior.Automatic).markedBackgrounded(start).awayExpiresAtMs) + } + + @Test + fun `After 1 Hour asks exactly when the hour has passed`() { + val away = state(ProfileLaunchBehavior.AfterOneHour, backgroundedAt = start) + assertFalse(away.requiresSelectionAfterBackground(start + hour - 1)) + assertTrue(away.requiresSelectionAfterBackground(start + hour)) + assertFalse(away.requiresSelectionAtLaunch(start + hour - 1)) + assertTrue(away.requiresSelectionAtLaunch(start + hour)) + // Without a recorded away interval a timed choice restores. + assertFalse(state(ProfileLaunchBehavior.AfterOneHour).requiresSelectionAtLaunch(start + 100 * hour)) + } + + @Test + fun `After 12 Hours asks exactly when twelve hours have passed`() { + val away = state(ProfileLaunchBehavior.AfterTwelveHours, backgroundedAt = start) + assertFalse(away.requiresSelectionAfterBackground(start + 12 * hour - 1)) + assertTrue(away.requiresSelectionAfterBackground(start + 12 * hour)) + } + + @Test + fun `a clock behind the recorded start asks instead of trusting it`() { + val away = state(ProfileLaunchBehavior.AfterTwelveHours, backgroundedAt = start) + assertTrue(away.requiresSelectionAtLaunch(start - 1)) + assertTrue(away.requiresSelectionAfterBackground(start - 1)) + } + + @Test + fun `timed choices keep Watch Next until the interval runs out`() { + val away = state(ProfileLaunchBehavior.AfterOneHour, backgroundedAt = start) + assertTrue(away.allowsWatchNext(start + hour - 1)) + assertFalse(away.allowsWatchNext(start + hour)) + assertTrue(state(ProfileLaunchBehavior.AfterOneHour).allowsWatchNext(start + 100 * hour)) + } + + @Test + fun `switching to Automatic ends a running away interval`() { + val away = state(ProfileLaunchBehavior.EveryTime, backgroundedAt = start) + assertNull(away.withBehavior(ProfileLaunchBehavior.Automatic).backgroundedAtMs) + assertEquals(start, away.withBehavior(ProfileLaunchBehavior.AfterOneHour).backgroundedAtMs) + } + + @Test + fun `the choice and the away interval survive a process restart`() { + val prefs = InMemoryPrefs() + var now = start + val first = ProfileLaunchPreferences(prefs) { now } + first.setBehavior(ProfileLaunchBehavior.AfterOneHour) + first.markBackgrounded() + + now = start + hour + val restarted = ProfileLaunchPreferences(prefs) { now } + assertEquals(ProfileLaunchBehavior.AfterOneHour, restarted.state.value.behavior) + assertEquals(start, restarted.state.value.backgroundedAtMs) + assertTrue(restarted.requiresSelectionAtLaunch()) + + restarted.clearBackgroundedAt() + assertFalse(ProfileLaunchPreferences(prefs) { now }.requiresSelectionAtLaunch()) + } + + @Test + fun `a new TV asks who's watching and offers to remember the first profile`() { + val prefs = InMemoryPrefs() + val store = ProfileLaunchPreferences(prefs) { start } + store.settleFirstLaunch(hasRememberedProfile = false) + + assertEquals(ProfileLaunchBehavior.EveryTime, store.state.value.behavior) + assertTrue(store.isRememberOfferPending) + assertTrue(store.requiresSelectionAtLaunch()) + // Later launches keep what was saved. + val next = ProfileLaunchPreferences(prefs) { start } + next.settleFirstLaunch(hasRememberedProfile = true) + assertEquals(ProfileLaunchBehavior.EveryTime, next.state.value.behavior) + assertTrue(next.isRememberOfferPending) + } + + @Test + fun `a TV that already opens a profile keeps Automatic and gets no offer`() { + val prefs = InMemoryPrefs() + ProfileLaunchPreferences(prefs) { start }.settleFirstLaunch(hasRememberedProfile = true) + + val restarted = ProfileLaunchPreferences(prefs) { start } + assertEquals(ProfileLaunchBehavior.Automatic, restarted.state.value.behavior) + assertFalse(restarted.isRememberOfferPending) + assertFalse(restarted.requiresSelectionAtLaunch()) + } + + @Test + fun `the offer is answered once and the answer is kept`() { + for (remember in listOf(true, false)) { + val prefs = InMemoryPrefs() + ProfileLaunchPreferences(prefs) { start }.apply { + settleFirstLaunch(hasRememberedProfile = false) + answerRememberOffer(remember) + } + val restarted = ProfileLaunchPreferences(prefs) { start } + assertFalse(restarted.isRememberOfferPending) + assertEquals( + if (remember) ProfileLaunchBehavior.Automatic else ProfileLaunchBehavior.EveryTime, + restarted.state.value.behavior, + ) + } + } + + @Test + fun `choosing in Settings ends the offer`() { + val prefs = InMemoryPrefs() + val store = ProfileLaunchPreferences(prefs) { start } + store.settleFirstLaunch(hasRememberedProfile = false) + store.setBehavior(ProfileLaunchBehavior.AfterOneHour) + assertFalse(ProfileLaunchPreferences(prefs) { start }.isRememberOfferPending) + } + + @Test + fun `Cast that stops while Prairie is hidden starts the away clock, so Every Time asks`() { + val store = ProfileLaunchPreferences(InMemoryPrefs()) { start } + store.setBehavior(ProfileLaunchBehavior.EveryTime) + var casting = true + val tracker = ProfileAwayTracker(store, ActivePlayerHolder(), isCasting = { casting }) + + tracker.onBackground() + assertNull(store.state.value.backgroundedAtMs) + + casting = false + tracker.onRemotePlaybackChanged() + assertEquals(start, store.state.value.backgroundedAtMs) + + tracker.onForeground() + assertTrue(tracker.selectionRequired.value) + } + + @Test + fun `a Cast change while Prairie is in use starts no away clock`() { + val store = ProfileLaunchPreferences(InMemoryPrefs()) { start } + store.setBehavior(ProfileLaunchBehavior.EveryTime) + val tracker = ProfileAwayTracker(store, ActivePlayerHolder(), isCasting = { false }) + + tracker.onRemotePlaybackChanged() + assertNull(store.state.value.backgroundedAtMs) + } +} + +/** Just enough SharedPreferences for the store: strings, longs and booleans. */ +internal class InMemoryPrefs : SharedPreferences { + private val values = mutableMapOf() + + override fun getAll(): MutableMap = values.toMutableMap() + override fun getString(key: String?, defValue: String?): String? = values[key] as String? ?: defValue + override fun getStringSet(key: String?, defValues: MutableSet?): MutableSet? = defValues + override fun getInt(key: String?, defValue: Int): Int = defValue + override fun getLong(key: String?, defValue: Long): Long = values[key] as Long? ?: defValue + override fun getFloat(key: String?, defValue: Float): Float = defValue + override fun getBoolean(key: String?, defValue: Boolean): Boolean = values[key] as Boolean? ?: defValue + override fun contains(key: String?): Boolean = key in values + override fun edit(): SharedPreferences.Editor = Editor() + override fun registerOnSharedPreferenceChangeListener(l: SharedPreferences.OnSharedPreferenceChangeListener?) {} + override fun unregisterOnSharedPreferenceChangeListener(l: SharedPreferences.OnSharedPreferenceChangeListener?) {} + + private inner class Editor : SharedPreferences.Editor { + private val pending = mutableMapOf() + private val removed = mutableSetOf() + + override fun putString(key: String, value: String?): SharedPreferences.Editor { + pending[key] = value + return this + } + + override fun putLong(key: String, value: Long): SharedPreferences.Editor { + pending[key] = value + return this + } + + override fun remove(key: String): SharedPreferences.Editor { + removed += key + return this + } + + override fun putStringSet(key: String, values: MutableSet?): SharedPreferences.Editor = this + override fun putInt(key: String, value: Int): SharedPreferences.Editor = this + override fun putFloat(key: String, value: Float): SharedPreferences.Editor = this + override fun putBoolean(key: String, value: Boolean): SharedPreferences.Editor { + pending[key] = value + return this + } + override fun clear(): SharedPreferences.Editor = this + override fun commit(): Boolean { + apply() + return true + } + + override fun apply() { + removed.forEach(values::remove) + values.putAll(pending) + } + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/AndroidPlayerSettingsStoreTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/AndroidPlayerSettingsStoreTest.kt index f10489808d..79f2468432 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/AndroidPlayerSettingsStoreTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/AndroidPlayerSettingsStoreTest.kt @@ -24,6 +24,7 @@ import io.ktor.client.HttpClient import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.Json @@ -143,6 +144,10 @@ class AndroidPlayerSettingsStoreTest { assertEquals("720p",store.preferredQualityFlow.first()) assertEquals(2000,store.maxBitrateKbpsFlow.first()) assertFalse(store.autoPlayNextFlow.first()) + assertEquals( + setOf(PlaybackSettingsKeys.PreferredQuality, PlaybackSettingsKeys.MaxBitrateKbps, PlaybackSettingsKeys.AutoPlayNext), + store.deviceOverrideKeysFlow.first(), + ) assertTrue(fakeFlusher.calls.isEmpty()) } finally { client.close() } } @@ -359,12 +364,145 @@ class AndroidPlayerSettingsStoreTest { } @Test - fun `setPlaybackSpeed clamps out-of-range values`() = runTest { + fun `updateSubtitleAppearance applies the transform against the current value`() = runTest { + val store = newStore() + store.setSubtitleAppearance(SubtitleAppearance.DEFAULT.copy(fontSize = SubtitleFontSizePreset.XLarge)) + store.updateSubtitleAppearance { it.copy(textOpacity = 42) } + val read = store.subtitleAppearanceFlow.first() + // The field the transform didn't touch survives... + assertEquals(SubtitleFontSizePreset.XLarge, read.fontSize) + // ...and the one it did is applied. + assertEquals(42, read.textOpacity) + } + + @Test + fun `sequential updateSubtitleAppearance calls do not clobber each other`() = runTest { + val store = newStore() + // Each call reads the current stored value inside its own write + // transaction rather than a value read before either call started, + // so a per-field edit from one call survives the next. + store.updateSubtitleAppearance { it.copy(textOpacity = 55) } + store.updateSubtitleAppearance { it.copy(backgroundOpacity = 33) } + val read = store.subtitleAppearanceFlow.first() + assertEquals(55, read.textOpacity) + assertEquals(33, read.backgroundOpacity) + } + + @Test + fun `concurrent appearance edits enqueue in the order they commit`() = runTest { + val gated = ReturnGatedDataStore( + PreferenceDataStoreFactory.create(produceFile = { File(tempFolder.root, "ds_gated.preferences_pb") }), + ) + val store = AndroidPlayerSettingsStore( + context = mockContextStub(), + legacyCache = fakeLegacyCache, + getActiveProfileId = { activeProfileId }, + getServerUrl = { serverUrl }, + serverSettingsFlusher = fakeFlusher, + getDeviceId = { null }, + dataStoreFactory = { gated }, + ) + // Runs the one-time migrations so the gate only sees the two edits. + store.setSubtitleAppearance(SubtitleAppearance.DEFAULT) + fakeFlusher.calls.clear() + gated.armed = true + + // A commits first but its edit returns only after B's has returned: + // exactly the interleaving where an enqueue made after `edit` returned + // would send B's composite and then overwrite it with A's older one. + val a = launch { store.updateSubtitleAppearance { it.copy(textOpacity = 40) } } + val b = launch { store.updateSubtitleAppearance { it.copy(backgroundOpacity = 30) } } + a.join() + b.join() + + val stored = store.subtitleAppearanceFlow.first() + assertEquals(40, stored.textOpacity) + assertEquals(30, stored.backgroundOpacity) + val lastEnqueued = fakeFlusher.calls.last { it.key == PlaybackSettingsKeys.SubtitleAppearance } + assertEquals(stored, SubtitleAppearance.decode(lastEnqueued.value)) + } + + @Test + fun `text opacity is offered unless the active server is known to be below revision 14`() = runTest { + var active = serverUrl + var revision: ApiResult = + ApiResult.NetworkError(IllegalStateException("offline")) + val contractRevision = SettingsContractRevision({ revision }, { active }) + val store = AndroidPlayerSettingsStore( + context = mockContextStub(), + legacyCache = fakeLegacyCache, + getActiveProfileId = { activeProfileId }, + getServerUrl = { active }, + serverSettingsFlusher = fakeFlusher, + settingsRepository = SettingsRepository(FakeSettingsApi()), + getDeviceId = { null }, + contractRevision = contractRevision, + dataStoreFactory = { id -> + PreferenceDataStoreFactory.create(produceFile = { File(tempFolder.root, "ds_rev_$id.preferences_pb") }) + }, + ) + + // Unknown: offered, since the flusher holds the value until it is known. + store.refreshFromServer() + assertTrue(store.subtitleTextOpacitySupportedFlow.first()) + + // The refresh re-reads the revision before it pushes. + revision = capabilitiesAt(13) + store.refreshFromServer() + assertFalse(store.subtitleTextOpacitySupportedFlow.first()) + + // A revision known for another server says nothing about this one. + active = "https://other.example" + assertTrue(store.subtitleTextOpacitySupportedFlow.first()) + + active = serverUrl + revision = capabilitiesAt(14) + store.refreshFromServer() + assertTrue(store.subtitleTextOpacitySupportedFlow.first()) + } + + private fun capabilitiesAt(revision: Int): ApiResult = + ApiResult.Success( + org.prairieserver.prairie.model.settings.SettingsContractCapabilities( + apiVersion = 1, manifestRevision = revision, supportsBatchedEffective = true, + ), + ) + + @Test + fun `setPlaybackSpeed clamps to the contract range and step`() = runTest { val store = newStore() store.setPlaybackSpeed(10.0) - assertEquals(4.0, store.playbackSpeedFlow.first(), 0.0) + assertEquals(3.0, store.playbackSpeedFlow.first(), 0.0) + store.setPlaybackSpeed(3.5) + assertEquals(3.0, store.playbackSpeedFlow.first(), 0.0) store.setPlaybackSpeed(0.01) assertEquals(0.25, store.playbackSpeedFlow.first(), 0.0) + // Off the 0.05 grid: the server would refuse it and the write would + // be dropped, so it is snapped before it is stored or sent. + store.setPlaybackSpeed(1.33) + assertEquals(1.35, store.playbackSpeedFlow.first(), 0.0) + assertEquals( + listOf("3.0", "3.0", "0.25", "1.35"), + fakeFlusher.calls.filter { it.key == PlaybackSettingsKeys.PlaybackSpeed }.map { it.value }, + ) + } + + @Test + fun `a playback speed stored by an older build is normalized on read`() = runTest { + // Older builds clamped to 4.0, so a stored speed can sit outside the + // contract range until the next server refresh, or forever offline. + val store = newStoreSeededWith { prefs -> + prefs[stringPreferencesKey(PlaybackSettingsKeys.PlaybackSpeed)] = "3.5" + } + assertEquals(3.0, store.playbackSpeedFlow.first(), 0.0) + } + + @Test + fun `Profile 7 HDR10 fallback defaults off before the first refresh`() = runTest { + // The contract default is false; reading true before hydration (or + // offline) sent Profile 7 sources down the HDR10 path. + assertFalse(newStore().dvProfile7HDR10FallbackFlow.first()) + assertFalse(newStore(profileId = null).dvProfile7HDR10FallbackFlow.first()) } @Test @@ -667,6 +805,183 @@ class AndroidPlayerSettingsStoreTest { } } + @Test + fun `using the profile setting deletes the device value through the scoped queue`() = runTest { + val api = FakeSettingsApi() + val store = newStore(repository = SettingsRepository(api), deviceId = "device") + store.setAutoSkipCredits(true) + assertEquals(setOf(PlaybackSettingsKeys.AutoSkipCredits), store.deviceOverrideKeysFlow.first()) + + // The server now resolves the profile's value. + api.effective = mapOf( + stored(PlaybackSettingsKeys.AutoSkipCredits, JsonPrimitive(false), scope = SettingScope.PROFILE.wire), + ) + val flushesBefore = fakeFlusher.flushNowCount + store.resetDeviceSetting(PlaybackSettingsKeys.AutoSkipCredits) + + val delete = fakeFlusher.calls.last() + assertTrue(delete.isDelete) + assertEquals(PlaybackSettingsKeys.AutoSkipCredits, delete.key) + // Stamped with the profile and server it was made under, like a set, + // so a profile or server switch before it lands drops it rather than + // clearing someone else's value. + assertEquals(activeProfileId, delete.profileId) + assertEquals(serverUrl, delete.serverUrl) + assertTrue(fakeFlusher.flushNowCount > flushesBefore, "the clear is pushed right away") + assertEquals(emptySet(), store.deviceOverrideKeysFlow.first()) + assertFalse(store.autoSkipCreditsFlow.first()) + } + + @Test + fun `using the profile setting clears every key the control writes`() = runTest { + val store = newStore(repository = SettingsRepository(FakeSettingsApi())) + store.setQuality("1080p", 8000) + store.setIntroSkipMode(IntroSkipMode.NEVER) + + store.resetDeviceSetting(PlaybackSettingsKeys.PreferredQuality) + store.resetDeviceSetting(PlaybackSettingsKeys.IntroSkipMode) + + val deleted = fakeFlusher.calls.filter { it.isDelete }.map { it.key }.toSet() + assertEquals( + setOf( + PlaybackSettingsKeys.PreferredQuality, + PlaybackSettingsKeys.MaxBitrateKbps, + PlaybackSettingsKeys.IntroSkipMode, + PlaybackSettingsKeys.AutoSkipIntro, + ), + deleted, + ) + assertEquals(emptySet(), store.deviceOverrideKeysFlow.first()) + } + + @Test + fun `no audio language preference clears the device value instead of storing null`() = runTest { + val store = newStore(repository = SettingsRepository(FakeSettingsApi())) + store.setAudioLanguage("ja") + assertTrue(PlaybackSettingsKeys.AudioLanguage in store.deviceOverrideKeysFlow.first()) + + store.setAudioLanguage("") + + val audio = fakeFlusher.calls.filter { it.key == PlaybackSettingsKeys.AudioLanguage } + assertEquals(listOf(false, true), audio.map { it.isDelete }, "a PUT for ja, then a DELETE") + assertFalse(PlaybackSettingsKeys.AudioLanguage in store.deviceOverrideKeysFlow.first()) + } + + @Test + fun `a refresh records which values this device holds itself`() = runTest { + val api = FakeSettingsApi( + effective = mapOf( + stored(PlaybackSettingsKeys.AutoPlayNext, JsonPrimitive(false)), + stored(PlaybackSettingsKeys.AutoSkipCredits, JsonPrimitive(true), scope = SettingScope.PROFILE.wire), + defaulted(PlaybackSettingsKeys.IntroSkipMode, JsonPrimitive("ask")), + ), + ) + val store = newStore(repository = SettingsRepository(api)) + store.setIntroSkipMode(IntroSkipMode.NEVER) + store.refreshFromServer() + // Answered from the device scope only for auto-play; the intro-skip + // write has landed (nothing pending), so the server's answer wins. + assertEquals(setOf(PlaybackSettingsKeys.AutoPlayNext), store.deviceOverrideKeysFlow.first()) + + // A write still queued keeps its marker: the response predates it. + store.setAutoSkipCredits(false) + fakeFlusher.pending = setOf(PlaybackSettingsKeys.AutoSkipCredits) + store.refreshFromServer() + assertEquals( + setOf(PlaybackSettingsKeys.AutoPlayNext, PlaybackSettingsKeys.AutoSkipCredits), + store.deviceOverrideKeysFlow.first(), + ) + } + + @Test + fun `device values are tracked per profile`() = runTest { + var profile = "profile-a" + val stores = mutableMapOf>() + val store = AndroidPlayerSettingsStore( + context = mockContextStub(), legacyCache = fakeLegacyCache, + getActiveProfileId = { profile }, getServerUrl = { serverUrl }, + getDeviceId = { "device" }, serverSettingsFlusher = fakeFlusher, + dataStoreFactory = { id -> stores.getOrPut(id) { + PreferenceDataStoreFactory.create( + produceFile = { File(tempFolder.root, "overrides_$id.preferences_pb") }, + ) + } }, + ) + store.setAutoSkipCredits(true) + assertEquals(setOf(PlaybackSettingsKeys.AutoSkipCredits), store.deviceOverrideKeysFlow.first()) + profile = "profile-b" + assertEquals(emptySet(), store.deviceOverrideKeysFlow.first()) + store.resetDeviceSetting(PlaybackSettingsKeys.AutoSkipCredits) + assertEquals("profile-b", fakeFlusher.calls.last().profileId) + profile = "profile-a" + assertEquals(setOf(PlaybackSettingsKeys.AutoSkipCredits), store.deviceOverrideKeysFlow.first()) + } + + @Test + fun `resetAllDeviceSettings clears the markers and reports whether the clears landed`() = runTest { + val store = newStore(repository = SettingsRepository(FakeSettingsApi())) + store.setAutoSkipCredits(true) + assertTrue(store.resetAllDeviceSettings()) + assertEquals(emptySet(), store.deviceOverrideKeysFlow.first()) + + // Offline: the deletes stay queued, and the caller must not claim the + // profile's settings already apply. + fakeFlusher.pending = setOf(PlaybackSettingsKeys.AutoSkipCredits) + assertFalse(store.resetAllDeviceSettings()) + } + + @Test + fun `resetAllDeviceSettings does not report success when the server still has a device value`() = runTest { + // The flusher drops a delete it cannot retry (a 401/403, a replaced + // owner), so the queue comes back empty while the row survives. + val api = FakeSettingsApi( + effective = mapOf( + PlaybackSettingsKeys.AutoSkipCredits to EffectiveSettingValue( + key = PlaybackSettingsKeys.AutoSkipCredits, + value = JsonPrimitive(true), + source = SettingScope.PROFILE_DEVICE.wire, + scope = SettingScope.PROFILE_DEVICE.wire, + ), + ), + ) + val store = newStore(repository = SettingsRepository(api)) + store.setAutoSkipCredits(true) + assertFalse(store.resetAllDeviceSettings()) + assertEquals(setOf(PlaybackSettingsKeys.AutoSkipCredits), store.deviceOverrideKeysFlow.first()) + } + + @Test + fun `resetAllDeviceSettings does not confirm a reset for a profile switched away from mid-reset`() = runTest { + val ownerA = org.prairieserver.prairie.network.AuthScopeSnapshot("server", "profile-a", serverUrl, "token-a", credentialEpoch = 1) + var owner = ownerA + val stores = mutableMapOf>() + // Profile B's answer: every device row is gone, which would confirm + // the reset if it were read as profile A's. + val api = FakeSettingsApi( + effective = mapOf(defaulted(PlaybackSettingsKeys.AutoSkipCredits, JsonPrimitive(false))), + ) + val store = AndroidPlayerSettingsStore( + context = mockContextStub(), legacyCache = fakeLegacyCache, + getActiveProfileId = { owner.profileId }, getServerUrl = { serverUrl }, + getDeviceId = { "device" }, serverSettingsFlusher = fakeFlusher, + settingsRepository = SettingsRepository(api), getAuthScope = { owner }, + dataStoreFactory = { id -> stores.getOrPut(id) { + PreferenceDataStoreFactory.create( + produceFile = { File(tempFolder.root, "reset_switch_$id.preferences_pb") }, + ) + } }, + ) + store.setAutoSkipCredits(true) + // The user switches to profile B while the reset's refresh is in flight. + api.onEffective = { owner = ownerA.copy(profileId = "profile-b", profileToken = "token-b") } + + assertFalse(store.resetAllDeviceSettings()) + assertEquals(listOf(ownerA), api.requestedAuthorities) + // Profile A's store did not take profile B's answer either. + owner = ownerA + assertTrue(store.autoSkipCreditsFlow.first()) + } + @Test fun `setSubtitleDeviceOverrideEnabled false enqueues delete and clears local flag`() = runTest { val repo = SettingsRepository(FakeSettingsApi()) @@ -915,6 +1230,36 @@ private class FakeServerSettingsFlusher : ServerSettingsFlusher { override suspend fun flushNow() { flushNowCount++ } + + /** Keys whose write is still queued after a flush, as offline would leave them. */ + var pending: Set = emptySet() + override fun pendingKeys(profileId: String): Set = pending +} + +/** + * Delegates to a real DataStore, but once [armed] holds the first edit's + * return until the second edit has returned. The commits themselves stay in + * submission order; only the callers resume in reverse. + */ +private class ReturnGatedDataStore( + private val delegate: DataStore, +) : DataStore { + @Volatile var armed = false + private val edits = java.util.concurrent.atomic.AtomicInteger() + private val secondReturned = kotlinx.coroutines.CompletableDeferred() + + override val data: kotlinx.coroutines.flow.Flow = delegate.data + + override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences { + if (!armed) return delegate.updateData(transform) + val index = edits.incrementAndGet() + val result = delegate.updateData(transform) + when (index) { + 1 -> secondReturned.await() + 2 -> secondReturned.complete(Unit) + } + return result + } } /** One canned entry: a value stored at [scope] (default: profile_device). */ @@ -942,6 +1287,10 @@ private class FakeSettingsApi( // up a second DataStore over the same file. var effective: Map = effective var requestedKeys: List = emptyList() + val requestedAuthorities = mutableListOf() + + /** Runs while a request is in flight, before it is answered. */ + var onEffective: () -> Unit = {} override suspend fun getEffectiveValues( keys: List, @@ -950,6 +1299,8 @@ private class FakeSettingsApi( authority: org.prairieserver.prairie.network.AuthScopeSnapshot?, ): ApiResult { requestedKeys = keys + requestedAuthorities += authority + onEffective() // Like the server: answer only the keys this contract knows. val entries = keys.mapNotNull { effective[it] } return ApiResult.Success(EffectiveSettingValuesResponse(settings = entries, revision = 1)) diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/EpisodeSpoilerStoreTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/EpisodeSpoilerStoreTest.kt new file mode 100644 index 0000000000..c8518d7687 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/EpisodeSpoilerStoreTest.kt @@ -0,0 +1,419 @@ +package org.prairieserver.prairie.common.settings + +import org.prairieserver.prairie.model.settings.EffectiveSettingValue +import org.prairieserver.prairie.model.settings.EffectiveSettingValuesResponse +import org.prairieserver.prairie.model.settings.EpisodeSpoilerPrefs +import org.prairieserver.prairie.model.settings.SettingKeys +import org.prairieserver.prairie.model.settings.SettingScope +import org.prairieserver.prairie.model.settings.SettingScopeIdentity +import org.prairieserver.prairie.model.settings.SettingsContractCapabilities +import org.prairieserver.prairie.model.settings.StoredSettingValue +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.api.SettingsApi +import org.prairieserver.prairie.repository.SettingsRepository +import io.ktor.client.HttpClient +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class EpisodeSpoilerStoreTest { + + @Test + fun `a revision 17 server resolves both switches`() = runTest { + val api = FakeSpoilerSettingsApi( + values = mapOf(SettingKeys.CATALOG_HIDE_UNWATCHED_EPISODE_IMAGES to JsonPrimitive(true)), + ) + val store = storeFor(api) + + store.refresh() + + assertEquals(EpisodeSpoilerSupport.Supported, store.state.value.support) + assertEquals(EpisodeSpoilerPrefs(hideImages = true, hideOverviews = false), store.state.value.prefs) + assertEquals(listOf(SettingKeys.CATALOG_HIDE_UNWATCHED_EPISODE_IMAGES, SettingKeys.CATALOG_HIDE_UNWATCHED_EPISODE_OVERVIEWS), api.effectiveReads.single()) + } + + @Test + fun `an older server hides nothing, never reads the keys, and never receives writes`() = runTest { + val api = FakeSpoilerSettingsApi( + revision = 16, + values = mapOf(SettingKeys.CATALOG_HIDE_UNWATCHED_EPISODE_IMAGES to JsonPrimitive(true)), + ) + val store = storeFor(api) + + store.refresh() + store.set(EpisodeSpoilerSetting.Images, true) + runCurrent() + + assertEquals(EpisodeSpoilerSupport.Unsupported, store.state.value.support) + assertEquals(EpisodeSpoilerPrefs.NONE, store.state.value.prefs) + assertTrue(api.effectiveReads.isEmpty()) + assertTrue(api.puts.isEmpty()) + } + + @Test + fun `a server without the contract routes is unsupported`() = runTest { + val api = FakeSpoilerSettingsApi(capabilities = ApiResult.Error(404, "not_found", "Not Found")) + val store = storeFor(api) + + store.refresh() + + assertEquals(EpisodeSpoilerSupport.Unsupported, store.state.value.support) + } + + @Test + fun `a server at the revision without the keys is unsupported`() = runTest { + val api = FakeSpoilerSettingsApi( + effectiveError = ApiResult.Error(404, "unknown_setting", "No setting named catalog.hide_unwatched_episode_images"), + ) + val store = storeFor(api) + + store.refresh() + store.set(EpisodeSpoilerSetting.Images, true) + runCurrent() + + assertEquals(EpisodeSpoilerSupport.Unsupported, store.state.value.support) + assertEquals(EpisodeSpoilerPrefs.NONE, store.state.value.prefs) + assertTrue(api.puts.isEmpty()) + } + + @Test + fun `nothing is written or hidden while support is unknown`() = runTest { + val api = FakeSpoilerSettingsApi(capabilities = ApiResult.NetworkError(RuntimeException("offline"))) + val store = storeFor(api) + + store.refresh() + store.set(EpisodeSpoilerSetting.Overviews, true) + runCurrent() + + assertEquals(EpisodeSpoilerSupport.Unknown, store.state.value.support) + assertEquals(EpisodeSpoilerPrefs.NONE, store.state.value.prefs) + assertTrue(api.puts.isEmpty()) + assertTrue(store.lastError.value != null) + } + + @Test + fun `a write goes to profile scope and is cached`() = runTest { + val api = FakeSpoilerSettingsApi() + val cache = InMemoryEpisodeSpoilerCache() + val store = storeFor(api, cache) + store.refresh() + + store.set(EpisodeSpoilerSetting.Overviews, true) + assertTrue(store.state.value.hideOverviews) + runCurrent() + + assertEquals( + listOf(Triple(SettingKeys.CATALOG_HIDE_UNWATCHED_EPISODE_OVERVIEWS, SettingScope.PROFILE, JsonPrimitive(true) as JsonElement)), + api.puts, + ) + assertTrue(store.state.value.hideOverviews) + assertEquals(true, cache.read("https://server.test|profile-1")?.hideOverviews) + } + + @Test + fun `a failed write rolls back to the confirmed value`() = runTest { + val api = FakeSpoilerSettingsApi(failPuts = true) + val store = storeFor(api) + store.refresh() + + store.set(EpisodeSpoilerSetting.Images, true) + runCurrent() + + assertFalse(store.state.value.hideImages) + assertTrue(store.lastError.value != null) + assertEquals("Couldn't save spoiler settings: Server error", store.saveError.value) + + api.failPuts = false + store.set(EpisodeSpoilerSetting.Images, true) + runCurrent() + assertEquals(null, store.saveError.value) + } + + @Test + fun `a cached answer applies before the network answers and clear drops it`() = runTest { + val cache = InMemoryEpisodeSpoilerCache() + cache.write( + "https://server.test|profile-1", + EpisodeSpoilerState(EpisodeSpoilerSupport.Supported, hideImages = true, hideOverviews = true), + ) + val api = FakeSpoilerSettingsApi(capabilities = ApiResult.NetworkError(RuntimeException("offline"))) + val store = storeFor(api, cache) + + store.hydrateIfNeeded() + assertEquals(EpisodeSpoilerPrefs(hideImages = true, hideOverviews = true), store.state.value.prefs) + + store.clear() + assertEquals(EpisodeSpoilerPrefs.NONE, store.state.value.prefs) + } + + @Test + fun `hydrating for another server re-resolves without a clear`() = runTest { + // Add Server → sign in → pick profile: nothing calls clear(), and the + // old server's Unsupported answer must not survive the switch. + var serverUrl = "https://old.test" + var profileId = "old-profile" + val api = FakeSpoilerSettingsApi(revision = 16) + val store = storeFor(api, serverUrl = { serverUrl }, profileId = { profileId }) + store.hydrateIfNeeded() + assertEquals(EpisodeSpoilerSupport.Unsupported, store.state.value.support) + + serverUrl = "https://new.test" + profileId = "new-profile" + api.serve(revision = 17, values = bothOn) + store.hydrateIfNeeded() + + assertEquals(EpisodeSpoilerPrefs(hideImages = true, hideOverviews = true), store.state.value.prefs) + } + + @Test + fun `an active server or profile change resets and re-resolves`() = runTest { + var serverUrl = "https://old.test" + val identityChanges = MutableSharedFlow() + val api = FakeSpoilerSettingsApi(revision = 16) + val store = storeFor(api, serverUrl = { serverUrl }, identityChanges = identityChanges) + runCurrent() + store.hydrateIfNeeded() + + serverUrl = "https://new.test" + api.serve(revision = 17, values = bothOn) + identityChanges.emit(Unit) + runCurrent() + + assertEquals(EpisodeSpoilerPrefs(hideImages = true, hideOverviews = true), store.state.value.prefs) + } + + @Test + fun `refresh preserves a toggle while its write is pending`() = runTest { + val api = FakeSpoilerSettingsApi() + val store = storeFor(api) + store.refresh() + val finishWrite = CompletableDeferred() + api.beforePut = { finishWrite.await() } + + store.set(EpisodeSpoilerSetting.Images, true) + runCurrent() + store.refresh() + + assertTrue(store.state.value.hideImages) + finishWrite.complete(Unit) + runCurrent() + assertTrue(store.state.value.hideImages) + } + + @Test + fun `a completed write never caches its values for a later profile`() = runTest { + var profileId = "profile-1" + var delayIdentityRead = false + val finishIdentityRead = CompletableDeferred() + val api = FakeSpoilerSettingsApi() + val cache = InMemoryEpisodeSpoilerCache() + val store = storeFor(api, cache, profileId = { + if (delayIdentityRead) finishIdentityRead.await() + profileId + }) + store.refresh() + delayIdentityRead = true + store.set(EpisodeSpoilerSetting.Images, true) + runCurrent() + + profileId = "profile-2" + delayIdentityRead = false + store.clear() + store.hydrateIfNeeded() + finishIdentityRead.complete(Unit) + runCurrent() + + assertEquals(false, cache.read("https://server.test|profile-2")?.hideImages) + assertEquals(true, cache.read("https://server.test|profile-1")?.hideImages) + } + + @Test + fun `a second identity change cancels hydration and loads its own cache`() = runTest { + var serverUrl = "https://old.test" + val changes = MutableSharedFlow() + val api = FakeSpoilerSettingsApi() + val cache = InMemoryEpisodeSpoilerCache() + cache.write("https://new.test|profile-1", EpisodeSpoilerState( + EpisodeSpoilerSupport.Supported, hideImages = true, hideOverviews = true, + )) + val store = storeFor(api, cache, serverUrl = { serverUrl }, identityChanges = changes) + runCurrent() + store.refresh() + val oldHydration = CompletableDeferred() + api.beforeCapabilities = { oldHydration.await() } + changes.emit(Unit) + runCurrent() + + serverUrl = "https://new.test" + api.beforeCapabilities = { } + api.serve(revision = 17, values = bothOn) + backgroundScope.launch { changes.emit(Unit) } + runCurrent() + + assertEquals(EpisodeSpoilerPrefs(true, true), store.state.value.prefs) + } + + @Test + fun `an identity change shows its cache while another caller's refresh is in flight`() = runTest { + var serverUrl = "https://old.test" + val changes = MutableSharedFlow() + val api = FakeSpoilerSettingsApi() + val cache = InMemoryEpisodeSpoilerCache() + cache.write("https://new.test|profile-1", EpisodeSpoilerState( + EpisodeSpoilerSupport.Supported, hideImages = true, hideOverviews = true, + )) + val store = storeFor(api, cache, serverUrl = { serverUrl }, identityChanges = changes) + runCurrent() + val staleCapabilities = CompletableDeferred() + api.beforeCapabilities = { staleCapabilities.await() } + // The Watch Next worker, say, refreshing the old server. + backgroundScope.launch { store.refresh() } + runCurrent() + + serverUrl = "https://new.test" + api.beforeCapabilities = { } + backgroundScope.launch { changes.emit(Unit) } + runCurrent() + assertEquals(EpisodeSpoilerPrefs(true, true), store.state.value.prefs) + + staleCapabilities.complete(Unit) + runCurrent() + // Only the new server's refresh read the values. + assertEquals(1, api.effectiveReads.size) + assertEquals(EpisodeSpoilerPrefs.NONE, store.state.value.prefs) + } + + @Test + fun `a queued write stays pinned to the profile that made it`() = runTest { + val original = authorityFor("profile-1") + var active = original + val api = FakeSpoilerSettingsApi() + val store = storeFor(api, captureAuthority = { active }) + store.refresh() + val finishFirstWrite = CompletableDeferred() + api.beforePut = { finishFirstWrite.await() } + store.set(EpisodeSpoilerSetting.Images, true) + runCurrent() + // Queued behind the first write while the active profile changes. + store.set(EpisodeSpoilerSetting.Overviews, true) + runCurrent() + + active = authorityFor("profile-2") + api.beforePut = { } + finishFirstWrite.complete(Unit) + runCurrent() + + assertEquals(listOf(original, original), api.putAuthorities.toList()) + assertEquals(listOf(original), api.effectiveAuthorities.toList()) + } + + private fun authorityFor(profileId: String) = AuthScopeSnapshot( + serverId = "server", profileId = profileId, serverUrl = "https://server.test", profileToken = "token-$profileId", + ) + + private val bothOn = mapOf( + SettingKeys.CATALOG_HIDE_UNWATCHED_EPISODE_IMAGES to JsonPrimitive(true), + SettingKeys.CATALOG_HIDE_UNWATCHED_EPISODE_OVERVIEWS to JsonPrimitive(true), + ) + + private fun TestScope.storeFor( + api: FakeSpoilerSettingsApi, + cache: EpisodeSpoilerCache = InMemoryEpisodeSpoilerCache(), + serverUrl: suspend () -> String? = { "https://server.test" }, + profileId: suspend () -> String? = { "profile-1" }, + identityChanges: Flow = emptyFlow(), + captureAuthority: suspend () -> AuthScopeSnapshot? = { null }, + ) = DefaultEpisodeSpoilerStore.forTest( + repository = SettingsRepository(api), + scope = backgroundScope, + getActiveProfileId = profileId, + getServerUrl = serverUrl, + captureAuthority = captureAuthority, + cache = cache, + identityChanges = identityChanges, + ) +} + +private class FakeSpoilerSettingsApi( + revision: Int = 17, + var capabilities: ApiResult = capabilitiesAt(revision), + private var values: Map = emptyMap(), + var failPuts: Boolean = false, + private val effectiveError: ApiResult.Error? = null, +) : SettingsApi( + org.prairieserver.prairie.network.apiv2.SettingsV2Api( + HttpClient(), + org.prairieserver.prairie.network.TokenManagerImpl(), + org.prairieserver.prairie.network.apiv2.ApiV2Gate.Unrestricted, + ), +) { + val puts = mutableListOf>() + val putAuthorities = mutableListOf() + val effectiveReads = mutableListOf>() + val effectiveAuthorities = mutableListOf() + var beforeCapabilities: suspend () -> Unit = { } + var beforePut: suspend () -> Unit = { } + + /** Become a different server: [revision] and stored [values]. */ + fun serve(revision: Int, values: Map) { + capabilities = capabilitiesAt(revision) + this.values = values + } + + override suspend fun getContractCapabilities(): ApiResult { + beforeCapabilities() + return capabilities + } + + override suspend fun getEffectiveValues( + keys: List, + libraryIds: List, + seriesIds: List, + authority: org.prairieserver.prairie.network.AuthScopeSnapshot?, + ): ApiResult { + effectiveReads += keys + effectiveAuthorities += authority + effectiveError?.let { return it } + return ApiResult.Success( + EffectiveSettingValuesResponse( + settings = keys.map { key -> + EffectiveSettingValue( + key = key, + value = values[key] ?: JsonPrimitive(false), + source = if (key in values) "profile" else "default", + ) + }, + ), + ) + } + + override suspend fun putValue( + key: String, + scope: SettingScopeIdentity, + value: JsonElement, + profileId: String?, + authority: org.prairieserver.prairie.network.AuthScopeSnapshot?, + ): ApiResult { + puts += Triple(key, scope.scope, value) + putAuthorities += authority + beforePut() + if (failPuts) return ApiResult.Error(500, "boom", "Server error") + return ApiResult.Success(StoredSettingValue(key = key, scope = scope.scope.wire)) + } +} + +private fun capabilitiesAt(revision: Int): ApiResult = ApiResult.Success( + SettingsContractCapabilities(apiVersion = 1, manifestRevision = revision, supportsBatchedEffective = true), +) diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/OverlayPrefsStoreTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/OverlayPrefsStoreTest.kt index 0d140db67d..d15071e15a 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/OverlayPrefsStoreTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/OverlayPrefsStoreTest.kt @@ -24,6 +24,7 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -45,7 +46,73 @@ class OverlayPrefsStoreTest { assertEquals(expected, store.prefs.value) assertTrue(store.hasUserOverride) - assertEquals(listOf(SettingKeys.UI_CARD_OVERLAYS), api.effectiveRequests.single()) + assertEquals( + listOf(SettingKeys.UI_CARD_OVERLAYS, SettingKeys.UI_CARD_OVERLAYS_ENABLED), + api.effectiveRequests.single(), + ) + } + + @Test + fun `profile that turned overlays off hides them although the server default is on`() = runTest { + val api = RecordingOverlaySettingsApi(profileOverlaysEnabled = false) + val store = DefaultOverlayPrefsStore(SettingsRepository(api), this) + + store.refresh() + + assertFalse(store.enabled.value) + } + + @Test + fun `profile that turned overlays on shows them although the server default is off`() = runTest { + val api = RecordingOverlaySettingsApi(profileOverlaysEnabled = true) + api.overlayEnabled = false + val store = DefaultOverlayPrefsStore(SettingsRepository(api), this) + + store.refresh() + + assertTrue(store.enabled.value) + } + + @Test + fun `profile without a choice follows the server default, including on servers without the key`() = runTest { + for (servesKey in listOf(true, false)) { + val api = RecordingOverlaySettingsApi(servesOverlaysEnabled = servesKey) + api.overlayEnabled = false + val store = DefaultOverlayPrefsStore(SettingsRepository(api), this) + + store.refresh() + + assertFalse(store.enabled.value, "servesKey=$servesKey") + assertEquals(null, store.lastError.value, "servesKey=$servesKey") + } + } + + @Test + fun `profile choice made elsewhere applies on the next refresh`() = runTest { + val api = RecordingOverlaySettingsApi() + val store = DefaultOverlayPrefsStore(SettingsRepository(api), this) + store.refresh() + assertTrue(store.enabled.value) + + api.profileOverlaysEnabled = false + store.refresh() + assertFalse(store.enabled.value) + + api.profileOverlaysEnabled = null + store.refresh() + assertTrue(store.enabled.value) + } + + @Test + fun `clear forgets the previous profile's overlay choice`() = runTest { + val api = RecordingOverlaySettingsApi(profileOverlaysEnabled = false) + val store = DefaultOverlayPrefsStore(SettingsRepository(api), this) + store.refresh() + assertFalse(store.enabled.value) + + store.clear() + + assertTrue(store.enabled.value) } @Test @@ -295,6 +362,10 @@ class OverlayPrefsStoreTest { private class RecordingOverlaySettingsApi( var storedValue: JsonElement? = null, var adminDefaults: String? = null, + var profileOverlaysEnabled: Boolean? = null, + // A server below the revision that introduced ui.card_overlays_enabled + // omits the key from the response. + val servesOverlaysEnabled: Boolean = true, ) : SettingsApi(org.prairieserver.prairie.network.apiv2.SettingsV2Api(HttpClient(), org.prairieserver.prairie.network.TokenManagerImpl(), org.prairieserver.prairie.network.apiv2.ApiV2Gate.Unrestricted)) { data class CallGate( @@ -345,30 +416,37 @@ private class RecordingOverlaySettingsApi( ): ApiResult { effectiveRequests += keys val value = storedValue + val enabled = profileOverlaysEnabled nextEffectiveReadGate?.also { gate -> nextEffectiveReadGate = null gate.started.complete(Unit) gate.release.await() } + val settings = buildList { + add(effective(SettingKeys.UI_CARD_OVERLAYS, value)) + if (servesOverlaysEnabled && SettingKeys.UI_CARD_OVERLAYS_ENABLED in keys) { + add(effective(SettingKeys.UI_CARD_OVERLAYS_ENABLED, enabled?.let(::JsonPrimitive))) + } + } return ApiResult.Success( EffectiveSettingValuesResponse( - settings = listOf( - EffectiveSettingValue( - key = SettingKeys.UI_CARD_OVERLAYS, - value = value ?: JsonNull, - source = if (value == null) { - EffectiveSettingValue.SOURCE_DEFAULT - } else { - SettingScope.PROFILE.wire - }, - scope = SettingScope.PROFILE.wire.takeIf { value != null }, - ), - ), + settings = settings, revision = SettingKeys.REVISION, ), ) } + private fun effective(key: String, value: JsonElement?) = EffectiveSettingValue( + key = key, + value = value ?: JsonNull, + source = if (value == null) { + EffectiveSettingValue.SOURCE_DEFAULT + } else { + SettingScope.PROFILE.wire + }, + scope = SettingScope.PROFILE.wire.takeIf { value != null }, + ) + override suspend fun putValue( key: String, scope: SettingScopeIdentity, diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/ServerDrivenConfigRefresherTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/ServerDrivenConfigRefresherTest.kt index 8c82e3fa72..21d403a957 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/ServerDrivenConfigRefresherTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/ServerDrivenConfigRefresherTest.kt @@ -262,6 +262,6 @@ private class FakePlayerSettingsStore : PlayerSettingsStore { } override suspend fun setSubtitleDeviceOverrideEnabled(enabled: Boolean) = Unit override suspend fun resetDeviceSetting(key: String) = Unit - override suspend fun resetAllDeviceSettings() = Unit + override suspend fun resetAllDeviceSettings() = true override suspend fun flushPendingDeviceSettings() = Unit } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/ServerSettingsFlusherTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/ServerSettingsFlusherTest.kt index 7fc98b5fe9..f4767015d4 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/ServerSettingsFlusherTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/ServerSettingsFlusherTest.kt @@ -4,6 +4,7 @@ import org.prairieserver.prairie.model.settings.PlaybackSettingsKeys import org.prairieserver.prairie.model.settings.SettingKeys import org.prairieserver.prairie.model.settings.SettingScope import org.prairieserver.prairie.model.settings.SettingScopeIdentity +import org.prairieserver.prairie.model.settings.SettingsContractCapabilities import org.prairieserver.prairie.model.settings.StoredSettingValue import org.prairieserver.prairie.model.settings.SubtitleAppearance import org.prairieserver.prairie.network.ApiResult @@ -19,6 +20,7 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertTrue @@ -502,6 +504,76 @@ class ServerSettingsFlusherTest { assertEquals(0, api.calls.size, "non-remote keys would 404 as unknown_setting; drop locally") } + + // ---- Settings revision gate for subtitle text opacity ------------------- + + private val translucentText = SubtitleAppearance.DEFAULT.copy(textOpacity = 40).toJsonString() + + private fun sentAppearance(api: RecordingSettingsApi): JsonObject = + api.calls.last { it.key == objectKey }.value as JsonObject + + @Test + fun `subtitle appearance omits textOpacity for a server below revision 14`() = runTest { + val api = RecordingSettingsApi().apply { manifestRevision(13) } + val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) + + flusher.enqueue("p1", objectKey, translucentText, serverUrl) + advanceUntilIdle() + + val sent = sentAppearance(api) + assertFalse(SubtitleAppearance.TEXT_OPACITY_FIELD in sent, "a revision 13 server rejects the whole object") + assertEquals(JsonPrimitive("#ffffff"), sent["fontColor"], "every other field still goes up") + } + + @Test + fun `subtitle appearance keeps textOpacity for a revision 14 server`() = runTest { + val api = RecordingSettingsApi().apply { manifestRevision(14) } + val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) + + flusher.enqueue("p1", objectKey, translucentText, serverUrl) + advanceUntilIdle() + + assertEquals(JsonPrimitive(40), sentAppearance(api)[SubtitleAppearance.TEXT_OPACITY_FIELD]) + } + + @Test + fun `subtitle appearance is held while the revision is unknown, then sent whole`() = runTest { + val api = RecordingSettingsApi().apply { + capabilities = ApiResult.NetworkError(IllegalStateException("offline")) + } + val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) + + flusher.enqueue("p1", objectKey, translucentText, serverUrl) + flusher.enqueue("p1", stringKey, "720p", serverUrl) + flusher.flushNow() + + // Neither stripped nor whole: stripped could reset a revision-14 + // server's stored value, whole would be refused by an older one. + assertTrue(api.calls.none { it.key == objectKey }) + assertEquals(setOf(objectKey), flusher.pendingKeys("p1"), "held, not dropped") + assertTrue(api.calls.any { it.key == stringKey }, "the hold is per key; other writes still land") + + // Queued before the revision was known; gated when it is sent. + api.manifestRevision(14) + flusher.flushNow() + + assertEquals(JsonPrimitive(40), sentAppearance(api)[SubtitleAppearance.TEXT_OPACITY_FIELD]) + assertTrue(flusher.pendingKeys("p1").isEmpty()) + } + + @Test + fun `the revision is probed once per server, not per write`() = runTest { + val api = RecordingSettingsApi().apply { manifestRevision(13) } + val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) + + flusher.enqueue("p1", objectKey, translucentText, serverUrl) + flusher.flushNow() + flusher.enqueue("p1", objectKey, SubtitleAppearance.DEFAULT.copy(textOpacity = 60).toJsonString(), serverUrl) + flusher.flushNow() + + assertEquals(1, api.capabilityCalls) + assertEquals(2, api.calls.count { it.key == objectKey }) + } } /** @@ -522,6 +594,19 @@ private class RecordingSettingsApi : SettingsApi(org.prairieserver.prairie.netwo val calls = mutableListOf() + /** What the settings capabilities probe answers; the generated revision by default. */ + var capabilities: ApiResult = capabilitiesAt(SettingKeys.REVISION) + var capabilityCalls = 0 + + fun manifestRevision(revision: Int) { + capabilities = capabilitiesAt(revision) + } + + override suspend fun getContractCapabilities(): ApiResult { + capabilityCalls++ + return capabilities + } + /** * Runs while a call is "in flight", before its result is returned — the * hook for simulating the user editing the same setting during a flush. @@ -534,6 +619,11 @@ private class RecordingSettingsApi : SettingsApi(org.prairieserver.prairie.netwo private var deleteFailuresRemaining = 0 private var deleteFailure: ApiResult? = null + private fun capabilitiesAt(revision: Int): ApiResult = + ApiResult.Success( + SettingsContractCapabilities(apiVersion = 1, manifestRevision = revision, supportsBatchedEffective = true), + ) + fun failNextPuts(count: Int, failure: ApiResult) { putFailuresRemaining = count putFailure = failure diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/SettingsContractRevisionTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/SettingsContractRevisionTest.kt new file mode 100644 index 0000000000..ccae18e1d0 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/SettingsContractRevisionTest.kt @@ -0,0 +1,54 @@ +package org.prairieserver.prairie.common.settings + +import org.prairieserver.prairie.model.settings.SettingsContractCapabilities +import org.prairieserver.prairie.network.ApiResult +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class SettingsContractRevisionTest { + + private fun capabilities(revision: Int) = + ApiResult.Success(SettingsContractCapabilities(apiVersion = 1, manifestRevision = revision)) + + @Test + fun cachesTheRevisionForTheServerThatAnswered() = runTest { + var probes = 0 + val revision = SettingsContractRevision( + fetchCapabilities = { probes++; capabilities(14) }, + getServerUrl = { "https://a.example" }, + ) + + assertEquals(14, revision.revisionFor("https://a.example")) + assertEquals(14, revision.revisionFor("https://a.example")) + assertEquals(1, probes) + } + + @Test + fun discardsAnAnswerWhenTheServerChangesDuringTheFetch() = runTest { + var active = "https://old.example" + val revision = SettingsContractRevision( + fetchCapabilities = { + // The user switched servers while this request was in flight, + // so it reached the new server. + active = "https://new.example" + capabilities(14) + }, + getServerUrl = { active }, + ) + + assertNull(revision.revisionFor("https://old.example")) + assertNull(revision.known.value) + } + + @Test + fun treatsAMissingCapabilitiesRouteAsAnOldServer() = runTest { + val revision = SettingsContractRevision( + fetchCapabilities = { ApiResult.Error(404, "not_found", "") }, + getServerUrl = { "https://a.example" }, + ) + + assertEquals(0, revision.revisionFor("https://a.example")) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/TitleArtStoreTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/TitleArtStoreTest.kt new file mode 100644 index 0000000000..0e05c34aad --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/TitleArtStoreTest.kt @@ -0,0 +1,673 @@ +package org.prairieserver.prairie.common.settings + +import org.prairieserver.prairie.domain.settings.TitleArtController +import org.prairieserver.prairie.domain.settings.TitleArtPreference +import org.prairieserver.prairie.model.settings.EffectiveSettingValue +import org.prairieserver.prairie.model.settings.EffectiveSettingValuesResponse +import org.prairieserver.prairie.model.settings.SettingKeys +import org.prairieserver.prairie.model.settings.SettingScope +import org.prairieserver.prairie.model.settings.SettingScopeIdentity +import org.prairieserver.prairie.model.settings.SettingsContractCapabilities +import org.prairieserver.prairie.model.settings.StoredSettingValue +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.api.SettingsApi +import org.prairieserver.prairie.repository.SettingsRepository +import io.ktor.client.HttpClient +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class TitleArtStoreTest { + + @Test + fun `before any answer title surfaces keep logos`() { + assertTrue(TitleArtState().showTitleArt) + assertTrue(TitleArtState(TitleArtSupport.Unavailable).showTitleArt) + // An older server's answer never turns logos off, whatever is stored. + assertTrue( + TitleArtState(TitleArtSupport.Unsupported, TitleArtPreference(showTitleArt = false)).showTitleArt, + ) + } + + @Test + fun `refresh adopts the device's effective value and source`() = runTest { + val server = FakeTitleArtServer(profileValue = false) + val store = storeFor(server) + + store.refresh() + + assertEquals( + TitleArtState( + TitleArtSupport.Supported, + TitleArtPreference(showTitleArt = false, appliesToAllDevices = true), + isConfirmed = true, + ), + store.state.value, + ) + assertFalse(store.state.value.showTitleArt) + } + + @Test + fun `an older server hides the setting and is never written`() = runTest { + val server = FakeTitleArtServer( + capabilities = ApiResult.Success(SUPPORTED.copy(manifestRevision = 15)), + ) + val store = storeFor(server) + + store.refresh() + store.setShowTitleArt(false) + store.setAppliesToAllDevices(true) + runCurrent() + + assertEquals(TitleArtSupport.Unsupported, store.state.value.support) + assertFalse(store.state.value.isSupported) + assertTrue(store.state.value.showTitleArt) + assertTrue(server.calls.isEmpty()) + } + + @Test + fun `the main switch writes this device and shows the change at once`() = runTest { + val server = FakeTitleArtServer() + server.putGate = CompletableDeferred() + val store = storeFor(server) + store.refresh() + + store.setShowTitleArt(false) + // Optimistic: surfaces switch to text before the write lands. + assertFalse(store.state.value.showTitleArt) + + server.putGate!!.complete(Unit) + runCurrent() + + assertEquals(listOf("put profile_device false"), server.calls) + assertEquals(TitleArtPreference(showTitleArt = false, appliesToAllDevices = false), store.state.value.preference) + } + + @Test + fun `while applied to all devices the main switch writes the profile`() = runTest { + val server = FakeTitleArtServer(profileValue = true) + val store = storeFor(server) + store.refresh() + + store.setShowTitleArt(false) + runCurrent() + + assertEquals(listOf("put profile false"), server.calls) + assertEquals(TitleArtPreference(showTitleArt = false, appliesToAllDevices = true), store.state.value.preference) + } + + @Test + fun `apply to all devices carries the value on screen both ways`() = runTest { + val server = FakeTitleArtServer(deviceValue = false) + val store = storeFor(server) + store.refresh() + + store.setAppliesToAllDevices(true) + runCurrent() + assertEquals(listOf("put profile false"), server.calls) + assertEquals(TitleArtPreference(showTitleArt = false, appliesToAllDevices = true), store.state.value.preference) + + store.setAppliesToAllDevices(false) + runCurrent() + assertEquals( + listOf("put profile false", "put profile_device false", "delete profile"), + server.calls, + ) + assertNull(server.profileValue) + assertEquals(TitleArtPreference(showTitleArt = false, appliesToAllDevices = false), store.state.value.preference) + } + + @Test + fun `a failed write restores the last confirmed state`() = runTest { + val server = FakeTitleArtServer() + val store = storeFor(server) + store.refresh() + server.failPuts = true + + store.setShowTitleArt(false) + runCurrent() + + assertTrue(store.state.value.showTitleArt) + // Settings shows the failure under the switches until a save succeeds. + assertEquals("Couldn't save title art: Server error", store.saveError.value) + + server.failPuts = false + store.setShowTitleArt(false) + runCurrent() + assertFalse(store.state.value.showTitleArt) + assertNull(store.saveError.value) + } + + @Test + fun `the cached answer paints before the network does`() = runTest { + val cache = InMemoryTitleArtCache() + storeFor(FakeTitleArtServer(deviceValue = false), cache).refresh() + + val offline = FakeTitleArtServer(capabilities = ApiResult.NetworkError(RuntimeException("offline"))) + val coldStart = storeFor(offline, cache) + coldStart.hydrateIfNeeded() + + assertEquals(TitleArtSupport.Supported, coldStart.state.value.support) + assertFalse(coldStart.state.value.showTitleArt) + } + + @Test + fun `a refresh during a pending write cannot reroute the next toggle`() = runTest { + // Title art on, this device only. + val server = FakeTitleArtServer(deviceValue = true) + val store = storeFor(server) + store.refresh() + + // Apply to all devices; hold its PUT profile=true in flight. + server.putGate = CompletableDeferred() + store.setAppliesToAllDevices(true) + runCurrent() + assertEquals(listOf("put profile true"), server.calls) + + // Settings reopens / the app foregrounds: the server still answers + // with the pre-write value (device scope, not all devices). + store.refresh() + assertTrue(store.state.value.appliesToAllDevices) + + // Turning title art off must address the profile value, not this device. + store.setShowTitleArt(false) + server.putGate!!.complete(Unit) + runCurrent() + + assertEquals(listOf("put profile true", "put profile false"), server.calls) + assertEquals(false, server.profileValue) + assertEquals(TitleArtPreference(showTitleArt = false, appliesToAllDevices = true), store.state.value.preference) + } + + @Test + fun `a stalled hydration never holds back the next server`() = runTest { + var server = "https://a.test" + val stalled = CompletableDeferred() + val api = FakeTitleArtServer() + api.capabilitiesFor = { if (server == "https://a.test") stalled.await() } + val cache = InMemoryTitleArtCache().apply { + write("https://a.test|profile-1", TitleArtState(TitleArtSupport.Supported, TitleArtPreference(showTitleArt = true))) + } + val identities = MutableSharedFlow(extraBufferCapacity = 8) + val store = DefaultTitleArtStore.forTest( + controller = TitleArtController(SettingsRepository(api)), + scope = backgroundScope, + getServerUrl = { server }, + cache = cache, + identityChanges = identities, + ) + runCurrent() + + // Server A: the identity collector and another caller both hydrate + // against a capabilities request that never answers. + identities.tryEmit(Unit) + backgroundScope.launch { store.refresh() } + runCurrent() + assertEquals(TitleArtSupport.Supported, store.state.value.support) + assertTrue(store.state.value.showTitleArt) + + // Switch to server B, where this device has title art off. + server = "https://b.test" + api.deviceValue = false + identities.tryEmit(Unit) + runCurrent() + + assertEquals(TitleArtSupport.Supported, store.state.value.support) + assertFalse(store.state.value.showTitleArt) + + // A's answer arriving late must not repaint B. + api.deviceValue = true + stalled.complete(Unit) + runCurrent() + assertFalse(store.state.value.showTitleArt) + } + + @Test + fun `a write stalled on the previous server never holds back the next server's save`() = runTest { + var server = "https://a.test" + val api = FakeTitleArtServer() + val identities = MutableSharedFlow(extraBufferCapacity = 8) + val store = DefaultTitleArtStore.forTest( + controller = TitleArtController(SettingsRepository(api)), + scope = backgroundScope, + getServerUrl = { server }, + identityChanges = identities, + ) + runCurrent() + store.refresh() + + // Server A: apply to all devices, with its PUT held on the wire. + val heldOnA = CompletableDeferred() + api.putGate = heldOnA + store.setAppliesToAllDevices(true) + runCurrent() + assertEquals(listOf("put profile true"), api.calls) + + // Switch to server B and let it hydrate. + api.putGate = null + server = "https://b.test" + identities.tryEmit(Unit) + runCurrent() + assertTrue(store.state.value.isSupported) + + // B's change goes out while A's write is still held. + store.setShowTitleArt(false) + runCurrent() + assertEquals(listOf("put profile true", "put profile_device false"), api.calls) + assertEquals(TitleArtPreference(showTitleArt = false, appliesToAllDevices = false), store.state.value.preference) + + // A's write finishing late leaves B's state alone. + heldOnA.complete(Unit) + runCurrent() + assertEquals(TitleArtPreference(showTitleArt = false, appliesToAllDevices = false), store.state.value.preference) + assertNull(store.saveError.value) + } + + @Test + fun `one title art change stays on the profile it started on`() = runTest { + var activeProfile = "profile-1" + val server = FakeTitleArtServer(profileValue = true) + val store = DefaultTitleArtStore.forTest( + controller = TitleArtController(SettingsRepository(server)), + scope = backgroundScope, + getActiveProfileId = { activeProfile }, + ) + store.refresh() + server.authorities.clear() + // The profile switches while the device PUT is on the wire. + server.afterPut = { activeProfile = "profile-2" } + + store.setAppliesToAllDevices(false) + runCurrent() + + assertEquals(listOf("put profile_device true", "delete profile"), server.calls) + assertEquals( + listOf>( + "put" to "profile-1", + "delete" to "profile-1", + "effective" to "profile-1", + ), + server.authorities, + ) + } + + @Test + fun `a change queued behind a failed write is dropped, not sent at the wrong scope`() = runTest { + // Title art on, this device only. + val server = FakeTitleArtServer(deviceValue = true) + val store = storeFor(server) + store.refresh() + + // Apply to all devices (held in flight), then turn title art off + // while the first write is still pending. + server.putGate = CompletableDeferred() + store.setAppliesToAllDevices(true) + store.setShowTitleArt(false) + runCurrent() + + // The first PUT fails. + server.failPuts = true + server.putGate!!.complete(Unit) + runCurrent() + + // The queued change was built on the failed one's unsaved state; it + // must not create a profile-wide value. + assertEquals(listOf("put profile true"), server.calls) + assertNull(server.profileValue) + assertEquals(TitleArtPreference(showTitleArt = true, appliesToAllDevices = false), store.state.value.preference) + assertEquals("Couldn't save title art: Server error", store.saveError.value) + + // A new change after the failure goes through normally. + server.failPuts = false + store.setShowTitleArt(false) + runCurrent() + assertEquals(listOf("put profile true", "put profile_device false"), server.calls) + assertFalse(store.state.value.showTitleArt) + } + + @Test + fun `a reset during the identity read never pairs the old profile with the new generation`() = runTest { + var activeProfile = "profile-a" + val identityRead = CompletableDeferred() + var holdNextIdentityRead = true + val cache = InMemoryTitleArtCache().apply { + // Profile A had title art off. + write( + "https://server.test|profile-a", + TitleArtState(TitleArtSupport.Supported, TitleArtPreference(showTitleArt = false)), + ) + } + val server = FakeTitleArtServer(capabilities = ApiResult.NetworkError(RuntimeException("offline"))) + val store = DefaultTitleArtStore.forTest( + controller = TitleArtController(SettingsRepository(server)), + scope = backgroundScope, + getActiveProfileId = { + val profile = activeProfile + if (holdNextIdentityRead) { + holdNextIdentityRead = false + identityRead.await() + } + profile + }, + cache = cache, + ) + + // A refresh reads profile A's identity and stalls there. + backgroundScope.launch { store.refresh() } + runCurrent() + + // The profile switches to B (which has nothing cached) before it returns. + activeProfile = "profile-b" + store.clear() + identityRead.complete(Unit) + runCurrent() + + // Profile A's cached "off" must not be painted for profile B. + assertTrue(store.state.value.showTitleArt) + assertFalse(store.state.value.isSupported) + } + + @Test + fun `a cached answer is shown but not written until the server re-reads it`() = runTest { + // Cached: this device only. Since then another device applied "off" to all devices. + val cache = InMemoryTitleArtCache().apply { + write("https://server.test|profile-1", TitleArtState(TitleArtSupport.Supported, TitleArtPreference())) + } + val server = FakeTitleArtServer(profileValue = false) + val held = CompletableDeferred() + server.capabilitiesFor = { held.await() } + val store = storeFor(server, cache) + backgroundScope.launch { store.hydrateIfNeeded() } + runCurrent() + assertTrue(store.state.value.isSupported) + assertFalse(store.state.value.canEdit) + + // A toggle before the read lands would pick profile_device from the stale cache. + store.setShowTitleArt(false) + runCurrent() + assertTrue(server.calls.isEmpty()) + assertNull(store.saveError.value) + + held.complete(Unit) + runCurrent() + assertTrue(store.state.value.canEdit) + store.setShowTitleArt(true) + runCurrent() + assertEquals(listOf("put profile true"), server.calls) + } + + @Test + fun `a remote playback overlay on the same profile does not block a change`() = runTest { + // Shaped like the token manager's snapshots: the saved account carries + // a credential epoch, an overlay carries a generation id and epoch 0. + val saved = AuthScopeSnapshot( + serverId = "https://server.test", + profileId = "profile-1", + serverUrl = "https://server.test", + profileToken = "token-profile-1", + identityGeneration = 1L, + isIdentityGenerationStamped = true, + credentialEpoch = 3L, + ) + val overlay = saved.copy( + profileToken = null, + credentialGenerationId = "overlay-1", + identityGeneration = 2L, + credentialEpoch = 0L, + ) + var current = saved + val server = FakeTitleArtServer() + val store = DefaultTitleArtStore.forTest( + controller = TitleArtController(SettingsRepository(server)), + scope = backgroundScope, + getAuthScope = { current }, + ) + store.refresh() + + // A cast session for the same profile begins after the read. + current = overlay + store.setShowTitleArt(false) + runCurrent() + // It ends again before the next change. + current = saved.copy(identityGeneration = 3L) + store.setShowTitleArt(true) + runCurrent() + + assertEquals(listOf("put profile_device false", "put profile_device true"), server.calls) + assertTrue(store.state.value.showTitleArt) + assertNull(store.saveError.value) + } + + @Test + fun `a change read before the overlay ends still saves after it`() = runTest { + val saved = AuthScopeSnapshot( + serverId = "https://server.test", + profileId = "profile-1", + serverUrl = "https://server.test", + profileToken = "token-profile-1", + identityGeneration = 2L, + isIdentityGenerationStamped = true, + credentialEpoch = 3L, + ) + var current = saved.copy( + profileToken = null, + credentialGenerationId = "overlay-1", + credentialEpoch = 0L, + ) + val server = FakeTitleArtServer() + val store = DefaultTitleArtStore.forTest( + controller = TitleArtController(SettingsRepository(server)), + scope = backgroundScope, + getAuthScope = { current }, + ) + // The read lands while the overlay is active. + store.refresh() + + current = saved.copy(identityGeneration = 3L) + store.setShowTitleArt(false) + runCurrent() + + assertEquals(listOf("put profile_device false"), server.calls) + assertNull(store.saveError.value) + } + + @Test + fun `a new sign-in on the same profile blocks a change read before it`() = runTest { + val before = AuthScopeSnapshot( + serverId = "https://server.test", + profileId = "profile-1", + serverUrl = "https://server.test", + profileToken = "token-profile-1", + identityGeneration = 1L, + isIdentityGenerationStamped = true, + credentialEpoch = 3L, + ) + var current = before + val server = FakeTitleArtServer() + val store = DefaultTitleArtStore.forTest( + controller = TitleArtController(SettingsRepository(server)), + scope = backgroundScope, + getAuthScope = { current }, + ) + store.refresh() + + current = before.copy(identityGeneration = 3L, credentialEpoch = 5L) + store.setShowTitleArt(false) + runCurrent() + + assertTrue(server.calls.isEmpty()) + assertEquals( + DefaultTitleArtStore.saveErrorText(DefaultTitleArtStore.IDENTITY_CHANGED), + store.saveError.value, + ) + } + + @Test + fun `the answer is cached for the profile the load was pinned to`() = runTest { + val cache = InMemoryTitleArtCache() + val server = FakeTitleArtServer(deviceValue = false) + val store = DefaultTitleArtStore.forTest( + controller = TitleArtController(SettingsRepository(server)), + scope = backgroundScope, + // The active scope is a cast overlay for another profile than the + // saved account's profile-1. + getAuthScope = { + AuthScopeSnapshot( + serverId = "https://server.test", + profileId = "phone-profile", + serverUrl = "https://server.test", + profileToken = null, + credentialGenerationId = "overlay-1", + ) + }, + cache = cache, + ) + store.refresh() + + assertEquals(listOf>("effective" to "phone-profile"), server.authorities) + assertNull(cache.read("https://server.test|profile-1")) + assertFalse(cache.read("https://server.test|phone-profile")!!.preference.showTitleArt) + } + + @Test + fun `concurrent startup loads share one round trip`() = runTest { + val server = FakeTitleArtServer() + val held = CompletableDeferred() + server.capabilitiesFor = { held.await() } + val store = storeFor(server) + + repeat(3) { backgroundScope.launch { store.hydrateIfNeeded() } } + backgroundScope.launch { store.refresh() } + runCurrent() + held.complete(Unit) + runCurrent() + + assertEquals(1, server.capabilityRequests) + assertTrue(store.state.value.canEdit) + } + + @Test + fun `clear drops the previous profile's answer`() = runTest { + val store = storeFor(FakeTitleArtServer(deviceValue = false)) + store.refresh() + + store.clear() + + assertEquals(TitleArtState(), store.state.value) + } + + private fun TestScope.storeFor( + server: FakeTitleArtServer, + cache: TitleArtCache = InMemoryTitleArtCache(), + ) = DefaultTitleArtStore.forTest( + controller = TitleArtController(SettingsRepository(server)), + scope = backgroundScope, + cache = cache, + ) + + private companion object { + val SUPPORTED = SettingsContractCapabilities( + apiVersion = 1, + manifestRevision = 16, + supportsBatchedEffective = true, + ) + } + + /** Stores `ui.title_art` per scope and resolves profile → profile_device → default. */ + private class FakeTitleArtServer( + var capabilities: ApiResult = ApiResult.Success(SUPPORTED), + var profileValue: Boolean? = null, + var deviceValue: Boolean? = null, + ) : SettingsApi( + org.prairieserver.prairie.network.apiv2.SettingsV2Api( + HttpClient(), + org.prairieserver.prairie.network.TokenManagerImpl(), + org.prairieserver.prairie.network.apiv2.ApiV2Gate.Unrestricted, + ), + ) { + val calls = mutableListOf() + var failPuts = false + var putGate: CompletableDeferred? = null + + /** The profile each request was pinned to, in order. */ + val authorities = mutableListOf>() + var afterPut: () -> Unit = {} + + /** Runs before each capabilities answer; a test can suspend it. */ + var capabilitiesFor: suspend () -> Unit = {} + var capabilityRequests = 0 + + override suspend fun getContractCapabilities(): ApiResult { + capabilityRequests += 1 + capabilitiesFor() + return capabilities + } + + override suspend fun getEffectiveValues( + keys: List, + libraryIds: List, + seriesIds: List, + authority: org.prairieserver.prairie.network.AuthScopeSnapshot?, + ): ApiResult { + authorities += "effective" to authority?.profileId + val (value, source) = profileValue?.let { it to "profile" } + ?: deviceValue?.let { it to "profile_device" } + ?: (true to EffectiveSettingValue.SOURCE_DEFAULT) + return ApiResult.Success( + EffectiveSettingValuesResponse( + settings = listOf( + EffectiveSettingValue(SettingKeys.UI_TITLE_ART, JsonPrimitive(value), source), + ), + ), + ) + } + + override suspend fun putValue( + key: String, + scope: SettingScopeIdentity, + value: JsonElement, + profileId: String?, + authority: org.prairieserver.prairie.network.AuthScopeSnapshot?, + ): ApiResult { + val bool = (value as JsonPrimitive).content.toBooleanStrict() + calls += "put ${scope.scope.wire} $bool" + authorities += "put" to authority?.profileId + putGate?.await() + afterPut() + if (failPuts) return ApiResult.Error(500, "boom", "Server error") + when (scope.scope) { + SettingScope.PROFILE -> profileValue = bool + SettingScope.PROFILE_DEVICE -> deviceValue = bool + else -> Unit + } + return ApiResult.Success(StoredSettingValue(key = key, scope = scope.scope.wire)) + } + + override suspend fun deleteValue( + key: String, + scope: SettingScopeIdentity, + profileId: String?, + authority: org.prairieserver.prairie.network.AuthScopeSnapshot?, + ): ApiResult { + calls += "delete ${scope.scope.wire}" + authorities += "delete" to authority?.profileId + if (scope.scope == SettingScope.PROFILE) { + val existed = profileValue != null + profileValue = null + if (!existed) return ApiResult.Error(404, "not_found", "Not Found") + } + return ApiResult.Success(Unit) + } + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/util/LanguageNamesTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/ui/LanguageNamesTest.kt similarity index 97% rename from androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/util/LanguageNamesTest.kt rename to android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/ui/LanguageNamesTest.kt index e7cfa08e08..d6a870fc91 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/util/LanguageNamesTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/ui/LanguageNamesTest.kt @@ -1,4 +1,4 @@ -package org.prairieserver.prairie.android.ui.util +package org.prairieserver.prairie.common.ui import kotlin.test.Test import kotlin.test.assertEquals diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/ui/components/DefaultArtworkKindTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/ui/components/DefaultArtworkKindTest.kt new file mode 100644 index 0000000000..1591c57384 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/ui/components/DefaultArtworkKindTest.kt @@ -0,0 +1,30 @@ +package org.prairieserver.prairie.common.ui.components + +import kotlin.test.Test +import kotlin.test.assertEquals + +// Apple and web map the same types to the same marks; a change here should +// land on all three clients. +class DefaultArtworkKindTest { + private fun assertKind(expected: DefaultArtworkKind, vararg types: String?) { + for (type in types) { + assertEquals(expected, DefaultArtworkKind.forItemType(type), "type=$type") + } + } + + @Test + fun seriesSeasonsAndEpisodesGetTheTvMark() = + assertKind(DefaultArtworkKind.Tv, "series", "season", "episode", " Episode ") + + @Test + fun audiobooksAndPodcastsGetTheHeadphonesMark() = + assertKind(DefaultArtworkKind.Audiobook, "audiobook", "podcast", "podcasts", "PODCAST") + + @Test + fun readingTypesGetTheBookMark() = + assertKind(DefaultArtworkKind.Book, "book", "ebook", "ebooks", "comic", "comics", "manga", "EBook") + + @Test + fun moviesAndUnknownTypesGetTheFilmMark() = + assertKind(DefaultArtworkKind.Video, "movie", "books", "audiobooks", "", null, "something-new") +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/ui/marquee/ServerBrandingTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/ui/marquee/ServerBrandingTest.kt new file mode 100644 index 0000000000..599451de61 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/ui/marquee/ServerBrandingTest.kt @@ -0,0 +1,46 @@ +package org.prairieserver.prairie.common.ui.marquee + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** Branding asset URLs are read before sign-in, so only the server's own origin is accepted. */ +class ServerBrandingTest { + @Test + fun resolvesSiteRelativePathsAgainstTheServer() { + assertEquals( + "https://media.example.com/branding/mark.png", + ServerBranding.resolveAsset("/branding/mark.png", "https://media.example.com"), + ) + assertEquals( + "https://media.example.com/prairie/branding/mark.png", + ServerBranding.resolveAsset("branding/mark.png", "https://media.example.com/prairie"), + ) + } + + @Test + fun acceptsTheSameOriginWithTheDefaultPortSpelledOut() { + assertEquals( + "https://media.example.com:443/mark.png", + ServerBranding.resolveAsset("https://media.example.com:443/mark.png", "https://media.example.com"), + ) + } + + @Test + fun rejectsOtherHostsSchemesAndPorts() { + val base = "https://media.example.com" + assertNull(ServerBranding.resolveAsset("https://evil.example.net/mark.png", base)) + assertNull(ServerBranding.resolveAsset("http://media.example.com/mark.png", base)) + assertNull(ServerBranding.resolveAsset("https://media.example.com:8443/mark.png", base)) + // A protocol-relative path stays on the server, as a path. + assertEquals("https://media.example.com/evil.example.net/mark.png", ServerBranding.resolveAsset("//evil.example.net/mark.png", base)) + assertNull(ServerBranding.resolveAsset("javascript:alert(1)", base)) + assertNull(ServerBranding.resolveAsset(" ", base)) + } + + @Test + fun clampsAccentsToALegibleTint() { + assertNull(MarqueeScene.clampedAccent("not a color")) + assertNull(MarqueeScene.clampedAccent("#12345")) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/watchparty/WatchPartyExperimentTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/watchparty/WatchPartyExperimentTest.kt new file mode 100644 index 0000000000..4cc9895311 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/watchparty/WatchPartyExperimentTest.kt @@ -0,0 +1,38 @@ +package org.prairieserver.prairie.common.watchparty + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WatchPartyExperimentTest { + private var stored: Boolean? = null + private var departures = 0 + + private fun experiment(defaultEnabled: Boolean) = + WatchPartyExperiment({ stored }, { stored = it }, defaultEnabled) { departures++ } + + @Test + fun `release builds default off and debug builds default on`() { + assertFalse(experiment(defaultEnabled = false).enabled.value) + assertTrue(experiment(defaultEnabled = true).enabled.value) + } + + @Test + fun `the stored choice beats the build default`() { + stored = true + assertTrue(experiment(defaultEnabled = false).enabled.value) + stored = false + assertFalse(experiment(defaultEnabled = true).enabled.value) + } + + @Test + fun `turning it off leaves the party once`() { + val toggle = experiment(defaultEnabled = true) + toggle.setEnabled(false) + toggle.setEnabled(false) + toggle.setEnabled(true) + assertEquals(1, departures) + assertEquals(true, stored) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/watchparty/WatchPartyServerMatchTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/watchparty/WatchPartyServerMatchTest.kt new file mode 100644 index 0000000000..9dddbb78e2 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/watchparty/WatchPartyServerMatchTest.kt @@ -0,0 +1,39 @@ +package org.prairieserver.prairie.common.watchparty + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WatchPartyServerMatchTest { + @Test + fun `an invitation matches the active server by scheme, host, port, and path`() { + assertTrue(watchPartyServerMatches("https://media.example.com", "https://media.example.com")) + assertTrue(watchPartyServerMatches("https://Media.Example.com/", "https://media.example.com")) + assertTrue(watchPartyServerMatches("HTTPS://media.example.com", "https://media.example.com")) + assertTrue(watchPartyServerMatches("https://media.example.com/prairie/", "https://media.example.com/prairie")) + assertTrue(watchPartyServerMatches("https://media.example.com:443", "https://media.example.com")) + assertTrue(watchPartyServerMatches("http://10.0.0.5:8096", "http://10.0.0.5:8096/")) + assertTrue(watchPartyServerMatches("http://[fd00::5]:8096", "http://[FD00::5]:8096/")) + } + + @Test + fun `a different scheme, port, base path, or address is a different server`() { + assertFalse(watchPartyServerMatches("http://media.example.com", "https://media.example.com")) + assertFalse(watchPartyServerMatches("https://media.example.com:8443", "https://media.example.com")) + assertFalse(watchPartyServerMatches("https://media.example.com/prairie", "https://media.example.com")) + assertFalse(watchPartyServerMatches("https://media.example.com/Prairie", "https://media.example.com/prairie")) + assertFalse(watchPartyServerMatches("http://[fd00::5]:8096", "http://[fd00::5]")) + // A LAN address and a public address for the same server do not match. + assertFalse(watchPartyServerMatches("http://192.168.1.10:8096", "https://media.example.com")) + } + + @Test + fun `no active server, credentials, or an unparseable URL never match`() { + assertFalse(watchPartyServerMatches("https://media.example.com", null)) + assertFalse(watchPartyServerMatches("https://user@media.example.com", "https://media.example.com")) + assertFalse(watchPartyServerMatches("not a url", "https://media.example.com")) + assertFalse(watchPartyServerMatches("media.example.com", "media.example.com")) + assertFalse(watchPartyServerMatches("https://media.example.com:port", "https://media.example.com")) + assertFalse(watchPartyServerMatches("ftp://media.example.com", "ftp://media.example.com")) + } +} diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 46ee78d1be..f934050f16 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -117,6 +117,7 @@ kotlin { implementation(libs.koin.compose) implementation(libs.koin.compose.viewmodel) implementation(libs.coil.compose) + implementation(libs.zxing.core) implementation(libs.haze) implementation(libs.coil.network.okhttp) // Ktor client (download worker wiring). Previously satisfied @@ -132,6 +133,8 @@ kotlin { implementation(libs.kotlinx.coroutines.android) implementation(libs.androidx.work.runtime.ktx) implementation(libs.androidx.webkit) + implementation(libs.androidx.browser) + implementation(libs.coil.svg) implementation(libs.koin.androidx.workmanager) implementation(libs.firebase.messaging) // Google Cast (Chromecast) — phone app only. TV app must not depend diff --git a/androidApp/gradle.lockfile b/androidApp/gradle.lockfile index 2748063483..a137d06978 100644 --- a/androidApp/gradle.lockfile +++ b/androidApp/gradle.lockfile @@ -15,6 +15,7 @@ androidx.arch.core:core-common:2.2.0=allInstrumentedTestSourceSetsCompileDepende androidx.arch.core:core-runtime:2.2.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.asynclayoutinflater:asynclayoutinflater:1.0.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath androidx.autofill:autofill:1.0.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.browser:browser:1.10.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.collection:collection-jvm:1.5.0=androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.collection:collection-ktx:1.5.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.collection:collection:1.5.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath @@ -240,6 +241,7 @@ com.android.tools:annotations:31.10.1=_internal-unified-test-platform-android-de com.android.tools:common:31.10.1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle com.android.tools:desugar_jdk_libs:2.1.5=coreLibraryDesugaring com.android.tools:desugar_jdk_libs_configuration:2.1.5=coreLibraryDesugaring +com.caverock:androidsvg-aar:1.4=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath com.google.accompanist:accompanist-drawablepainter:0.36.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath com.google.android.datatransport:transport-api:3.0.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath com.google.android.datatransport:transport-api:3.1.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath @@ -308,6 +310,7 @@ com.google.testing.platform:core-proto:0.0.9-alpha03=_internal-unified-test-plat com.google.testing.platform:core:0.0.9-alpha03=_internal-unified-test-platform-core com.google.testing.platform:launcher:0.0.9-alpha03=_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-launcher com.google.testparameterinjector:test-parameter-injector:1.18=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +com.google.zxing:core:3.5.4=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath com.ibm.icu:icu4j:77.1=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath com.squareup.okhttp3:okhttp-sse:4.12.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath com.squareup.okhttp3:okhttp:4.12.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath @@ -327,6 +330,8 @@ io.coil-kt.coil3:coil-network-core-android:3.1.0=androidBenchmarkReleaseCompileC io.coil-kt.coil3:coil-network-core:3.1.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath io.coil-kt.coil3:coil-network-okhttp-jvm:3.1.0=androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath io.coil-kt.coil3:coil-network-okhttp:3.1.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.coil-kt.coil3:coil-svg-android:3.1.0=androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.coil-kt.coil3:coil-svg:3.1.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath io.coil-kt.coil3:coil:3.1.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath io.github.peerless2012:ass-kt:0.5.1=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath io.github.peerless2012:ass-media:0.5.1=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath diff --git a/androidApp/lint-baseline.xml b/androidApp/lint-baseline.xml index ee1a9ec944..5f1df567fa 100644 --- a/androidApp/lint-baseline.xml +++ b/androidApp/lint-baseline.xml @@ -4093,28 +4093,6 @@ column="25"/> - - - - - - - - + + + + + + + + + + + + + + diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/MainActivity.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/MainActivity.kt index edcc1c3243..451c41bd60 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/MainActivity.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/MainActivity.kt @@ -14,6 +14,7 @@ import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.width @@ -29,11 +30,15 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput import androidx.lifecycle.lifecycleScope import org.prairieserver.prairie.common.diagnostics.DiagnosticsLifecycleLogger import org.prairieserver.prairie.android.downloads.LEGACY_PUBLIC_DOWNLOAD_PERMISSION import org.prairieserver.prairie.android.downloads.hasLegacyPublicDownloadPermission +import org.prairieserver.prairie.android.auth.ActiveProfileReset import org.prairieserver.prairie.android.cast.PrairieCastController +import org.prairieserver.prairie.android.profiles.ProfileLaunchAction +import org.prairieserver.prairie.android.profiles.profileLaunchAction import org.prairieserver.prairie.android.push.PushNotificationPresenter import org.prairieserver.prairie.android.ui.navigation.AppNavigation import org.prairieserver.prairie.android.ui.navigation.ExternalRouteRequest @@ -48,10 +53,17 @@ import org.prairieserver.prairie.android.ui.navigation.hasLocalDownloadsForScope import org.prairieserver.prairie.android.ui.navigation.inviteClaimRouteOrNull import org.prairieserver.prairie.android.ui.navigation.notificationNavigationRouteOrNull import org.prairieserver.prairie.android.ui.navigation.shouldStartOnDownloads +import org.prairieserver.prairie.android.ui.screens.watchparty.WatchPartyHandoff +import org.prairieserver.prairie.android.ui.screens.watchparty.watchPartyAppLinkOrNull +import org.prairieserver.prairie.android.ui.screens.watchparty.watchPartyInviteRoute +import org.prairieserver.prairie.common.watchparty.WatchPartyExperiment import org.prairieserver.prairie.android.ui.screens.onboarding.OnboardingTourLocalCache import org.prairieserver.prairie.android.ui.theme.PrairieTheme import org.prairieserver.prairie.common.network.ServerReachabilityMonitor import org.prairieserver.prairie.common.pip.PrairiePictureInPictureCoordinator +import org.prairieserver.prairie.common.profiles.ProfileAwayTracker +import org.prairieserver.prairie.common.profiles.ProfileLaunchBehavior +import org.prairieserver.prairie.common.profiles.ProfileLaunchPreferences import org.prairieserver.prairie.common.pip.PrairiePictureInPictureSurface import org.prairieserver.prairie.common.settings.PlayerSettingsStore import org.prairieserver.prairie.common.settings.ServerDrivenConfigRefresher @@ -69,6 +81,8 @@ import org.prairieserver.prairie.repository.SectionRepository import org.prairieserver.prairie.repository.port.HomeCachePort import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.java.KoinJavaComponent.get @@ -83,6 +97,12 @@ class MainActivity : ComponentActivity() { @Volatile private var hasShownColdSplash = false + // Profile Selection's launch rule applies once per process; a later + // Activity in the same process (Back left Prairie, then it was reopened) + // follows the return rule instead. Mirrors MainTvActivity. + @Volatile + private var hasResolvedLaunchProfile = false + /** * Set on the launch Intent once its external route has been delivered. * `putExtra` mutates the process-local Intent, which covers ordinary @@ -134,10 +154,28 @@ class MainActivity : ComponentActivity() { maybeRequestNotificationPermission() maybeRequestLegacyPublicDownloadPermission() + // Recents and a warm return to Prairie show a snapshot of its last screen. + // When Profile Selection asks, that is the previous viewer's profile, + // so keep no snapshot then (Android 13+; earlier versions have no + // equivalent short of blocking screenshots altogether). + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + lifecycleScope.launch { + get(ProfileLaunchPreferences::class.java).state + .map { it.behavior == ProfileLaunchBehavior.Automatic } + .distinctUntilChanged() + .collect { automatic -> setRecentsScreenshotEnabled(automatic) } + } + } + setContent { var startRoute by remember { mutableStateOf(null) } val pendingExternalRoute by pendingExternalRouteRequests.collectAsState() var splashPlaybackComplete by remember { mutableStateOf(hasShownColdSplash) } + // Profile Selection: from the moment Prairie returns and the setting + // asks, cover the previous profile's screens until Who's Watching + // has replaced them (AppNavigation lifts the request). + val profileAwayTracker = remember { get(ProfileAwayTracker::class.java) } + val profileCoverVisible by profileAwayTracker.selectionRequired.collectAsState() LaunchedEffect(Unit) { val route = resolveStartDestination() @@ -184,36 +222,54 @@ class MainActivity : ComponentActivity() { ) } } else { - AppNavigation( - startDestination = resolvedRoute, - pendingExternalRoute = pendingExternalRoute, - onRequeueExternalRoute = { route -> - // A fresh request: clear the consumed marker so - // this re-delivery is not mistaken for the - // already-followed original. - consumedExternalRoute = null - intent?.removeExtra(EXTRA_EXTERNAL_ROUTE_CONSUMED) - pendingExternalRouteRequests.value = - externalRouteRequestFactory.create(route) - }, - onExternalRouteConsumed = { consumedRequest -> - // Record the delivery in two places. The Intent - // extra covers in-process Activity recreation, - // which re-parses the retained Intent in - // onCreate and would otherwise yank the user - // back to a link they already followed. It is - // process-local, so the saved-state route below - // is what covers process death. - intent?.putExtra(EXTRA_EXTERNAL_ROUTE_CONSUMED, true) - consumedExternalRoute = consumedRequest.route - pendingExternalRouteRequests.update { pendingRequest -> - clearConsumedExternalRouteRequest( - pendingRequest = pendingRequest, - consumedRequest = consumedRequest, - ) - } - }, - ) + Box(modifier = Modifier.fillMaxSize()) { + AppNavigation( + startDestination = resolvedRoute, + pendingExternalRoute = pendingExternalRoute, + onRequeueExternalRoute = { route -> + // A fresh request: clear the consumed marker so + // this re-delivery is not mistaken for the + // already-followed original. + consumedExternalRoute = null + intent?.removeExtra(EXTRA_EXTERNAL_ROUTE_CONSUMED) + pendingExternalRouteRequests.value = + externalRouteRequestFactory.create(route) + }, + onExternalRouteConsumed = { consumedRequest -> + // Record the delivery in two places. The Intent + // extra covers in-process Activity recreation, + // which re-parses the retained Intent in + // onCreate and would otherwise yank the user + // back to a link they already followed. It is + // process-local, so the saved-state route below + // is what covers process death. + intent?.putExtra(EXTRA_EXTERNAL_ROUTE_CONSUMED, true) + consumedExternalRoute = consumedRequest.route + pendingExternalRouteRequests.update { pendingRequest -> + clearConsumedExternalRouteRequest( + pendingRequest = pendingRequest, + consumedRequest = consumedRequest, + ) + } + }, + ) + if (profileCoverVisible) { + // Swallows touches too, so nothing on the previous + // profile's screens can be tapped meanwhile. + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + awaitPointerEvent().changes.forEach { it.consume() } + } + } + }, + ) + } + } } } } @@ -247,6 +303,7 @@ class MainActivity : ComponentActivity() { override fun onStart() { super.onStart() DiagnosticsLifecycleLogger.state("foreground") + get(ProfileAwayTracker::class.java).onForeground() val refresher = get(ServerDrivenConfigRefresher::class.java) val monitor = get(ServerReachabilityMonitor::class.java) monitor.startForeground() @@ -278,6 +335,10 @@ class MainActivity : ComponentActivity() { override fun onStop() { DiagnosticsLifecycleLogger.state("background") super.onStop() + // A configuration-change recreation is not leaving Prairie. + if (!isChangingConfigurations) { + get(ProfileAwayTracker::class.java).onBackground() + } val monitor = get(ServerReachabilityMonitor::class.java) monitor.stopForeground() val store = get(PlayerSettingsStore::class.java) @@ -346,8 +407,15 @@ class MainActivity : ComponentActivity() { val notificationRoute = notification?.first val contentRoute = contentDeepLinkRouteOrNull(intent?.dataString) val inviteRoute = inviteClaimRouteOrNull(intent?.dataString) - - val route = notificationRoute ?: contentRoute ?: deviceRoute ?: inviteRoute ?: return + // A Watch Party invitation (prairie://watch-party). Ignored while Watch + // Party is turned off (D1). The join token stays in memory in + // WatchPartyHandoff; the route, and the consumed-route marker saved + // across process death, carry only an opaque id derived from it. + val partyLink = watchPartyAppLinkOrNull(intent?.dataString) + ?.takeIf { get(WatchPartyExperiment::class.java).enabled.value } + val partyRoute = partyLink?.let(::watchPartyInviteRoute) + + val route = notificationRoute ?: contentRoute ?: deviceRoute ?: inviteRoute ?: partyRoute ?: return if (route == consumedExternalRoute) return val scope = when { @@ -358,12 +426,20 @@ class MainActivity : ComponentActivity() { // produced it, and that requires a complete identity. route === notificationRoute -> checkNotNull(notification).second route === contentRoute -> currentIdentityScope() + // Like an invite claim, a party invitation names its own server + // and usually arrives before sign-in or profile selection, so it + // must NOT be pinned to the identity at arrival: signing in moves + // the identity generation and would drop it. The hub joins only + // when the invitation's server is the active one, under whoever is + // signed in by then; a newer link still replaces it. // An invite claim carries its own target server and is designed to - // work before authentication, so it must NOT be pinned to the - // current identity. + // work before authentication, so it must NOT be pinned either. else -> ExternalRouteScope.Unscoped } + if (route === partyRoute && partyLink != null) { + get(WatchPartyHandoff::class.java).offerInvite(partyLink) + } pendingExternalRouteRequests.value = externalRouteRequestFactory.create(route = route, scope = scope) } @@ -424,6 +500,7 @@ class MainActivity : ComponentActivity() { private suspend fun resolveStartDestination(): String { val registry = get(ServerRegistry::class.java) val tokenManager = get(TokenManager::class.java) + val processStart = !hasResolvedLaunchProfile // NOTE: a device link is deliberately NOT returned as the start // destination. It used to be, which put Pair Device at the root of a @@ -470,6 +547,22 @@ class MainActivity : ComponentActivity() { val profileId = activeEntry.profileId ?: tokenManager.getProfileId() if (profileId.isNullOrBlank()) return Route.ProfileSelection.route + // Profile Selection (prairie-apple `ProfileLaunchState.resolution`). A + // process start applies the launch rule: Every Time, or a timed choice + // whose away interval ran out while Prairie was closed. A later Activity + // in this process follows the return rule the away tracker evaluated + // in onStart. Applied below, after the offline check. + val launchPreferences = get(ProfileLaunchPreferences::class.java) + val profileAwayTracker = get(ProfileAwayTracker::class.java) + val requiresSelection = if (processStart) { + // A process the playback service started (Android's media + // resumption) can already be playing this profile's audiobook; + // that is use of the profile, as on a return. + launchPreferences.requiresSelectionAtLaunch() && !profileAwayTracker.keepsProfileActive() + } else { + profileAwayTracker.selectionRequired.value + } + // Offline-with-downloads fast path: if local media exists and either // the device has no network OR the configured Silo server fails the // authoritative health probe, land directly on Downloads instead of @@ -488,15 +581,32 @@ class MainActivity : ComponentActivity() { } else { online } - if ( - shouldStartOnDownloads( - hasLocalDownloads = hasDownloads, - isDeviceOnline = online, - canUseServer = canUseServer, - ) + val startsOnDownloads = shouldStartOnDownloads( + hasLocalDownloads = hasDownloads, + isDeviceOnline = online, + canUseServer = canUseServer, + ) + when ( + profileLaunchAction(requiresSelection = requiresSelection, startsOfflineOnDownloads = startsOnDownloads) ) { - return Route.Downloads.route + ProfileLaunchAction.AskWhoIsWatching -> { + // Cleared before the picker loads; losing its PIN proof makes + // a protected profile ask for its PIN again. + get(ActiveProfileReset::class.java).clearActiveProfile() + hasResolvedLaunchProfile = true + return Route.ProfileSelection.route + } + // The away interval stays recorded, so Prairie asks once the server + // can be reached again (AppNavigation watches for it). Every Time + // can ask with none recorded, so start one. + ProfileLaunchAction.KeepProfileOffline -> + if (launchPreferences.state.value.backgroundedAtMs == null) launchPreferences.markBackgrounded() + ProfileLaunchAction.KeepProfile -> if (processStart) launchPreferences.clearBackgroundedAt() } + // Only once applied, as on the TV: an Activity recreated while this + // was suspended must still apply the launch rule. + hasResolvedLaunchProfile = true + if (startsOnDownloads) return Route.Downloads.route // A warm start would otherwise bypass the tour gate entirely (e.g. // process death mid-tour). Once completion is confirmed the local diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/PrairieApplication.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/PrairieApplication.kt index 025ef063d2..2060527d2b 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/PrairieApplication.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/PrairieApplication.kt @@ -14,6 +14,8 @@ import org.prairieserver.prairie.android.downloads.AppWorkerFactory import org.prairieserver.prairie.android.notifications.NotificationsForegroundStarter import org.prairieserver.prairie.android.push.AndroidPushRegistrationStarter import org.prairieserver.prairie.common.di.playerInfraModule +import org.prairieserver.prairie.common.ui.marquee.marqueeModule +import org.prairieserver.prairie.common.di.watchPartyModule import org.prairieserver.prairie.common.di.playerModule import org.prairieserver.prairie.common.diagnostics.DiagnosticsCoordinator import org.prairieserver.prairie.common.diagnostics.DiagnosticsStartup @@ -42,12 +44,21 @@ class PrairieApplication : Application(), Configuration.Provider, SingletonImage DiagnosticsStartup.installCrashCapture(this) val koinApp = startKoin { androidContext(this@PrairieApplication) - modules(sharedModules() + playerModule + playerInfraModule + androidModule + diagnosticsModule) + modules(sharedModules() + playerModule + playerInfraModule + marqueeModule + watchPartyModule + androidModule + diagnosticsModule) } DiagnosticsStartup.startCoordinator { koinApp.koin.get() } koinApp.koin.get().start( kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.IO), ) + // Profile-verification recovery: clears a profile whose PIN token the + // server stopped accepting (after an access change), including when + // background work hits it first. Guarded — never load-bearing. + runCatching { + koinApp.koin.get() + }.onFailure { + // Class name only: the exception chain can carry auth or server data. + android.util.Log.w("PrairieApplication", "Profile verification recovery init failed: ${it::class.simpleName}") + } // Drive notifications realtime off the app foreground lifecycle. Guarded: // it's a foreground accelerator, never load-bearing for cold start. runCatching { @@ -99,6 +110,7 @@ class PrairieApplication : Application(), Configuration.Provider, SingletonImage org.prairieserver.prairie.android.home.HomeRealtimeForegroundStarter( coordinator = koinApp.koin.get(), profileRepository = koinApp.koin.get(), + accessCheck = koinApp.koin.getOrNull(), ).register() }.onFailure { android.util.Log.w("PrairieApplication", "Home realtime starter init failed", it) @@ -151,6 +163,13 @@ class PrairieApplication : Application(), Configuration.Provider, SingletonImage }.onFailure { android.util.Log.w("PrairieApplication", "Download subscription periodic enqueue failed", it) } + // Re-fetch saved subtitle sidecars the server has retimed since they + // were downloaded. Guarded for the same reason. + runCatching { + org.prairieserver.prairie.common.downloads.OfflineSubtitleRefreshWorker.enqueuePeriodic(this) + }.onFailure { + android.util.Log.w("PrairieApplication", "Offline subtitle refresh periodic enqueue failed", it) + } registerDownloadsNotificationChannel() } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/auth/ActiveProfileReset.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/auth/ActiveProfileReset.kt new file mode 100644 index 0000000000..b7e2c4df39 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/auth/ActiveProfileReset.kt @@ -0,0 +1,63 @@ +package org.prairieserver.prairie.android.auth + +import kotlinx.coroutines.withTimeoutOrNull +import org.prairieserver.prairie.common.profiles.ProfileLaunchPreferences +import org.prairieserver.prairie.common.settings.CardPresentationStore +import org.prairieserver.prairie.common.settings.EpisodeSpoilerStore +import org.prairieserver.prairie.common.settings.OverlayPrefsStore +import org.prairieserver.prairie.common.settings.PlayerSettingsStore +import org.prairieserver.prairie.common.settings.SeekIntervalStore +import org.prairieserver.prairie.common.settings.TitleArtStore +import org.prairieserver.prairie.model.profile.ActiveProfileStore +import org.prairieserver.prairie.repository.ProfileRepository + +/** + * Drops the active profile and everything cached for it, so whoever picks + * next starts clean. Profile Selection uses it at launch, on a return to + * Prairie, and on a server switch; the per-profile caches are the ones Switch + * Profile drops. + */ +class ActiveProfileReset( + private val profileRepository: ProfileRepository, + private val playerSettingsStore: PlayerSettingsStore, + private val profileLaunchPreferences: ProfileLaunchPreferences, + private val overlayPrefsStore: OverlayPrefsStore, + private val activeProfileStore: ActiveProfileStore, + private val cardPresentationStore: CardPresentationStore, + private val seekIntervalStore: SeekIntervalStore, + private val titleArtStore: TitleArtStore, + private val episodeSpoilerStore: EpisodeSpoilerStore, +) { + suspend fun clearActiveProfile() { + // The flusher drops a write once its profile is no longer active, and + // onStop's flush may still be in flight; push pending writes first, + // bounded, as Switch Profile does (ProfileSwitchTeardown). + withTimeoutOrNull(FLUSH_TIMEOUT_MS) { playerSettingsStore.flushPendingDeviceSettings() } + // The profile id and its PIN proof go together, so a protected + // profile asks for its PIN again. + profileRepository.clearProfile() + overlayPrefsStore.clear() + activeProfileStore.reset() + cardPresentationStore.clear() + seekIntervalStore.clear() + titleArtStore.clear() + episodeSpoilerStore.clear() + } + + /** + * Profile Selection covers a server switch as it does a launch (prairie-apple + * design §7.5): when the setting would ask now, drop the newly active + * server's saved profile so Who's Watching shows and a protected profile + * needs its PIN. Call it after the switch, before choosing a destination. + * Returns whether it cleared the profile. + */ + suspend fun clearIfSelectionRequired(): Boolean { + if (!profileLaunchPreferences.requiresSelectionAtLaunch()) return false + clearActiveProfile() + return true + } + + private companion object { + const val FLUSH_TIMEOUT_MS = 5_000L + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/auth/NativeSignIn.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/auth/NativeSignIn.kt new file mode 100644 index 0000000000..72d00597dd --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/auth/NativeSignIn.kt @@ -0,0 +1,361 @@ +package org.prairieserver.prairie.android.auth + +import java.net.IDN +import java.net.URI +import java.net.URLDecoder +import java.net.URLEncoder +import java.security.MessageDigest +import java.security.SecureRandom +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlinx.serialization.Serializable +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.PrairieAuthUnavailableException +import org.prairieserver.prairie.network.canonicalHttpOrigin + +/** + * The native OAuth handoff (prairie-server `docs/architecture/external-sign-in.md`, + * "Native apps"): the app opens the provider's native start in the system + * browser with a PKCE S256 challenge and a random `app_state`; the server + * returns the browser to the fixed app redirect + * `org.prairieserver.prairie:/auth/callback?code=&state=&server=&iss=` (or + * `error=`), and the app redeems the code with its verifier. Codes, verifiers + * and tokens are never logged. + * + * The native start always opens on the saved server's own base URL: the path + * the server lists is resolved against it, whatever origin it was listed + * with. The server answers with `iss`, the origin where the native start first + * arrived (RFC 9207), which must be the saved base's origin, and the code is + * redeemed at the saved base only. The saved server never changes address or + * id because of a sign-in. The `server` id is self-asserted, so it never + * decides where the verifier goes. A saved server whose native start relays + * the browser to another server gets nothing: the other server's redirect + * names its own origin. + */ +object NativeSignInProtocol { + const val CALLBACK_SCHEME = "org.prairieserver.prairie" + const val CALLBACK_PATH = "/auth/callback" + + /** `prompt` value asking the provider to let the person pick another account. */ + const val PROMPT_SELECT_ACCOUNT = "select_account" + + /** Server flows expire after 10 minutes; a callback for an older flow is ignored. */ + const val FLOW_LIFETIME_MS = 10 * 60 * 1000L + + private val random = SecureRandom() + + /** 32 random bytes as unpadded base64url: 43 unreserved characters (RFC 7636 §4.1). */ + fun newCodeVerifier(): String = randomToken() + + /** A random `app_state` of unreserved characters. */ + fun newAppState(): String = randomToken() + + /** `BASE64URL(SHA256(ASCII(verifier)))`, unpadded (RFC 7636 §4.2). */ + @OptIn(ExperimentalEncodingApi::class) + fun challenge(verifier: String): String = + base64Url.encode(MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray(Charsets.US_ASCII))) + + @OptIn(ExperimentalEncodingApi::class) + private fun randomToken(): String = ByteArray(32).also(random::nextBytes).let(base64Url::encode) + + @OptIn(ExperimentalEncodingApi::class) + private val base64Url = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT) + + /** + * The URL to open: [nativeStartPath] (base-relative, from the provider + * listing) on the saved server's base [serverUrl], with the PKCE + * challenge, `app_state`, a `prompt` when given and, when linking, the + * link ticket. Null when [serverUrl] isn't an http(s) base or the path + * isn't base-relative, so nothing ever opens on another origin. + */ + fun startUrl( + serverUrl: String, + nativeStartPath: String, + appState: String, + codeVerifier: String, + linkTicket: String? = null, + prompt: String? = null, + ): String? { + if (origin(serverUrl) == null) return null + if (!nativeStartPath.startsWith("/") || nativeStartPath.startsWith("//")) return null + val params = buildList { + add("code_challenge" to challenge(codeVerifier)) + add("code_challenge_method" to "S256") + add("app_state" to appState) + if (prompt != null) add("prompt" to prompt) + if (linkTicket != null) add("link_ticket" to linkTicket) + }.joinToString("&") { (name, value) -> "$name=${URLEncoder.encode(value, "UTF-8")}" } + val base = serverUrl.trim().substringBefore('#').substringBefore('?').trimEnd('/') + nativeStartPath.substringBefore('#') + val separator = when { + !base.contains('?') -> "?" + base.endsWith("?") || base.endsWith("&") -> "" + else -> "&" + } + return base + separator + params + } + + /** + * The web origin of an http(s) [url] (`scheme://host[:port]`, lowercase, + * default port omitted), or null when it has none or carries user info. + * The app's canonical form ([canonicalHttpOrigin]) with the host written + * the way a browser sends it as `Host`, which is what the server names in + * `iss`: an IDN name in punycode and an IPv6 literal compressed (RFC 5952). + * Used for every same-origin decision in a native flow, so the saved base + * and `iss` compare in one form. + */ + fun origin(url: String?): String? { + val trimmed = url?.trim().orEmpty() + if (!trimmed.startsWith("https://", ignoreCase = true) && !trimmed.startsWith("http://", ignoreCase = true)) return null + val canonical = canonicalHttpOrigin(trimmed) ?: return null + val scheme = canonical.substringBefore("://") + val authority = canonical.substringAfter("://") + val host = if (authority.startsWith('[')) authority.substringBefore(']') + "]" else authority.substringBefore(':') + val port = authority.removePrefix(host) + return "$scheme://${browserHost(host) ?: return null}$port" + } + + /** [host] as a browser's URL serializer writes it; null when an IDN name has no ASCII form. */ + private fun browserHost(host: String): String? = when { + host.startsWith('[') -> "[${compressIpv6(host.trim('[', ']')) ?: host.trim('[', ']')}]" + host.all { it.code < 0x80 } -> host + else -> runCatching { IDN.toASCII(host).lowercase() }.getOrNull() + } + + /** + * An IPv6 literal of hex groups in RFC 5952 form: lowercase, no leading + * zeros, the first longest run of two or more zero groups as `::`. Null + * when [literal] isn't eight groups once expanded. + */ + internal fun compressIpv6(literal: String): String? { + val halves = literal.split("::") + if (halves.size > 2) return null + fun groups(part: String) = if (part.isEmpty()) emptyList() else part.split(':') + val left = groups(halves[0]) + val right = if (halves.size == 2) groups(halves[1]) else emptyList() + val missing = 8 - left.size - right.size + if (if (halves.size == 2) missing < 1 else missing != 0) return null + val values = (left + List(missing) { "0" } + right).map { it.toIntOrNull(16) ?: return null } + var bestStart = -1 + var bestLength = 1 + var index = 0 + while (index < values.size) { + if (values[index] != 0) { + index++ + continue + } + val start = index + while (index < values.size && values[index] == 0) index++ + if (index - start > bestLength) { + bestStart = start + bestLength = index - start + } + } + val hex = values.map { it.toString(16) } + if (bestStart < 0) return hex.joinToString(":") + return hex.subList(0, bestStart).joinToString(":") + "::" + hex.subList(bestStart + bestLength, 8).joinToString(":") + } +} + +/** What the app redirect carried. Parsed only for the exact scheme and path. */ +data class NativeSignInCallback( + val code: String?, + val state: String?, + val server: String?, + val error: String?, + val link: Boolean, + /** The origin where the flow's native start first arrived (RFC 9207 `iss`); must equal the saved base's origin; required. */ + val iss: String? = null, +) { + override fun toString(): String = + "NativeSignInCallback(code=${if (code == null) "none" else ""}, " + + "state=${if (state == null) "none" else ""}, server=$server, iss=$iss, error=$error, link=$link)" + + companion object { + /** + * Null unless [uri] is exactly `org.prairieserver.prairie:/auth/callback` + * with a query: another path, a host, or user info is not ours. + */ + fun parse(uri: String?): NativeSignInCallback? { + if (uri.isNullOrBlank()) return null + val parsed = runCatching { URI(uri) }.getOrNull() ?: return null + if (!parsed.scheme.equals(NativeSignInProtocol.CALLBACK_SCHEME, ignoreCase = true)) return null + if (parsed.isOpaque || parsed.rawAuthority != null) return null + if (parsed.rawPath != NativeSignInProtocol.CALLBACK_PATH) return null + val query = parsed.rawQuery ?: return null + val values = mutableMapOf() + for (pair in query.split('&')) { + if (pair.isEmpty()) continue + val name = decode(pair.substringBefore('=')) + val value = decode(pair.substringAfter('=', "")) + // A repeated parameter is ambiguous: the first one wins, as on the server. + if (name != null && value != null && name !in values) values[name] = value + } + return NativeSignInCallback( + code = values["code"]?.takeIf { it.isNotEmpty() }, + state = values["state"]?.takeIf { it.isNotEmpty() }, + server = values["server"]?.takeIf { it.isNotEmpty() }, + error = values["error"]?.takeIf { it.isNotEmpty() }, + link = values["link"] == "1", + iss = values["iss"]?.takeIf { it.isNotEmpty() }, + ) + } + + private fun decode(value: String): String? = runCatching { URLDecoder.decode(value, "UTF-8") }.getOrNull() + } +} + +/** Why a flow was started: to sign in, or to link the signed-in account (Account settings). */ +enum class NativeSignInPurpose { SignIn, Link } + +/** + * A flow the app started and has not finished. Kept on disk (encrypted) for + * the flow's lifetime, so a process the system killed while the browser was + * open can still finish it. + */ +@Serializable +data class PendingNativeSignIn( + val purpose: NativeSignInPurpose, + /** The saved server ([org.prairieserver.prairie.model.server.ServerEntry.id]) the flow belongs to. */ + val serverEntryId: String, + /** That server's verified `server_id`; the redirect's `server` must equal it. */ + val verifiedServerId: String, + /** + * The origin of the saved server's base URL, where the app opened the + * native start. The redirect's `iss` must equal it, and the code is + * redeemed only while the saved server is still at that origin. + */ + val startOrigin: String, + val appState: String, + val codeVerifier: String, + val providerName: String, + val startedAtEpochMs: Long, + /** + * The saved server's login when the flow began + * ([org.prairieserver.prairie.network.TokenManager.loginSessionId]); null when + * signed out there. It must be unchanged when the redirect arrives, so a + * sign-in or sign-out since then supersedes the flow, and it survives the + * process being killed while the browser is open. Linking requires one. + */ + val loginSessionId: String? = null, +) { + override fun toString(): String = + "PendingNativeSignIn(purpose=$purpose, serverEntryId=$serverEntryId, providerName=$providerName, )" +} + +interface PendingNativeSignInStore { + fun load(): PendingNativeSignIn? + fun save(pending: PendingNativeSignIn) + fun clear() +} + +/** + * Saved servers whose next provider sign-in asks the provider to offer + * another account (`prompt=select_account`): set by an explicit Prairie + * sign-out, cleared by the next sign-in there. Kept on disk, so a sign-out + * today still asks tomorrow after the app was closed. + */ +interface AccountChoiceStore { + fun request(serverEntryId: String) + fun isRequested(serverEntryId: String): Boolean + fun clear(serverEntryId: String) +} + +/** + * Text for every reason a native flow can end without signing in: the + * server's `error=` reasons (docs/auth-api.md, "OAuth sign-in flows") and the + * app's own checks. The same wording as the web login page where both exist. + * The account's Sign-in settings and the password form reuse the reasons that + * other operations share ([forProblem]). + */ +object NativeSignInMessages { + const val WRONG_SERVER = "wrong_server" + + /** The redirect's `iss` is missing or isn't the saved server's origin, where the app opened the native start. */ + const val ISSUER_MISMATCH = "issuer_mismatch" + + const val ACCOUNT_REQUIRED = "account_required" + const val ACCOUNT_CHANGED = "account_changed" + const val PERMISSION_DENIED = "permission_denied" + const val NETWORK = "network" + const val NO_BROWSER = "no_browser" + const val RATE_LIMITED = "rate_limited" + const val UPDATE_REQUIRED = org.prairieserver.prairie.network.apiv2.ApiV2Gate.UPDATE_REQUIRED_ERROR + + /** Problem codes whose text is the same whichever operation refused. */ + private val SHARED_PROBLEMS = setOf( + "not_permitted", + "email_in_use", + "identity_linked_elsewhere", + "account_disabled", + "provider_unavailable", + "already_linked", + ACCOUNT_REQUIRED, + PERMISSION_DENIED, + RATE_LIMITED, + ) + + fun forReason(reason: String?, providerName: String? = null): String { + val provider = providerLabel(providerName) + return when (reason) { + "not_permitted" -> "Your account at $provider isn't allowed to use this server." + "email_in_use" -> "An account with this email already exists. Ask an admin to connect it to $provider." + "identity_linked_elsewhere" -> "That $provider account is already connected to another account." + "account_disabled" -> "This account is disabled." + "provider_unavailable" -> "$provider can't be reached right now. Try again later." + "state_invalid" -> "The sign-in didn't finish in the browser. Try again." + "session_expired" -> "The sign-in took too long. Try again." + "already_linked" -> "This account is already connected to $provider." + "password_expired" -> "Your $provider password has expired. Change it, then try again." + "login_failed" -> "Sign-in with $provider failed. Try again." + ACCOUNT_REQUIRED -> "You don't have an account on this server yet. Ask an admin to add you." + WRONG_SERVER, ISSUER_MISMATCH -> DIFFERENT_SERVER + ACCOUNT_CHANGED -> "The account or server changed while you were signing in. Try again." + PERMISSION_DENIED -> + "This session can't change how the account signs in. Sign in with your own account and try again." + RATE_LIMITED -> "Too many attempts. Wait a moment, then try again." + UPDATE_REQUIRED -> "This server needs an update before this app can sign in." + NETWORK -> "Network error. Check your connection and try again." + NO_BROWSER -> "No browser is available to sign in with $provider." + else -> "Sign-in with $provider failed. Try again." + } + } + + /** The same words as prairie-apple for a redirect from another server or origin. */ + const val DIFFERENT_SERVER = "This sign-in came back from a different server. Nothing was signed in." + + /** + * The shared text for a refusal any account operation can meet, by + * problem code first and status second; null when the operation must + * word it itself. + */ + fun forProblem(error: ApiResult.Error, providerName: String? = null): String? = + problemReason(error)?.let { forReason(it, providerName) } + + /** Whether [code] is a problem code whose text every operation shares. */ + fun isSharedProblem(code: String): Boolean = code in SHARED_PROBLEMS + + /** [forProblem]'s reason rather than its text. */ + fun problemReason(error: ApiResult.Error): String? = when { + isSharedProblem(error.error) -> error.error + error.code == 0 && error.error == UPDATE_REQUIRED -> UPDATE_REQUIRED + error.code == 403 -> PERMISSION_DENIED + error.code == 429 -> RATE_LIMITED + error.code == 503 -> "provider_unavailable" + else -> null + } + + /** + * The reason an exchange that never got an answer shows: the provider + * couldn't re-check the session before a request that needs a fresh + * bearer (linking, a link ticket), or the network. + */ + fun networkReason(error: ApiResult.NetworkError): String = + if (PrairieAuthUnavailableException.isProviderUnavailable(error.exception)) "provider_unavailable" else NETWORK + + /** [networkReason]'s text. */ + fun forNetworkError(error: ApiResult.NetworkError, providerName: String? = null): String = + forReason(networkReason(error), providerName) + + private fun providerLabel(providerName: String?): String = + providerName?.trim()?.takeIf { it.isNotEmpty() } ?: "the sign-in provider" +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/auth/NativeSignInCoordinator.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/auth/NativeSignInCoordinator.kt new file mode 100644 index 0000000000..43300fe924 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/auth/NativeSignInCoordinator.kt @@ -0,0 +1,349 @@ +package org.prairieserver.prairie.android.auth + +import java.security.MessageDigest +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.prairieserver.prairie.model.auth.User +import org.prairieserver.prairie.network.ApiResult + +/** How one native flow ended, for the screen that started it. */ +sealed interface NativeSignInResult { + val purpose: NativeSignInPurpose + val serverEntryId: String + + /** The redirect arrived and the code is being redeemed. */ + data class Finishing( + override val purpose: NativeSignInPurpose, + override val serverEntryId: String, + val providerName: String, + ) : NativeSignInResult + + data class SignedIn(override val serverEntryId: String, val user: User) : NativeSignInResult { + override val purpose get() = NativeSignInPurpose.SignIn + } + + data class Linked(override val serverEntryId: String, val providerName: String) : NativeSignInResult { + override val purpose get() = NativeSignInPurpose.Link + } + + data class Failed( + override val purpose: NativeSignInPurpose, + override val serverEntryId: String, + /** A server `error=` reason or one of [NativeSignInMessages]' own codes. */ + val reason: String, + val message: String, + ) : NativeSignInResult +} + +/** What [NativeSignInCoordinator.begin] decided. */ +sealed interface NativeSignInStart { + /** A flow is recorded; open [url] in the browser. */ + data class Open(val url: String) : NativeSignInStart + + /** No flow was recorded; show [message]. */ + data class Refused(val reason: String, val message: String) : NativeSignInStart +} + +/** + * Redeems what the flow's code is worth, at the saved server's base URL and + * only while it is still at [PendingNativeSignIn.startOrigin]. The + * production implementation ([RepositoryNativeSignInCompleter]) signs in + * through [org.prairieserver.prairie.repository.AuthRepository] and links through + * [org.prairieserver.prairie.network.api.ExternalSignInApi]. + */ +interface NativeSignInCompleter { + suspend fun signIn(pending: PendingNativeSignIn, code: String): ApiResult + suspend fun link(pending: PendingNativeSignIn, code: String): ApiResult +} + +/** + * Owns the one native OAuth flow the phone app may have in flight: starts it + * ([begin]) and finishes it when the app redirect arrives ([handleCallback]). + * + * The custom scheme can be claimed by another app, and anything can send an + * intent to it, so a callback is acted on only when it answers the flow this + * app started: a flow must be pending and not expired, and `state` must equal + * its `app_state` (compared in constant time). A callback that fails either + * check changes nothing, and the pending flow stays for the real redirect. + * Once `state` matches, the flow is spent: `iss` must be present and equal + * the saved server's origin, where the app opened the native start, `server` + * must be the verified identity of the saved server the flow was started + * for, and the code is redeemed only with this flow's verifier, only at the + * saved base. + * + * The `server` id is self-asserted: a hostile saved server can echo another + * deployment's id, and its native start can redirect the browser to that + * deployment's native start with this app's challenge and `app_state`. The + * real server records where its native start first arrived, its own origin, + * and names it in `iss`; that is not the saved origin, so the code is + * discarded. This covers only relays the real server can't be tricked into + * recording. A hostile server can request the start itself with its own + * origin as `Host` and get back an `iss` naming that origin, so the server + * records a start origin other than its public URL only where a server + * outside the local network can't hold it: the origins of connected network + * access providers, loopback, private, link-local and 100.64.0.0/10 IP + * literals, and single-label, .local, .lan, .localdomain, .home.arpa and + * .internal names. A hostile saved server on such an address can still relay. + * See prairie-server `docs/architecture/external-sign-in.md` ("Native apps"). + * A listing that names another origin changes nothing: only its path is + * used, on the saved base. + */ +class NativeSignInCoordinator( + private val store: PendingNativeSignInStore, + private val completer: NativeSignInCompleter, + private val scope: CoroutineScope, + private val accountChoices: AccountChoiceStore, + private val clock: () -> Long = System::currentTimeMillis, +) { + private val mutex = Mutex() + private val _result = MutableStateFlow(null) + + /** + * Moves when a flow is started or discarded, under [mutex]. A redemption + * publishes only while it is unchanged, so a flow superseded meanwhile + * (a new one began, a sign-in or sign-out discarded it) reports nothing. + */ + private var flowGeneration = 0L + + /** The latest flow's outcome until the screen that started it [consume]s it. */ + val result: StateFlow = _result.asStateFlow() + + /** The saved server whose login screen starts the provider sign-in by itself; memory only. */ + private var autoStartServerEntryId: String? = null + + /** + * An explicit sign-out of [serverEntryId] (Settings, the profile menu, + * "Not you? Switch account"): its next provider sign-in asks the provider + * to offer another account (`prompt=select_account`), until someone signs + * in there. A session that expired never asks. + * + * [autoStart] ("Not you? Switch account" where the provider takes + * `select_account`) also has that server's login screen start the sign-in + * as soon as it has its providers (prairie-apple `SelectAccountPrompt`). It + * lives in memory only, so a relaunch never opens a browser by itself. + */ + fun requestAccountChoice(serverEntryId: String, autoStart: Boolean = false) { + accountChoices.request(serverEntryId) + if (autoStart) synchronized(this) { autoStartServerEntryId = serverEntryId } + } + + fun accountChoiceRequested(serverEntryId: String): Boolean = accountChoices.isRequested(serverEntryId) + + /** Someone signed in on [serverEntryId] (by password or a provider): stop asking for another account. */ + fun clearAccountChoice(serverEntryId: String) { + accountChoices.clear(serverEntryId) + synchronized(this) { if (autoStartServerEntryId == serverEntryId) autoStartServerEntryId = null } + } + + /** Whether the login screen for [serverEntryId] should start the provider sign-in by itself. True once per request. */ + fun consumeAutoStart(serverEntryId: String): Boolean = synchronized(this) { + (autoStartServerEntryId == serverEntryId).also { if (it) autoStartServerEntryId = null } + } + + /** + * Gives back an auto-start that [consumeAutoStart] took for a sign-in that + * never opened because its login screen went away. Does nothing once + * someone signed in there ([clearAccountChoice]). + */ + fun restoreAutoStart(serverEntryId: String) { + if (accountChoiceRequested(serverEntryId)) synchronized(this) { autoStartServerEntryId = serverEntryId } + } + + /** + * Records a new flow and returns the URL to open in the browser. Replaces + * any earlier pending flow, whose redirect is then ignored. + * + * The callers read [verifiedServerId] from the saved server's identity + * (refreshed) just before. [serverUrl] is that server's saved base URL, + * and the native start opens there: [nativeStartPath] is the + * base-relative path from the provider listing, never an origin. The flow + * is bound to the saved origin. [prompt] (a sign-in's `select_account`) + * is never sent on a linking flow, which asks the provider for a fresh + * sign-in itself. [loginSessionId] is the saved server's login the flow + * belongs to ([PendingNativeSignIn.loginSessionId]). + */ + suspend fun begin( + purpose: NativeSignInPurpose, + serverEntryId: String, + serverUrl: String, + verifiedServerId: String, + providerName: String, + nativeStartPath: String, + linkTicket: String? = null, + loginSessionId: String? = null, + prompt: String? = null, + ): NativeSignInStart { + val serverOrigin = NativeSignInProtocol.origin(serverUrl) + val appState = NativeSignInProtocol.newAppState() + val codeVerifier = NativeSignInProtocol.newCodeVerifier() + val url = NativeSignInProtocol.startUrl( + serverUrl, + nativeStartPath, + appState, + codeVerifier, + linkTicket, + prompt = prompt.takeIf { purpose == NativeSignInPurpose.SignIn }, + ) + if (serverOrigin == null || url == null) { + return NativeSignInStart.Refused("login_failed", NativeSignInMessages.forReason("login_failed", providerName)) + } + return mutex.withLock { + flowGeneration++ + store.save( + PendingNativeSignIn( + purpose = purpose, + serverEntryId = serverEntryId, + verifiedServerId = verifiedServerId, + startOrigin = serverOrigin, + appState = appState, + codeVerifier = codeVerifier, + providerName = providerName, + startedAtEpochMs = clock(), + loginSessionId = loginSessionId, + ), + ) + _result.value = null + NativeSignInStart.Open(url) + } + } + + /** + * Handles an intent to the app redirect. Returns the job that [finish]es + * it, or null when [uri] isn't the app redirect at all; [finish] itself + * ignores a redirect that doesn't answer the pending flow. The job runs on + * this coordinator's own scope, so it outlives the activity that received + * the intent. + */ + fun handleCallback(uri: String?): Job? { + val callback = NativeSignInCallback.parse(uri) ?: return null + return scope.launch { finish(callback) } + } + + /** + * Another sign-in or a sign-out happened: the pending flow, if any, no + * longer speaks for the session, so its redirect is ignored, a + * redemption under way reports nothing, and an unshown result goes. + */ + suspend fun discardPending() { + mutex.withLock { + flowGeneration++ + store.clear() + _result.value = null + } + } + + /** [handleCallback]'s work, run inline. Returns whether the callback answered the pending flow. */ + suspend fun finish(callback: NativeSignInCallback): Boolean { + val (pending, generation) = mutex.withLock { + val pending = store.load() ?: return false + if (clock() - pending.startedAtEpochMs !in 0..NativeSignInProtocol.FLOW_LIFETIME_MS) { + store.clear() + return false + } + if (!constantTimeEquals(callback.state, pending.appState)) return false + // The flow is spent from here on, whatever the outcome. + store.clear() + pending to flowGeneration + } + val failure = when { + // RFC 9207: the server names where the native start first arrived, + // which must be the saved origin the app opened it on. A relay + // through another server's native start comes back with that + // server's own origin. + !issuedBy(callback.iss, pending.startOrigin) -> NativeSignInMessages.ISSUER_MISMATCH + callback.server == null || !constantTimeEquals(callback.server, pending.verifiedServerId) -> + NativeSignInMessages.WRONG_SERVER + callback.error != null -> callback.error + callback.code == null -> "login_failed" + callback.link != (pending.purpose == NativeSignInPurpose.Link) -> "login_failed" + else -> null + } + if (failure != null) { + publishFor(generation, failed(pending, failure)) + return true + } + val code = checkNotNull(callback.code) + publishFor(generation, NativeSignInResult.Finishing(pending.purpose, pending.serverEntryId, pending.providerName)) + val outcome = try { + when (pending.purpose) { + NativeSignInPurpose.SignIn -> when (val signedIn = completer.signIn(pending, code)) { + is ApiResult.Success -> { + clearAccountChoice(pending.serverEntryId) + NativeSignInResult.SignedIn(pending.serverEntryId, signedIn.data) + } + is ApiResult.Error -> failed(pending, completionReason(signedIn)) + is ApiResult.NetworkError -> failed(pending, NativeSignInMessages.networkReason(signedIn)) + } + NativeSignInPurpose.Link -> when (val linked = completer.link(pending, code)) { + is ApiResult.Success -> NativeSignInResult.Linked(pending.serverEntryId, pending.providerName) + is ApiResult.Error -> failed(pending, completionReason(linked)) + is ApiResult.NetworkError -> failed(pending, NativeSignInMessages.networkReason(linked)) + } + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + failed(pending, "login_failed") + } + publishFor(generation, outcome) + return true + } + + /** The screen has shown [result]; a later flow's result replaces it anyway. */ + fun consume(result: NativeSignInResult) { + _result.update { if (it == result) null else it } + } + + /** Publishes [result] only while the flow claimed at [generation] is still the latest. */ + private suspend fun publishFor(generation: Long, result: NativeSignInResult) { + mutex.withLock { if (flowGeneration == generation) _result.value = result } + } + + private fun failed(pending: PendingNativeSignIn, reason: String) = NativeSignInResult.Failed( + purpose = pending.purpose, + serverEntryId = pending.serverEntryId, + reason = reason, + message = NativeSignInMessages.forReason(reason, pending.providerName), + ) + + companion object { + /** + * The reason a failed redemption shows. The redemption's own cases + * first: a verifier that doesn't fit (400 `invalid_grant`) means the + * browser flow didn't finish as this app started it; an unknown, used + * or expired code (401 `invalid_token`) took too long, and either way + * the flow starts again. Then the refusals any account operation can + * meet ([NativeSignInMessages.problemReason]). + */ + internal fun completionReason(error: ApiResult.Error): String = when { + error.error == "identity_changed" -> NativeSignInMessages.ACCOUNT_CHANGED + error.error == "password_expired" -> "password_expired" + NativeSignInMessages.isSharedProblem(error.error) -> error.error + error.error == "invalid_grant" -> "state_invalid" + error.error == "invalid_token" || error.code == 401 -> "session_expired" + error.code == 409 && error.error == "conflict" -> "already_linked" + // A redemption refused outright is about the account, not this session. + error.code == 403 -> "not_permitted" + else -> NativeSignInMessages.problemReason(error) ?: "login_failed" + } + + /** Whether [iss] names [startOrigin]; a missing `iss` never does. */ + internal fun issuedBy(iss: String?, startOrigin: String): Boolean { + val issuer = NativeSignInProtocol.origin(iss) ?: return false + return constantTimeEquals(issuer, startOrigin) + } + + internal fun constantTimeEquals(a: String?, b: String?): Boolean { + if (a == null || b == null) return false + return MessageDigest.isEqual(a.toByteArray(Charsets.UTF_8), b.toByteArray(Charsets.UTF_8)) + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/auth/NativeSignInPlatform.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/auth/NativeSignInPlatform.kt new file mode 100644 index 0000000000..41ca74d23b --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/auth/NativeSignInPlatform.kt @@ -0,0 +1,154 @@ +package org.prairieserver.prairie.android.auth + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.os.Bundle +import androidx.browser.customtabs.CustomTabsIntent +import androidx.core.content.edit +import androidx.core.net.toUri +import kotlinx.serialization.json.Json +import org.koin.android.ext.android.inject +import org.prairieserver.prairie.android.MainActivity +import org.prairieserver.prairie.model.auth.User +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.TokenManager +import org.prairieserver.prairie.network.api.ExternalSignInApi +import org.prairieserver.prairie.repository.AuthRepository + +/** + * Signs in through [AuthRepository] and links through [ExternalSignInApi], at + * the saved server's base URL, and only while that server is still at the + * origin the flow was started on ([PendingNativeSignIn.startOrigin]). The + * saved server keeps its address and id: a sign-in never moves, re-keys or + * duplicates it. + */ +class RepositoryNativeSignInCompleter( + private val authRepository: AuthRepository, + private val api: ExternalSignInApi, + private val tokenManager: TokenManager, +) : NativeSignInCompleter { + /** + * Redeems only while the saved server's login is the one the flow began + * with: a password sign-in or a sign-out since then supersedes it. The + * session expectation [AuthRepository] captures first fences a sign-in + * that lands after this check. + */ + override suspend fun signIn(pending: PendingNativeSignIn, code: String): ApiResult { + return authRepository.completeNativeOAuthLogin(pending.serverEntryId) { serverUrl -> + if (NativeSignInProtocol.origin(serverUrl) != pending.startOrigin || + tokenManager.loginSessionId(pending.serverEntryId) != pending.loginSessionId + ) { + changed() + } else { + api.completeOAuthLogin(serverUrl, code, pending.codeVerifier) + } + } + } + + /** + * Only the account session that asked for the link ticket may confirm the + * link: the server checks the account, and this checks the session did not + * change (sign-out, another account, another server or address) while the + * browser was open. The login id survives the process being killed in the + * meantime; the request is pinned to the scope read before it, so a + * sign-in that lands after the check fails it. + */ + override suspend fun link(pending: PendingNativeSignIn, code: String): ApiResult { + val scope = tokenManager.snapshotCurrentScope() + if (scope == null || !scope.startedFlow(pending) || NativeSignInProtocol.origin(scope.serverUrl) != pending.startOrigin) { + return changed() + } + return api.completeLink(scope, code, pending.codeVerifier) + } + + private suspend fun AuthScopeSnapshot.startedFlow(pending: PendingNativeSignIn): Boolean = + serverId == pending.serverEntryId && credentialGenerationId == null && pending.loginSessionId != null && + tokenManager.loginSessionId(serverId) == pending.loginSessionId + + private fun changed() = ApiResult.Error(0, "identity_changed", "The account or server changed.") +} + +/** + * The pending flow in the app's encrypted preferences, so a process the + * system killed while the browser was open can still finish it. + */ +class SharedPrefsPendingNativeSignInStore(private val prefs: SharedPreferences) : PendingNativeSignInStore { + override fun load(): PendingNativeSignIn? = prefs.getString(KEY, null)?.let { + runCatching { json.decodeFromString(PendingNativeSignIn.serializer(), it) }.getOrNull() + } + + override fun save(pending: PendingNativeSignIn) { + prefs.edit(commit = true) { putString(KEY, json.encodeToString(PendingNativeSignIn.serializer(), pending)) } + } + + override fun clear() { + prefs.edit(commit = true) { remove(KEY) } + } + + private companion object { + const val KEY = "native_sign_in_pending" + val json = Json { ignoreUnknownKeys = true } + } +} + +/** [AccountChoiceStore] in the app's encrypted preferences. */ +class SharedPrefsAccountChoiceStore(private val prefs: SharedPreferences) : AccountChoiceStore { + override fun request(serverEntryId: String) = update { it + serverEntryId } + + override fun isRequested(serverEntryId: String): Boolean = serverEntryId in read() + + override fun clear(serverEntryId: String) = update { it - serverEntryId } + + private fun read(): Set = prefs.getStringSet(KEY, null).orEmpty() + + @Synchronized + private fun update(change: (Set) -> Set) { + val next = change(read()) + prefs.edit(commit = true) { if (next.isEmpty()) remove(KEY) else putStringSet(KEY, next.toSet()) } + } + + private companion object { + const val KEY = "native_sign_in_account_choice" + } +} + +/** + * Opens a native flow's start URL in a Custom Tab (the system browser when + * none supports Custom Tabs). Never a WebView: the provider's page must run + * in the browser that holds its cookies. Returns false when no browser can + * open it. + */ +fun Context.openNativeSignIn(url: String): Boolean = try { + CustomTabsIntent.Builder() + .setShowTitle(true) + .build() + .launchUrl(this, url.toUri()) + true +} catch (_: ActivityNotFoundException) { + false +} + +/** + * Receives `org.prairieserver.prairie:/auth/callback`. Hands the redirect to the + * coordinator, which acts on it only when it answers the flow this app + * started, then brings the app's task back to [MainActivity], closing the + * Custom Tab above it. Shows nothing itself. + */ +class NativeSignInCallbackActivity : Activity() { + private val coordinator: NativeSignInCoordinator by inject() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // A restored instance already handed its redirect over. + if (savedInstanceState == null) coordinator.handleCallback(intent?.dataString) + startActivity( + Intent(this, MainActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP), + ) + finish() + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/auth/ProfileSwitchTeardown.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/auth/ProfileSwitchTeardown.kt new file mode 100644 index 0000000000..72822f5184 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/auth/ProfileSwitchTeardown.kt @@ -0,0 +1,74 @@ +package org.prairieserver.prairie.android.auth + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withTimeoutOrNull +import org.prairieserver.prairie.common.settings.CardPresentationStore +import org.prairieserver.prairie.common.settings.EpisodeSpoilerStore +import org.prairieserver.prairie.common.settings.OverlayPrefsStore +import org.prairieserver.prairie.common.settings.PlayerSettingsStore +import org.prairieserver.prairie.common.settings.SeekIntervalStore +import org.prairieserver.prairie.common.settings.TitleArtStore +import org.prairieserver.prairie.model.profile.ActiveProfileStore + +/** + * The phone's "Switch Profile", shared by the profile menu and Settings. + * + * Settings writes wait in the flusher's debounce for a moment, and the flusher + * drops a write once its profile is no longer active. So the pending writes are + * pushed first, while the profile they were made on is still the active one + * (the TV switch does the same). Then the shell is left and the per-profile + * caches are dropped, or the next profile keeps rendering, and writing back, + * the previous one's values. + */ +class ProfileSwitchTeardown( + private val playerSettingsStore: PlayerSettingsStore, + private val overlayPrefsStore: OverlayPrefsStore, + private val activeProfileStore: ActiveProfileStore, + private val cardPresentationStore: CardPresentationStore, + private val seekIntervalStore: SeekIntervalStore, + private val titleArtStore: TitleArtStore, + private val episodeSpoilerStore: EpisodeSpoilerStore, + private val flushTimeoutMs: Long = FLUSH_TIMEOUT_MS, +) { + private val _switching = MutableStateFlow(false) + + /** True while a switch is pushing settings; the shell shows progress and blocks input. */ + val switching: StateFlow = _switching.asStateFlow() + + /** + * Push pending settings, then call [leaveShell] and drop per-profile state. + * + * The push is bounded: against an unreachable server each write can wait + * out a connect timeout, and the switch must not hang on that. A write + * still unsent when the bound passes stays queued under the old profile, + * as it would have without this flush. + * + * A request made while a switch is already running is ignored, so a + * second tap during the push cannot open a second profile picker. + * Returns whether this call performed the switch. + */ + suspend fun switchProfile(leaveShell: () -> Unit): Boolean { + if (!_switching.compareAndSet(expect = false, update = true)) return false + try { + withTimeoutOrNull(flushTimeoutMs) { playerSettingsStore.flushPendingDeviceSettings() } + // Navigate before clearing: clearing while the shell is still composed + // repaints it with default cards behind the picker. + leaveShell() + overlayPrefsStore.clear() + activeProfileStore.reset() + cardPresentationStore.clear() + seekIntervalStore.clear() + titleArtStore.clear() + episodeSpoilerStore.clear() + } finally { + _switching.value = false + } + return true + } + + private companion object { + const val FLUSH_TIMEOUT_MS = 5_000L + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/auth/SignOutTeardown.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/auth/SignOutTeardown.kt new file mode 100644 index 0000000000..33bacc64b6 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/auth/SignOutTeardown.kt @@ -0,0 +1,74 @@ +package org.prairieserver.prairie.android.auth + +import org.prairieserver.prairie.common.settings.CardPresentationStore +import org.prairieserver.prairie.common.settings.EpisodeSpoilerStore +import org.prairieserver.prairie.common.settings.LibraryPlaybackPrefsStore +import org.prairieserver.prairie.common.settings.OverlayPrefsStore +import org.prairieserver.prairie.common.settings.PlayerSettingsStore +import org.prairieserver.prairie.common.settings.SeekIntervalStore +import org.prairieserver.prairie.common.settings.TitleArtStore +import org.prairieserver.prairie.model.feature.MetadataAiFeatureStore +import org.prairieserver.prairie.model.feature.RequestsFeatureStore +import org.prairieserver.prairie.model.profile.ActiveProfileStore +import org.prairieserver.prairie.network.ServerRegistry +import org.prairieserver.prairie.repository.AuthRepository + +/** + * The one sign-out of the active server's account, shared by Settings, the + * profile menu and device pairing's "Switch account". Pending device settings + * are pushed first, while the session they belong to still exists; then the + * per-profile caches are dropped so the next account's shell doesn't render + * (or write back) the previous profile's values. + * + * Signing out of Prairie leaves the provider's session in the browser, so the + * server's next provider sign-in asks the provider to offer another account + * ([NativeSignInCoordinator.requestAccountChoice]). A session that expired + * never comes through here and never asks. + */ +class SignOutTeardown( + private val authRepository: AuthRepository, + private val playerSettingsStore: PlayerSettingsStore, + private val libraryPlaybackPrefsStore: LibraryPlaybackPrefsStore, + private val overlayPrefsStore: OverlayPrefsStore, + private val activeProfileStore: ActiveProfileStore, + private val cardPresentationStore: CardPresentationStore, + private val seekIntervalStore: SeekIntervalStore, + private val titleArtStore: TitleArtStore, + private val episodeSpoilerStore: EpisodeSpoilerStore, + private val requestsFeatureStore: RequestsFeatureStore, + private val metadataAiFeatureStore: MetadataAiFeatureStore, + private val serverRegistry: ServerRegistry, + private val nativeSignIn: NativeSignInCoordinator, +) { + /** + * Push settings still waiting to sync. Call before switching servers so + * they reach the server they were made on. + */ + suspend fun flushPendingSettings() { + playerSettingsStore.flushPendingDeviceSettings() + } + + /** + * Sign out of the active server and drop per-profile state. + * [flushFirst] is false only when the caller already flushed, before a + * server switch. [startSignIn] ("Not you? Switch account" where the + * provider is asked to offer another account) has the login screen that + * follows start the provider sign-in by itself. + */ + suspend fun signOut(flushFirst: Boolean = true, startSignIn: Boolean = false) { + if (flushFirst) flushPendingSettings() + val signedOutOf = serverRegistry.activeServerId.value + authRepository.logout() + nativeSignIn.discardPending() + if (signedOutOf != null) nativeSignIn.requestAccountChoice(signedOutOf, autoStart = startSignIn) + libraryPlaybackPrefsStore.clear() + overlayPrefsStore.clear() + activeProfileStore.reset() + cardPresentationStore.clear() + seekIntervalStore.clear() + titleArtStore.clear() + episodeSpoilerStore.clear() + requestsFeatureStore.reset() + metadataAiFeatureStore.reset() + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/CastSubtitleMenuButton.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/CastSubtitleMenuButton.kt index 02990111d4..65d170215e 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/CastSubtitleMenuButton.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/CastSubtitleMenuButton.kt @@ -1,12 +1,12 @@ package org.prairieserver.prairie.android.cast +import org.prairieserver.prairie.android.ui.components.PrairieDropdownMenuItem +import org.prairieserver.prairie.android.ui.components.PrairieDropdownMenu import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.ClosedCaption import androidx.compose.material.icons.outlined.ClosedCaptionOff -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor @@ -49,8 +49,8 @@ fun CastSubtitleMenuButton( tint = if (tint == Color.Unspecified) LocalContentColor.current else tint, modifier = Modifier.size(iconSize), ) - DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { - DropdownMenuItem( + PrairieDropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + PrairieDropdownMenuItem( text = { Text("Off") }, leadingIcon = { if (activeId == null) Icon(Icons.Default.Check, contentDescription = null) @@ -61,7 +61,7 @@ fun CastSubtitleMenuButton( }, ) options.forEach { option -> - DropdownMenuItem( + PrairieDropdownMenuItem( text = { Text(option.label) }, leadingIcon = { if (activeId == option.id) Icon(Icons.Default.Check, contentDescription = null) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/PrairieCastArtworkResolver.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/PrairieCastArtworkResolver.kt index fb7aff144d..5d67f06687 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/PrairieCastArtworkResolver.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/PrairieCastArtworkResolver.kt @@ -10,6 +10,8 @@ data class PrairieCastArtwork( val posterThumbhash: String? = null, val backdropUrl: String? = null, val backdropThumbhash: String? = null, + /** The item's display title (see [castDisplayTitle]), for one the TV has not reported yet. */ + val title: String? = null, ) { val isEmpty: Boolean get() = posterUrl == null && backdropUrl == null } @@ -31,9 +33,25 @@ internal suspend fun resolveCastArtwork( posterThumbhash = if (series?.posterUrl != null) series.posterThumbhash else detail.posterThumbhash, backdropUrl = detail.backdropUrl ?: series?.backdropUrl, backdropThumbhash = if (detail.backdropUrl != null) detail.backdropThumbhash else series?.backdropThumbhash, + title = castDisplayTitle(detail), ) } +/** + * One line naming an item that is starting on a TV. An episode reads + * "Series · S1 · E2", the TV player's own subtitle, since its bare title is + * often ambiguous ("Pilot"). + */ +internal fun castDisplayTitle(detail: ItemDetail): String? { + val series = detail.seriesTitle?.takeIf { detail.type == "episode" && it.isNotBlank() } + ?: return detail.title.takeIf { it.isNotBlank() } + return listOfNotNull( + series, + detail.seasonNumber?.let { "S$it" }, + detail.episodeNumber?.let { "E$it" }, + ).joinToString(" · ") +} + private suspend fun CatalogRepository.detailOrNull(contentId: String): ItemDetail? = getCachedItemDetail(contentId) ?: (getItemDetail(contentId) as? ApiResult.Success)?.data diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/PrairieCastButton.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/PrairieCastButton.kt index 943ab32c54..f36d6ba976 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/PrairieCastButton.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/PrairieCastButton.kt @@ -1,5 +1,19 @@ package org.prairieserver.prairie.android.cast +import androidx.compose.ui.Alignment +import androidx.compose.ui.unit.sp +import androidx.compose.ui.draw.clip +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.clickable +import androidx.compose.foundation.background +import androidx.compose.material.icons.rounded.Tv +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Cast +import org.prairieserver.prairie.android.ui.theme.PrairieForeground +import org.prairieserver.prairie.android.ui.components.PrairieDialogActionStyle +import org.prairieserver.prairie.android.ui.components.PrairieDialogAction +import org.prairieserver.prairie.android.ui.components.PrairieDialog import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -10,13 +24,11 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Cast import androidx.compose.material.icons.filled.CastConnected import androidx.compose.material.icons.filled.Check -import androidx.compose.material3.AlertDialog import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState @@ -71,93 +83,77 @@ fun PrairieCastButton( } if (showPicker) { - AlertDialog( + PrairieDialog( + title = "Cast to", + message = if (routes.isEmpty()) { + "No cast devices found. Make sure your Chromecast is on the same Wi-Fi network." + } else { + null + }, onDismissRequest = { showPicker = false }, - title = { Text("Cast to") }, - text = { - if (routes.isEmpty()) { - Text( - text = "No cast devices found. Make sure your Chromecast is on the same Wi-Fi network.", - color = MaterialTheme.colorScheme.onSurfaceVariant, + icon = Icons.Rounded.Cast, + actions = buildList { + if (castState.isConnected) { + add( + PrairieDialogAction( + label = "Stop casting", + style = PrairieDialogActionStyle.Destructive, + onClick = { + castManager.disconnect() + showPicker = false + }, + ), ) - } else { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - routes.forEachIndexed { index, route -> - if (index > 0) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), - ) - } - TextButton( - onClick = { + } + add(PrairieDialogAction("Close", { showPicker = false })) + }, + ) { + if (routes.isNotEmpty()) { + Column { + routes.forEach { route -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(if (route.isSelected) Color.White.copy(alpha = 0.07f) else Color.Transparent) + .clickable { // Stage the cast stream, then join the route. onStartCast() castManager.selectRoute(route.id) showPicker = false - }, - modifier = Modifier.fillMaxWidth(), + } + .heightIn(min = 52.dp) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.Tv, + contentDescription = null, + tint = PrairieForeground.copy(alpha = 0.78f), + modifier = Modifier.size(22.dp), + ) + Column( + modifier = Modifier + .weight(1f) + .padding(start = 14.dp), ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = route.name, - color = MaterialTheme.colorScheme.onSurface, - ) - if (route.isConnecting) { - Text( - text = "Connecting…", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - ) - } - } - if (route.isSelected) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = "Selected", - tint = MaterialTheme.colorScheme.primary, - ) - } + Text(text = route.name, color = PrairieForeground, fontSize = 15.sp) + if (route.isConnecting) { + Text(text = "Connecting…", fontSize = 12.sp) } } + if (route.isSelected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = "Selected", + tint = PrairieForeground, + modifier = Modifier.size(20.dp), + ) + } } } } - }, - confirmButton = { - if (castState.isConnected) { - TextButton( - onClick = { - castManager.disconnect() - showPicker = false - }, - ) { Text("Stop casting") } - } else { - TextButton(onClick = { showPicker = false }) { Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Close") } - } - }, - dismissButton = { - if (castState.isConnected) { - TextButton(onClick = { showPicker = false }) { Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Close") } - } - }, - ) + } + } } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/PrairieCastController.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/PrairieCastController.kt index 81fdac57a4..70f7580f5d 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/PrairieCastController.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/PrairieCastController.kt @@ -7,18 +7,25 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.async import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.selects.select @@ -26,6 +33,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.json.Json import org.prairieserver.prairie.cast.PrairieCastControlCommand import org.prairieserver.prairie.cast.PrairieCastHello @@ -52,6 +60,7 @@ import org.prairieserver.prairie.network.TokenManager import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.AuthScopeSnapshot import org.prairieserver.prairie.network.api.DeviceLoginApi +import org.prairieserver.prairie.repository.CatalogRepository data class PrairieCastControllerState( val targets: List = emptyList(), @@ -69,17 +78,28 @@ data class PrairieCastControllerState( /** A silent foreground re-attach probe is in flight or unconfirmed — * the mini bar stays hidden until the TV confirms it is playing. */ val isAutoResuming: Boolean = false, - /** A launch (connect + profile handoff + launch frame) is in flight. The - * TV keeps pushing idle state until its player registers, so without - * this the remote would show "Pick something from your library…" for - * seconds right after the user picked something. */ - val isLaunching: Boolean = false, + /** A Play sent to the TV, from the tap until the TV reports that title. + * The TV keeps pushing idle state (or the outgoing title's state) until + * its player registers, so without this the remote would show "Pick + * something from your library…" right after the user picked something. */ + val launch: PrairieCastPendingLaunch? = null, val error: String? = null, ) { val isConnected: Boolean get() = connectedTarget != null && !isReconnecting val hasActiveSession: Boolean get() = connectedTarget != null || isReconnecting } +data class PrairieCastPendingLaunch( + val contentId: String, + /** The launch frame is on the wire; only the TV's state after that counts. */ + val isSent: Boolean = false, + /** What the TV reported playing when the launch went out; its state does not end the launch. */ + val replacingContentId: String? = null, +) + +/** Haptic moments of a Play sent to a TV, mirroring prairie-apple's remote. */ +enum class PrairieCastLaunchFeedback { Sent, Started, Failed } + /** * Phone-side PrairieCast controller. Wire-compatible with prairie-apple's * PrairieControlClient/TVControlReceiver: TLS-PSK transport ([PrairieCastTls]), @@ -104,6 +124,7 @@ class PrairieCastController( private val serverRegistry: ServerRegistry, private val tokenManager: TokenManager, private val deviceLoginApi: DeviceLoginApi, + private val catalogRepository: CatalogRepository, private val lastTargetStore: PrairieCastLastTargetStore, private val deviceNameProvider: () -> String, private val deviceIdProvider: () -> String, @@ -149,6 +170,12 @@ class PrairieCastController( private val _state = MutableStateFlow(PrairieCastControllerState()) val state: StateFlow = _state.asStateFlow() + private val _launchFeedback = MutableSharedFlow( + extraBufferCapacity = 4, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val launchFeedback: SharedFlow = _launchFeedback.asSharedFlow() + private var session: PrairieCastTlsClientSession? = null private var output: OutputStream? = null private var readJob: Job? = null @@ -209,16 +236,31 @@ class PrairieCastController( } fun launchOnTarget(target: PrairieCastTarget, request: PrairieCastLaunchRequest) { + val pending = PrairieCastPendingLaunch(contentId = request.playback.contentId) val job = scope.launch(start = CoroutineStart.LAZY) { val self = currentCoroutineContext()[Job] ?: return@launch + // Looked up alongside the connect: the TV shows it while a full + // profile handoff runs. + val title = async { launchTitle(request.playback.contentId) } try { launchMutex.withLock { ensureConnected(target, allowCrossServer = true) - // AFTER ensureConnected: its teardown of any previous session - // resets the flag, so setting it earlier would be undone. - _state.update { it.copy(isLaunching = true) } - prepareRemoteIdentity(request) + // Again AFTER ensureConnected: its teardown of a previous + // session clears the launch set at the tap. + _state.update { if (isCurrentLaunch(self)) it.copy(launch = pending) else it } + prepareRemoteIdentity(request, title) + // Read before sending: the TV's state for the new title can + // arrive before the update below, and must not be taken + // for the title being replaced. + val replacing = _state.value.playbackState?.contentId send(PrairieCastMessage.Launch(request)) + _state.update { + if (isCurrentLaunch(self) && it.launch == pending) { + it.copy(launch = pending.copy(isSent = true, replacingContentId = replacing)) + } else { + it + } + } // A cross-server handoff changes the TV's advertised // server after the socket was opened. Persist the actual // remote session scope, not the target's stale discovery @@ -231,12 +273,20 @@ class PrairieCastController( ), ) } + // The TV reports a launched title within moments (its player + // registers before the stream loads); give up on one it never + // reports instead of leaving the remote on "Starting…". + val sent = _state.value.launch?.takeIf { it.isSent && it.contentId == pending.contentId } + if (sent != null) { + val ended = withTimeoutOrNull(LAUNCH_START_TIMEOUT_MS) { _state.first { it.launch !== sent } } + if (ended == null && isCurrentLaunch(self)) failLaunch("The TV didn't start playback.") + } } catch (error: CancellationException) { // Transport teardown cancels the handoff deferreds. Clear the // spinner when this is still the active launch, but do not let a // superseded launch overwrite the replacement's state. if (isCurrentLaunch(self)) { - _state.update { it.copy(isConnecting = false, connectingDeviceId = null, isLaunching = false) } + _state.update { it.copy(isConnecting = false, connectingDeviceId = null, launch = null) } } throw error } catch (error: Throwable) { @@ -245,12 +295,14 @@ class PrairieCastController( it.copy( isConnecting = false, connectingDeviceId = null, - isLaunching = false, + launch = null, error = error.message ?: "Unable to cast.", ) } + _launchFeedback.tryEmit(PrairieCastLaunchFeedback.Failed) } } finally { + title.cancel() synchronized(launchJobLock) { if (launchJob === self) launchJob = null } @@ -261,10 +313,38 @@ class PrairieCastController( launchJob = job old } + // From the tap, after the swap: a superseded job can no longer + // overwrite this launch. + _state.update { it.copy(launch = pending, error = null) } + _launchFeedback.tryEmit(PrairieCastLaunchFeedback.Sent) previous?.cancel(CancellationException("Superseded by a newer remote launch.")) job.start() } + /** Ends the pending launch as failed; the haptic fires only if one was pending. */ + private fun failLaunch(message: String?) { + var failed = false + _state.update { + failed = it.launch != null + it.copy(launch = null, error = message ?: it.error) + } + if (failed) _launchFeedback.tryEmit(PrairieCastLaunchFeedback.Failed) + } + + /** The launched item's display title; display-only. */ + private suspend fun launchTitle(contentId: String): String? = + try { + // A warm-up, so the remote's artwork lookup for the same item + // joins this request instead of fetching it again. + val detail = catalogRepository.getCachedItemDetail(contentId) + ?: (catalogRepository.warmItemDetail(contentId) as? ApiResult.Success)?.data + detail?.let(::castDisplayTitle) + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + null + } + private fun isCurrentLaunch(job: Job): Boolean = synchronized(launchJobLock) { launchJob === job } /** @@ -446,10 +526,6 @@ class PrairieCastController( sendControl(PrairieCastControlCommand.setVideoGravity(value)) } - fun setHdrEnabled(enabled: Boolean) { - sendControl(PrairieCastControlCommand.setHdrEnabled(enabled)) - } - fun setSubtitleSyncMs(milliseconds: Int) { sendControl(PrairieCastControlCommand.setSubtitleSyncMs(milliseconds)) } @@ -653,7 +729,7 @@ class PrairieCastController( ) } - private suspend fun prepareRemoteIdentity(request: PrairieCastLaunchRequest) { + private suspend fun prepareRemoteIdentity(request: PrairieCastLaunchRequest, title: Deferred) { val version = awaitHandoffStep(VERSION_TIMEOUT_MS, "The TV did not respond.") { negotiatedVersion.await() } require(version == PrairieCastProtocol.version) { "Update Prairie on both devices to use Remote Control." } @@ -679,6 +755,10 @@ class PrairieCastController( serverName = server.displayName, profileId = profileId, profileName = null, + // Brief wait only: a TV that already has this profile + // answers at once, and the title shows only during a + // full handoff. + title = withTimeoutOrNull(OFFER_TITLE_WAIT_MS) { title.await() }, ), ), ) @@ -757,7 +837,8 @@ class PrairieCastController( val read = withContext(Dispatchers.IO) { input.read(chunk) } if (read < 0) break frameBuffer.append(chunk.copyOf(read)).forEach { payload -> - handleMessage(json.decodeFromString(PrairieCastMessage.serializer(), payload.decodeToString())) + // A kind added after this build is skipped, not fatal. + PrairieCastMessage.decodeOrNull(json, payload.decodeToString())?.let { handleMessage(it) } } } } catch (e: CancellationException) { @@ -822,7 +903,8 @@ class PrairieCastController( // opened. if (session !== deadSession || suppressReconnect || reconnectJob != null) return@launch teardownTransportLocked() - _state.update { it.copy(isReconnecting = true, isConnecting = false, isLaunching = false) } + // Quietly: a launch already sent may still start on the TV. + _state.update { it.copy(isReconnecting = true, isConnecting = false, launch = null) } reconnectJob = scope.launch { try { for (attempt in 1..MAX_RECONNECT_ATTEMPTS) { @@ -914,6 +996,7 @@ class PrairieCastController( return } val now = nowMs() + var started = false val reconciled = synchronized(volumeStateLock) { val next = if (isIdle) { volumeReconciler.clear() @@ -925,22 +1008,33 @@ class PrairieCastController( ) } _state.update { + // The launch ends when the TV reports that title, or + // anything besides what it was playing at the tap (the + // TV may report a resolved id for the same title). + val launch = it.launch + started = launch != null && launch.isSent && !isIdle && + (next.contentId == launch.contentId || next.contentId != launch.replacingContentId) it.copy( playbackState = next, error = null, isAutoResuming = if (!isIdle) false else it.isAutoResuming, - isLaunching = if (!isIdle) false else it.isLaunching, + launch = if (started) null else launch, ) } next } clock.ingest(reconciled, now) + if (started) _launchFeedback.tryEmit(PrairieCastLaunchFeedback.Started) } is PrairieCastMessage.Error -> { if (sessionIsAutoResumed && !remoteScreenVisible) { quietDisconnect() + } else if (_state.value.launch?.isSent == true && message.error.code !in CONTROL_REPLY_ERRORS) { + failLaunch(message.error.message) } else { - _state.update { it.copy(error = message.error.message, isLaunching = false) } + // A reply to a control pressed mid-launch, or an error the + // launch's own handoff will surface, does not end it. + _state.update { it.copy(error = message.error.message) } } } is PrairieCastMessage.Ping -> send(PrairieCastMessage.Pong()) @@ -1025,7 +1119,7 @@ class PrairieCastController( connectingDeviceId = null, isReconnecting = false, isAutoResuming = false, - isLaunching = false, + launch = null, ) } } @@ -1079,6 +1173,10 @@ class PrairieCastController( const val VERSION_TIMEOUT_MS = 5_000L const val CHALLENGE_TIMEOUT_MS = 15_000L const val READY_TIMEOUT_MS = 35_000L + const val OFFER_TITLE_WAIT_MS = 300L + const val LAUNCH_START_TIMEOUT_MS = 30_000L + /** TV errors that answer a control command rather than a launch. */ + val CONTROL_REPLY_ERRORS = setOf("player_not_ready", "command_failed") const val HEARTBEAT_INTERVAL_MS = 3_000L const val MAX_MISSED_HEARTBEATS = 3 const val MAX_RECONNECT_ATTEMPTS = 5 diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/PrairieCastSessionManager.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/PrairieCastSessionManager.kt index c8e1d38c8b..8eafecf0dd 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/PrairieCastSessionManager.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/PrairieCastSessionManager.kt @@ -41,6 +41,8 @@ data class PrairieCastState( val isConnected: Boolean = false, val deviceName: String? = null, val isPlaying: Boolean = false, + /** Playing, or buffering or loading to play: a rebuffer is still playback. */ + val isPlaybackActive: Boolean = false, val position: Double = 0.0, val duration: Double = 0.0, val title: String = "", @@ -687,6 +689,7 @@ class PrairieCastSessionManager(private val context: Context) { isConnected = true, deviceName = session.castDevice?.friendlyName, isPlaying = remoteClient?.playerState == MediaStatus.PLAYER_STATE_PLAYING, + isPlaybackActive = remoteClient?.playerState in ACTIVE_PLAYER_STATES, position = remoteClient?.approximateStreamPosition ?.div(1000.0) ?.let { player -> pending?.playbackSession?.sourcePositionForPlayer(player) ?: player } @@ -750,5 +753,10 @@ class PrairieCastSessionManager(private val context: Context) { private companion object { private const val TAG = "PrairieCastSessionMgr" private const val SERVER_PROGRESS_INTERVAL_MS = 10_000L + private val ACTIVE_PLAYER_STATES = setOf( + MediaStatus.PLAYER_STATE_PLAYING, + MediaStatus.PLAYER_STATE_BUFFERING, + MediaStatus.PLAYER_STATE_LOADING, + ) } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/di/AndroidModule.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/di/AndroidModule.kt index f47d81010d..d9bcd0d446 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/di/AndroidModule.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/di/AndroidModule.kt @@ -2,6 +2,11 @@ package org.prairieserver.prairie.android.di +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import org.prairieserver.prairie.common.downloads.DownloadEnqueuer import org.prairieserver.prairie.common.downloads.DownloadSubscriptionEvaluatorFactory import org.prairieserver.prairie.common.downloads.DownloadSubscriptionWorker @@ -91,6 +96,7 @@ import org.prairieserver.prairie.android.cast.SharedPrefsPrairieCastLastTargetSt import org.prairieserver.prairie.android.cast.PrairieCastController import org.prairieserver.prairie.android.cast.PrairieCastLastTargetStore import org.prairieserver.prairie.android.cast.PrairieCastSessionManager +import org.prairieserver.prairie.android.cast.PrairieCastState import org.koin.android.ext.koin.androidContext import org.koin.androidx.workmanager.dsl.worker import org.koin.core.module.dsl.viewModel @@ -112,6 +118,21 @@ val androidModule = module { // passes on cold start. single { createSecureSharedPrefs(androidContext()) } + // External sign-in (OIDC): the one native flow the phone may have in + // flight. Its own scope, so redeeming a code outlives the activity that + // received the redirect. + single { + org.prairieserver.prairie.android.auth.SharedPrefsPendingNativeSignInStore(get()) + } + single { + org.prairieserver.prairie.android.auth.NativeSignInCoordinator( + store = get(), + completer = org.prairieserver.prairie.android.auth.RepositoryNativeSignInCompleter(get(), get(), get()), + scope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.IO), + accountChoices = org.prairieserver.prairie.android.auth.SharedPrefsAccountChoiceStore(get()), + ) + } + // Multi-server registry. Loaded synchronously in init so MainActivity's // `runBlocking { resolveStartDestination() }` reads consistent state. single { AndroidServerRegistry(get(), get()) } @@ -189,6 +210,20 @@ val androidModule = module { // every collaborator that reports client identity (headers, playback // context, diagnostics) resolves this rather than deriving its own answer. single { PrairieClientBuildIdentity(BuildConfig.BUILD_NUMBER, BuildConfig.RELEASE_CHANNEL) } + // Settings → Experimental → Watch Party: on by default only in debug builds. + single { + org.prairieserver.prairie.common.watchparty.WatchPartyExperiment( + prefs = androidContext().getSharedPreferences( + org.prairieserver.prairie.common.watchparty.WatchPartyExperiment.PREFS_NAME, + android.content.Context.MODE_PRIVATE, + ), + defaultEnabled = BuildConfig.DEBUG, + onDisabled = { get().depart() }, + ) + } + single { + get() + } single { AndroidDeviceMetadataProvider( androidContext(), @@ -199,13 +234,30 @@ val androidModule = module { single { PrairieCastNsdBrowser(androidContext()) } single { CompanionPairingNsdBrowser(androidContext()) } single { RegistryCompanionPairingServerStore(get(), get()) } - single { RepositoryCompanionDeviceLoginApprover(get(), get()) } + single { + val authRepository = get() + // The nearby approval card names the account approving signs the TV in as. + RepositoryCompanionDeviceLoginApprover(get(), get(), accountNameOf = { scope -> accountNameOn(authRepository, scope) }) + } single { CompanionPairingTransportFactory { target -> TlsPskPairingClientTransport.connect(target.host, target.port) } } - single { CompanionPairingCoordinator(get(), get(), get()) } + single { + CompanionPairingCoordinator( + serverStore = get(), + deviceLoginApprover = get(), + transportFactory = get(), + // serverIdentity + endpoints in pushServer, and st=login matching. + identitySource = org.prairieserver.prairie.common.pairing.RegistryCompanionServerIdentitySource( + registry = get(), + identities = get(), + identityApi = get(), + identityTransitions = get(), + ), + ) + } single { SharedPrefsPrairieCastLastTargetStore(androidContext()) } single { PrairieCastController( @@ -213,6 +265,7 @@ val androidModule = module { serverRegistry = get(), tokenManager = get(), deviceLoginApi = get(), + catalogRepository = get(), lastTargetStore = get(), deviceNameProvider = { android.os.Build.MODEL?.trim()?.ifBlank { null } ?: "Android Phone" @@ -354,6 +407,18 @@ val androidModule = module { profileRepository = get(), ) } + worker { + org.prairieserver.prairie.common.downloads.OfflineSubtitleRefreshWorker( + appContext = androidContext(), + params = get(), + metadataStore = get(), + storage = get(), + httpClient = get(), + authorities = get(), + transitions = get(), + gate = get(), + ) + } // Kept for consistency, but DEAD AT RUNTIME: Koin's WorkManager factory // returns null on WM 2.10 + Koin 4.1.0, so AppWorkerFactory does the real // injection (see AppWorkerFactory). Update both if SyncWorker's deps change. @@ -390,22 +455,24 @@ val androidModule = module { qualityLadderClient = get(), seekIntervalStore = get(), activeProfileStore = get(), + shuffleFeatureStore = get(), ) } - viewModel { HomeViewModel(get(), get(), get(), get(), getOrNull(), get(), get()) } + viewModel { HomeViewModel(get(), get(), get(), get(), getOrNull(), get(), get(), catalogLanguage = get()) } viewModel { MainHeaderViewModel(get()) } viewModel { LibrariesViewModel( get(), get(), get(), getOrNull() ?: org.prairieserver.prairie.repository.port.NoOpUserItemStatePort, get(), - get(), + get(), + catalogLanguage = get(), ) } - viewModel { RecommendationsViewModel(get()) } + viewModel { RecommendationsViewModel(get(), catalogLanguage = get()) } viewModel { SearchViewModel(get()) } single { - org.prairieserver.prairie.android.ui.screens.browse.BrowsePrefsStore( + org.prairieserver.prairie.common.settings.BrowsePrefsStore( context = get(), serverRegistry = get(), ) @@ -445,9 +512,12 @@ val androidModule = module { viewModel { HistoryViewModel(get(), get()) } viewModel { CollectionsViewModel(get()) } viewModel { params -> CollectionDetailViewModel(get(), get(), params.get()) } - viewModel { RequestsViewModel(get()) } + viewModel { RequestsViewModel(get(), get()) } viewModel { RequestSearchViewModel(get()) } viewModel { MyRequestsViewModel(get()) } + viewModel { params -> + org.prairieserver.prairie.viewmodel.RequestApprovalsViewModel(get(), loadOnInit = params.getOrNull() ?: true) + } // Platform supplies "today" and the IANA timezone; the shared ViewModel's // week math stays deterministic in commonTest (no Clock.System default). viewModel { @@ -464,28 +534,87 @@ val androidModule = module { repository = get(), mediaType = args.first, tmdbId = args.second, + featureStore = get(), + ) + } + single { + org.prairieserver.prairie.android.auth.SignOutTeardown(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) + } + single { + org.prairieserver.prairie.android.auth.ProfileSwitchTeardown(get(), get(), get(), get(), get(), get(), get()) + } + // Profile Selection (Apple Settings → General → Profile Selection): the + // choice kept on this device, the tracker MainActivity drives from + // onStop/onStart, and the reset that drops the active profile when it asks. + single { + org.prairieserver.prairie.common.profiles.ProfileLaunchPreferences( + androidContext(), + org.prairieserver.prairie.common.profiles.ProfileLaunchPreferences.PHONE_PREFS_NAME, ) } - viewModel { SettingsViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } + single { + val cast = get() + // Casting counts as use only while the receiver plays (a rebuffer + // included): a session left connected after a pause or the end of a + // title does not hold the profile. + fun PrairieCastState.castPlaying() = isConnected && isPlaybackActive + org.prairieserver.prairie.common.profiles.ProfileAwayTracker( + get(), + get(), + isCasting = { cast.castState.value.castPlaying() }, + ).also { tracker -> + // Cast playback that stops or disconnects while Prairie is hidden + // starts the away clock then, as a local player's stop does. + cast.castState.map { it.castPlaying() }.distinctUntilChanged().drop(1) + .onEach { tracker.onRemotePlaybackChanged() } + .launchIn( + kotlinx.coroutines.CoroutineScope( + kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.Main.immediate, + ), + ) + } + } + single { org.prairieserver.prairie.android.auth.ActiveProfileReset(get(), get(), get(), get(), get(), get(), get(), get(), get()) } + viewModel { SettingsViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } viewModel { DiagnosticsViewModel(get()) } - viewModel { DownloadsViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } - viewModel { org.prairieserver.prairie.android.ui.screens.pairing.CompanionPairingViewModel(get(), get()) } - viewModel { ServerSetupViewModel(get(), get()) } - viewModel { LoginViewModel(get()) } + viewModel { DownloadsViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } + viewModel { org.prairieserver.prairie.android.ui.screens.pairing.CompanionPairingViewModel(get(), get(), get()) } + viewModel { + val tokens = get() + ServerSetupViewModel(get(), get(), hasSession = { !tokens.getAccessToken().isNullOrBlank() }) + } + viewModel { LoginViewModel(get(), get(), get(), get(), get(), get()) } + viewModel { + org.prairieserver.prairie.android.ui.screens.settings.SignInSettingsViewModel(get(), get(), get(), get(), get()) + } viewModel { SetupViewModel(get()) } viewModel { SignupViewModel(get()) } viewModel { InviteClaimViewModel(get(), get()) } viewModel { OnboardingTourViewModel(get(), get(), get(), get(), get()) } - viewModel { ProfileSelectionViewModel(get(), get()) } + viewModel { ProfileSelectionViewModel(get(), get(), profileVerificationRecovery = getOrNull()) } viewModel { CreateProfileViewModel(get()) } viewModel { EditProfileViewModel(get()) } - viewModel { ServerListViewModel(get(), get(), get(), get()) } + viewModel { ServerListViewModel(get(), get(), get(), get(), get()) } viewModel { params -> val args = params.get>() + val authRepository = get() DevicePairingViewModel( repository = get(), initialToken = args.first, initialCode = args.second, + // "Sign in a TV" approves on a chosen saved server (active one + // preselected) through that server's own account scope. + servers = org.prairieserver.prairie.viewmodel.RegistryDeviceApprovalServers( + registry = get(), + tokenManager = get(), + identityTransitions = get(), + // Each server's account, read through that server's own scope + // (renewing its access token first when it is expiring). + accountNameOf = { scope -> accountNameOn(authRepository, scope) }, + ), + initialServerId = params.getOrNull(), + // A link named the server: approve there without switching. + lockServer = params.getOrNull() ?: false, ) } @@ -535,13 +664,51 @@ val androidModule = module { savedStateHandle = get(), ) } - viewModel { org.prairieserver.prairie.android.ui.screens.watchtogether.WatchTogetherEntryViewModel(get()) } - viewModel { org.prairieserver.prairie.android.ui.screens.watchtogether.SuggestToRoomViewModel(get()) } + // Watch Party (phone). The shared ViewModels own the state; the handoff + // carries invitations and detail-page items to the hub in memory. + single { org.prairieserver.prairie.android.ui.screens.watchparty.WatchPartyHandoff() } + viewModel { + val registry = get() + org.prairieserver.prairie.viewmodel.WatchPartyHubViewModel( + repository = get(), + roomSession = get(), + availability = get(), + recents = get(), + isCurrentServer = { url -> + org.prairieserver.prairie.common.watchparty.watchPartyServerMatches( + linkServerUrl = url, + activeServerUrl = registry.activeEntry.value?.url, + ) + }, + ) + } viewModel { params -> - org.prairieserver.prairie.android.ui.screens.watchtogether.WatchTogetherLobbyViewModel( + org.prairieserver.prairie.viewmodel.WatchPartyLobbyViewModel( roomId = params.get(), repository = get(), roomSession = get(), + availability = get(), + ) + } + viewModel { + val catalog = get() + org.prairieserver.prairie.viewmodel.WatchPartyPickerViewModel( + repository = get(), + availability = get(), + search = { query -> + when (val result = catalog.browse(query = query, limit = 30)) { + is org.prairieserver.prairie.network.ApiResult.Success -> + org.prairieserver.prairie.network.ApiResult.Success(result.data.items) + is org.prairieserver.prairie.network.ApiResult.Error -> result + is org.prairieserver.prairie.network.ApiResult.NetworkError -> result + } + }, + ) + } + viewModel { + org.prairieserver.prairie.android.ui.screens.watchparty.WatchPartyDetailViewModel( + repository = get(), + handoff = get(), ) } @@ -553,3 +720,23 @@ val androidModule = module { } viewModel { LiveTvPlayerViewModel(get()) } } + +/** + * The account signed in on [scope]'s server, read with that server's own + * credentials (renewing its access token first when it is expiring). Null + * when it can't be read; throws when the provider couldn't re-check the + * session, so a card that can say so does. + */ +private suspend fun accountNameOn( + authRepository: org.prairieserver.prairie.repository.AuthRepository, + scope: org.prairieserver.prairie.network.AuthScopeSnapshot, +): String? = when (val me = authRepository.getCurrentUser(scope)) { + is org.prairieserver.prairie.network.ApiResult.Success -> me.data.username + is org.prairieserver.prairie.network.ApiResult.NetworkError -> + if (org.prairieserver.prairie.network.PrairieAuthUnavailableException.isProviderUnavailable(me.exception)) { + throw me.exception + } else { + null + } + is org.prairieserver.prairie.network.ApiResult.Error -> null +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/downloads/AppWorkerFactory.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/downloads/AppWorkerFactory.kt index 05c2f9e7c3..344545b1f9 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/downloads/AppWorkerFactory.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/downloads/AppWorkerFactory.kt @@ -12,6 +12,7 @@ import org.prairieserver.prairie.common.downloads.DownloadSubscriptionEvaluatorF import org.prairieserver.prairie.common.downloads.DownloadSubscriptionWorker import org.prairieserver.prairie.common.downloads.DownloadStatusWorker import org.prairieserver.prairie.common.downloads.DownloadWorker +import org.prairieserver.prairie.common.downloads.OfflineSubtitleRefreshWorker import org.prairieserver.prairie.common.diagnostics.DiagnosticsCoordinator import org.prairieserver.prairie.common.diagnostics.DiagnosticsUploadWorker import org.prairieserver.prairie.common.diagnostics.HostedDiagnosticsDeletionWorker @@ -76,6 +77,19 @@ class AppWorkerFactory : WorkerFactory() { devices = koin.get(), ) } + OfflineSubtitleRefreshWorker::class.java.name -> { + Log.i(TAG, "Building OfflineSubtitleRefreshWorker via Koin") + OfflineSubtitleRefreshWorker( + appContext = appContext, + params = workerParameters, + metadataStore = koin.get(), + storage = koin.get(), + httpClient = koin.get(), + authorities = koin.get(), + transitions = koin.get(), + gate = koin.get(), + ) + } DownloadSubscriptionWorker::class.java.name -> { Log.i(TAG, "Building DownloadSubscriptionWorker via Koin") DownloadSubscriptionWorker( diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/home/HomeRealtimeForegroundStarter.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/home/HomeRealtimeForegroundStarter.kt index 2a90e26ed1..839fbe4978 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/home/HomeRealtimeForegroundStarter.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/home/HomeRealtimeForegroundStarter.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import org.prairieserver.prairie.repository.ForegroundAccessCheck import org.prairieserver.prairie.repository.HomeRealtimeCoordinator import org.prairieserver.prairie.repository.ProfileRepository @@ -18,14 +19,18 @@ import org.prairieserver.prairie.repository.ProfileRepository * (ProfileRepository.profileSwitches) * reconnects so the coordinator's profile filter tracks the new session. * REST stays the source of truth; a dead socket just means pull behavior. + * Returning from the background runs [accessCheck], because a socket that was + * closed while stopped cannot report an access change made in that time. */ class HomeRealtimeForegroundStarter( private val coordinator: HomeRealtimeCoordinator, private val profileRepository: ProfileRepository, + private val accessCheck: ForegroundAccessCheck? = null, ) : DefaultLifecycleObserver { private var realtimeScope: CoroutineScope? = null private var foregroundScope: CoroutineScope? = null + private var stoppedSinceStart = false fun register() { ProcessLifecycleOwner.get().lifecycle.addObserver(this) @@ -34,9 +39,14 @@ class HomeRealtimeForegroundStarter( override fun onStart(owner: LifecycleOwner) { startRealtime() observeProfileSwitches() + if (stoppedSinceStart) { + stoppedSinceStart = false + foregroundScope?.launch { accessCheck?.afterReturnToForeground() } + } } override fun onStop(owner: LifecycleOwner) { + stoppedSinceStart = true stopRealtime() foregroundScope?.cancel() foregroundScope = null diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/profiles/ProfileLaunchAction.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/profiles/ProfileLaunchAction.kt new file mode 100644 index 0000000000..ac92e95aca --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/profiles/ProfileLaunchAction.kt @@ -0,0 +1,30 @@ +package org.prairieserver.prairie.android.profiles + +/** What the phone does about Profile Selection when Prairie opens or returns. */ +enum class ProfileLaunchAction { + /** Keep the current profile; Prairie is in use again. */ + KeepProfile, + + /** Clear the current profile and show Who's Watching. */ + AskWhoIsWatching, + + /** + * The setting asks, but the server cannot be reached and this profile + * has downloads. Who's Watching cannot load profiles or check a PIN + * offline, so keep the profile and its downloads usable, and ask once + * the server is back. + */ + KeepProfileOffline, +} + +/** + * The phone's launch and return decision. [requiresSelection] is the shared + * Profile Selection rule; [startsOfflineOnDownloads] says the server cannot be + * used and the active profile has local downloads. + */ +fun profileLaunchAction(requiresSelection: Boolean, startsOfflineOnDownloads: Boolean): ProfileLaunchAction = + when { + !requiresSelection -> ProfileLaunchAction.KeepProfile + startsOfflineOnDownloads -> ProfileLaunchAction.KeepProfileOffline + else -> ProfileLaunchAction.AskWhoIsWatching + } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/BackdropCard.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/BackdropCard.kt index 36cff0e2b5..6d558f2edc 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/BackdropCard.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/BackdropCard.kt @@ -1,5 +1,7 @@ package org.prairieserver.prairie.android.ui.components +import org.prairieserver.prairie.common.ui.components.SpoilerImage +import org.prairieserver.prairie.common.ui.components.DefaultArtworkKind import org.prairieserver.prairie.common.ui.components.ThumbhashImage import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background @@ -76,6 +78,8 @@ fun BackdropCard( title: String, backdropUrl: String?, backdropThumbhash: String?, + /** Spoiler protection: blur the artwork (an unstarted episode's still). */ + hideArtwork: Boolean = false, seriesTitle: String? = null, seasonNumber: Int? = null, episodeNumber: Int? = null, @@ -115,12 +119,15 @@ fun BackdropCard( shape = RoundedCornerShape(10.dp), ), ) { - ThumbhashImage( + SpoilerImage( url = backdropUrl, thumbhash = backdropThumbhash, + hidden = hideArtwork, contentDescription = title, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), + // The centre glyph below always covers where the mark would sit. + defaultArtwork = DefaultArtworkKind.GlowOnly, ) // Bottom scrim gradient diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/CardActionsHelpers.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/CardActionsHelpers.kt index 54f816f32a..eeb95177f6 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/CardActionsHelpers.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/CardActionsHelpers.kt @@ -9,15 +9,14 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import org.prairieserver.prairie.domain.MediaActionsCoordinator import org.prairieserver.prairie.model.catalog.BrowseItem +import org.prairieserver.prairie.model.catalog.withWatched import org.prairieserver.prairie.model.catalog.MediaItemUserState import kotlinx.coroutines.launch import org.koin.compose.koinInject /** - * Returns a [MediaCardActions] that delegates to a Koin-injected - * [MediaActionsCoordinator]. Each invocation also exposes the *current* state - * (after any optimistic update) via the trailing [view] block so callers can - * render the up-to-date watched / favorite / watchlist state on the card. + * Returns actions, current optimistic user state, and resume position. + * Watched changes clear progress together; failed writes restore the snapshot. * * Use this for grid screens (search, catalog, library, person detail) whose * ViewModels don't yet manage these actions. The Home screen wires actions @@ -27,7 +26,7 @@ import org.koin.compose.koinInject @Composable fun rememberBrowseItemCardActions( item: BrowseItem, -): Pair { +): Triple { val coordinator: MediaActionsCoordinator = koinInject() val scope = rememberCoroutineScope() @@ -35,16 +34,16 @@ fun rememberBrowseItemCardActions( // (local watched/favorite applied), the card must pick it up — keying on // contentId alone kept the stale state and hid the overlay. The card's own // optimistic toggles mutate `state` (not item.userState), so they aren't reset. - var state by remember(item.contentId, item.userState) { - mutableStateOf(item.userState ?: MediaItemUserState()) + var state by remember(item.contentId, item.userState, item.positionSeconds) { + mutableStateOf(item) } - val actions = remember(item.contentId, coordinator, scope) { + val actions = remember(item.contentId, item.userState, item.positionSeconds, coordinator, scope) { MediaCardActions( onSetWatched = { watched -> val writeIntent = coordinator.beginWatched(item.contentId, watched) val previous = state - state = state.copy(played = watched) + state = state.withWatched(watched) scope.launch { if (coordinator.performPersonalWrite(writeIntent).isFailure()) { if (coordinator.isCurrent(writeIntent)) state = previous @@ -63,12 +62,12 @@ fun rememberBrowseItemCardActions( } val membershipActions by coordinator.memberships.actions.collectAsState() - var displayed = state + var displayed = state.userState ?: MediaItemUserState() membershipActions.values.filter { it.intent.key.itemId == item.contentId && it.baseline != null && coordinator.memberships.current(it.intent) }.forEach { displayed = if (it.intent.key.kind == org.prairieserver.prairie.repository.port.MembershipPort.Kind.FAVORITE) displayed.copy(isFavorite = it.baseline!!.present) else displayed.copy(inWatchlist = it.baseline!!.present) } - return actions to displayed + return Triple(actions, displayed, state.positionSeconds) } private fun org.prairieserver.prairie.network.ApiResult.isFailure(): Boolean = diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MainAppTopBar.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MainAppTopBar.kt index 093b33742d..32039c335b 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MainAppTopBar.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MainAppTopBar.kt @@ -42,7 +42,7 @@ fun MainAppTopBar( hazeState: HazeState, onSearchClick: () -> Unit, onRequestsClick: (() -> Unit)? = null, - onWatchTogetherClick: (() -> Unit)?, + onWatchPartyClick: (() -> Unit)?, onSettingsClick: () -> Unit, onSwitchProfileClick: () -> Unit, onSwitchServerClick: () -> Unit, @@ -80,7 +80,7 @@ fun MainAppTopBar( activeProfile = activeProfile, onSearchClick = onSearchClick, onRequestsClick = onRequestsClick, - onWatchTogetherClick = onWatchTogetherClick, + onWatchPartyClick = onWatchPartyClick, onSettingsClick = onSettingsClick, onSwitchProfileClick = onSwitchProfileClick, onSwitchServerClick = onSwitchServerClick, diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MediaCard.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MediaCard.kt index 9e808eeb65..01e2316471 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MediaCard.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MediaCard.kt @@ -1,5 +1,7 @@ package org.prairieserver.prairie.android.ui.components +import org.prairieserver.prairie.common.ui.components.SpoilerImage +import org.prairieserver.prairie.common.ui.components.DefaultArtworkKind import org.prairieserver.prairie.common.ui.components.ThumbhashImage import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background @@ -70,6 +72,7 @@ object MediaGridDefaults { @Composable fun MediaCard( title: String, + hideArtwork: Boolean = false, posterUrl: String?, posterThumbhash: String?, year: Int? = null, @@ -92,6 +95,7 @@ fun MediaCard( sharedContentId: String? = null, detailBackdropUrl: String? = null, detailBackdropThumbhash: String? = null, + hideDetailArtwork: Boolean = hideArtwork, ) { val overlayState = LocalCardOverlayUiState.current var menuExpanded by remember { mutableStateOf(false) } @@ -113,8 +117,8 @@ fun MediaCard( // pairs with this card, not a duplicate elsewhere on screen. if (heroKey != null) heroHandoff?.pendingKey = heroKey // Match the detail hero from its first loading frame. - heroHandoff?.pendingArtworkUrl = detailBackdropUrl ?: posterUrl - heroHandoff?.pendingArtworkThumbhash = if (detailBackdropUrl != null) { + heroHandoff?.pendingArtworkUrl = (detailBackdropUrl ?: posterUrl).takeUnless { hideDetailArtwork } + heroHandoff?.pendingArtworkThumbhash = if (hideDetailArtwork) null else if (detailBackdropUrl != null) { detailBackdropThumbhash } else { posterThumbhash @@ -135,12 +139,14 @@ fun MediaCard( .heroSource(heroKey) .clip(MaterialTheme.shapes.small), ) { - ThumbhashImage( + SpoilerImage( + hidden = hideArtwork, url = posterUrl, thumbhash = posterThumbhash, contentDescription = title, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), + defaultArtwork = DefaultArtworkKind.forItemType(type), ) // Card-overlay badge layer (resolution / audio / ratings, etc.). diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MediaCardActions.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MediaCardActions.kt index a46daf606a..9e8acd54c0 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MediaCardActions.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MediaCardActions.kt @@ -8,8 +8,6 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -36,7 +34,7 @@ data class MediaCardActions( /** * Long-press context menu for [MediaCard] and [BackdropCard]. Renders as a - * Material 3 [DropdownMenu] anchored to the card. Each item invokes the + * [PrairieDropdownMenu] anchored to the card. Each item invokes the * matching callback in [actions] then closes the menu. */ @Composable @@ -50,7 +48,7 @@ fun MediaCardContextMenu( ) { if (actions.isEmpty) return - DropdownMenu( + PrairieDropdownMenu( expanded = expanded, onDismissRequest = onDismiss, ) { @@ -99,7 +97,7 @@ fun MediaCardContextMenu( @Composable private fun MenuRow(text: String, icon: ImageVector, onClick: () -> Unit) { - DropdownMenuItem( + PrairieDropdownMenuItem( text = { Text(text) }, leadingIcon = { Icon(icon, contentDescription = null) }, onClick = onClick, diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MediaRow.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MediaRow.kt index cfe725d7c4..a5f912c78f 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MediaRow.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MediaRow.kt @@ -40,7 +40,10 @@ import org.prairieserver.prairie.common.ui.components.DeferImagePresentationWhil import org.prairieserver.prairie.common.diagnostics.DiagnosticsKeyAnomalyLogger import org.prairieserver.prairie.common.diagnostics.DiagnosticsKeyCollection import org.prairieserver.prairie.common.diagnostics.DiagnosticsListSnapshot +import org.prairieserver.prairie.common.cards.LocalEpisodeSpoilerPrefs import org.prairieserver.prairie.model.section.SectionItem +import org.prairieserver.prairie.model.settings.EpisodeSpoilerPrefs +import org.prairieserver.prairie.model.settings.EpisodeSpoilers import org.prairieserver.prairie.overlays.OverlayData import org.prairieserver.prairie.overlays.OverlayDataExtractor import org.prairieserver.prairie.android.ui.navigation.LocalHeroSourceHandoff @@ -57,8 +60,19 @@ private data class MediaRowItemModel( val overlay: OverlayData, val isBook: Boolean, val contentType: String, + /** Spoiler protection: blur this card's episode still. */ + val hidesArtwork: Boolean, ) +/** + * True when [item] is an episode the profile has not started and [prefs] + * blur such stills. For episodes the backdrop is the still, so the card and + * the detail hand-off both treat it as a spoiler. + */ +private fun hidesEpisodeArtwork(item: SectionItem, prefs: EpisodeSpoilerPrefs): Boolean = + item.type.equals("episode", ignoreCase = true) && + prefs.hidesImage(EpisodeSpoilers.isUnwatched(item), EpisodeSpoilers.selectedImageIsStill(item)) + /** * Horizontal row of media cards with a section headline above. * @@ -94,7 +108,8 @@ fun MediaRow( diagnosticsKeySnapshot, ) } - val rowItems = remember(uniqueItems, showProgress, cardStyle) { + val spoilerPrefs = LocalEpisodeSpoilerPrefs.current + val rowItems = remember(uniqueItems, showProgress, cardStyle, spoilerPrefs) { uniqueItems.map { item -> val pos = item.positionSeconds val dur = item.durationSeconds @@ -108,13 +123,10 @@ fun MediaRow( } else { null } - // Landscape cards take the backdrop first for every item type - // (iOS EpisodeThumbCard). For episodes the server's backdrop_url - // IS the episode still (falling back to the series backdrop), - // while poster_url is the season/series portrait — which the - // 16:9 frame used to crop down to a sliver of the title art. - val imageUrl = item.backdropUrl ?: item.posterUrl - val imageThumbhash = item.backdropThumbhash ?: item.posterThumbhash + // Landscape cards prefer the backdrop. Episode provenance tells + // us whether the selected artwork is a still or series fallback. + val imageUrl = item.backdropUrl?.takeIf { it.isNotBlank() } ?: item.posterUrl + val imageThumbhash = if (!item.backdropUrl.isNullOrBlank()) item.backdropThumbhash else item.posterThumbhash MediaRowItemModel( item = item, progress = progress, @@ -124,6 +136,7 @@ fun MediaRow( overlay = OverlayDataExtractor.fromSectionItem(item), isBook = isBookLikeItemType(item.type), contentType = "${cardStyle.name}:${item.type}", + hidesArtwork = hidesEpisodeArtwork(item, spoilerPrefs), ) } } @@ -207,6 +220,15 @@ fun MediaRow( // vertical fling (the helper ORs in any deferral already in scope). val rowState = rememberLazyListState() fun openDetail(item: SectionItem) { + // A blurred episode still must not reappear sharp as the detail + // page's loading artwork, so it is neither warmed nor handed off. + // (Home swaps in the parent series artwork when it knows it.) + if (hidesEpisodeArtwork(item, spoilerPrefs)) { + heroHandoff?.pendingArtworkUrl = null + heroHandoff?.pendingArtworkThumbhash = null + onItemClick(item.contentId) + return + } // Keep one full-size backdrop request alive across the loading- // skeleton -> detail-content composition swap. Without this, the // skeleton's differently-sized request can be cancelled as soon as @@ -273,6 +295,7 @@ fun MediaRow( title = item.title, backdropUrl = rowItem.backdropUrl, backdropThumbhash = rowItem.backdropThumbhash, + hideArtwork = rowItem.hidesArtwork, seriesTitle = item.seriesTitle, seasonNumber = item.seasonNumber, episodeNumber = item.episodeNumber, @@ -295,6 +318,7 @@ fun MediaRow( CardStyle.Poster -> { MediaCard( title = item.title, + hideArtwork = item.type.equals("episode", ignoreCase = true) && spoilerPrefs.hidesImage(EpisodeSpoilers.isUnwatched(item), item.posterIsEpisodeStill), posterUrl = item.posterUrl, posterThumbhash = item.posterThumbhash, year = item.year, diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/PrairieConfirmDialog.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/PrairieConfirmDialog.kt index fee0cf7202..0231e53132 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/PrairieConfirmDialog.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/PrairieConfirmDialog.kt @@ -1,29 +1,189 @@ package org.prairieserver.prairie.android.ui.components +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ButtonDefaults +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Logout +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import org.prairieserver.prairie.android.ui.theme.SettingsDimens +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import org.prairieserver.prairie.android.ui.theme.PrairieDestructive import org.prairieserver.prairie.android.ui.theme.PrairieForeground -import org.prairieserver.prairie.android.ui.theme.PrairieMutedText -import org.prairieserver.prairie.android.ui.theme.PrairieSurfaceContainer + +/** How a dialog button reads: the safe default, the one recommended action, or a destructive one. */ +enum class PrairieDialogActionStyle { Default, Primary, Destructive } + +data class PrairieDialogAction( + val label: String, + val onClick: () -> Unit, + val style: PrairieDialogActionStyle = PrairieDialogActionStyle.Default, + val enabled: Boolean = true, +) + +/** The dialog card's palette: one raised surface over a dim, Paper and red for actions. */ +internal object PrairieDialogColors { + val Surface = Color(0xFF1C1C1F) + val Stroke = Color.White.copy(alpha = 0.06f) + val Body = PrairieForeground.copy(alpha = 0.62f) + val DefaultButton = Color.White.copy(alpha = 0.08f) + val Paper = Color(0xFFEDEDED) + val Ink = Color(0xFF0B0B0C) +} /** - * The one confirmation dialog. + * The one dialog. A 28dp card with an optional tinted icon, a title, a body, + * and full-width buttons stacked in the order given — the recommended or + * destructive action first, the safe way out last. Stock M3 puts small text + * buttons in the bottom corner; these are easy to reach with a thumb and say + * how dangerous they are by their fill. * - * Extracted from the "Remove all downloads?" dialog that was inline in - * `SettingsScreen`, because sign-out now needs the identical gate from two - * unrelated places and a third hand-rolled `AlertDialog` is how a surface ends - * up with three reds and three button orders. + * [content] carries anything beyond a sentence of body copy: text fields, a + * short list. It scrolls when a small window cannot fit it. + */ +@Composable +fun PrairieDialog( + title: String, + onDismissRequest: () -> Unit, + actions: List, + modifier: Modifier = Modifier, + message: String? = null, + icon: ImageVector? = null, + iconTint: Color = PrairieForeground, + content: (@Composable ColumnScope.() -> Unit)? = null, +) { + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + val shape = RoundedCornerShape(28.dp) + Column( + modifier = modifier + .padding(horizontal = 22.dp, vertical = 24.dp) + .widthIn(max = 420.dp) + .fillMaxWidth() + .clip(shape) + .background(PrairieDialogColors.Surface) + .border(1.dp, PrairieDialogColors.Stroke, shape) + .verticalScroll(rememberScrollState()) + .padding(start = 24.dp, end = 24.dp, top = 24.dp, bottom = 20.dp), + ) { + if (icon != null) { + Box( + modifier = Modifier + .padding(bottom = 16.dp) + .size(44.dp) + .clip(CircleShape) + .background(iconTint.copy(alpha = 0.16f)), + contentAlignment = Alignment.Center, + ) { + Icon(imageVector = icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(24.dp)) + } + } + Text( + text = title, + color = PrairieForeground, + fontSize = 22.sp, + lineHeight = 28.sp, + fontWeight = FontWeight.Medium, + ) + if (!message.isNullOrBlank()) { + Text( + text = message, + color = PrairieDialogColors.Body, + fontSize = 15.sp, + lineHeight = 22.sp, + modifier = Modifier.padding(top = 10.dp), + ) + } + if (content != null) { + CompositionLocalProvider( + LocalContentColor provides PrairieDialogColors.Body, + LocalTextStyle provides TextStyle(fontSize = 15.sp, lineHeight = 22.sp), + ) { + Column( + modifier = Modifier.padding(top = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + content = content, + ) + } + } + if (actions.isNotEmpty()) { + Column( + modifier = Modifier.padding(top = 22.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + actions.forEach { action -> PrairieDialogButton(action) } + } + } + } + } +} + +@Composable +private fun PrairieDialogButton(action: PrairieDialogAction) { + val (fill, textColor) = when (action.style) { + PrairieDialogActionStyle.Primary -> PrairieDialogColors.Paper to PrairieDialogColors.Ink + PrairieDialogActionStyle.Destructive -> PrairieDestructive to Color.White + PrairieDialogActionStyle.Default -> PrairieDialogColors.DefaultButton to PrairieForeground + } + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + // Before the fill, so a disabled button dims as a whole, not just its label. + .alpha(if (action.enabled) 1f else 0.4f) + .clip(RoundedCornerShape(24.dp)) + .background(fill) + .clickable(enabled = action.enabled, role = Role.Button, onClick = action.onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = action.label, + color = textColor, + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + ) + } +} + +/** + * The one confirmation dialog: a question, what it does, and two buttons. * - * Cancel is the safe choice and sits where M3 puts the dismissive action; the - * confirm button carries [PrairieDestructive] when the action destroys or - * discards something. + * Cancel is the safe choice and sits last, under the action. The action is + * filled red when it destroys or discards something, Paper otherwise. */ @Composable fun PrairieConfirmDialog( @@ -36,37 +196,24 @@ fun PrairieConfirmDialog( dismissLabel: String = "Cancel", destructive: Boolean = true, confirmEnabled: Boolean = true, + icon: ImageVector? = null, ) { - AlertDialog( + PrairieDialog( + title = title, + message = body, onDismissRequest = onDismiss, modifier = modifier, - shape = RoundedCornerShape(SettingsDimens.cardRadius), - containerColor = PrairieSurfaceContainer, - titleContentColor = PrairieForeground, - textContentColor = PrairieMutedText, - title = { Text(title) }, - text = { Text(body) }, - confirmButton = { - TextButton( - enabled = confirmEnabled, + icon = icon, + iconTint = if (destructive) PrairieDestructive else PrairieForeground, + actions = listOf( + PrairieDialogAction( + label = confirmLabel, onClick = onConfirm, - colors = ButtonDefaults.textButtonColors( - contentColor = if (destructive) PrairieDestructive else PrairieForeground, - disabledContentColor = (if (destructive) PrairieDestructive else PrairieForeground) - .copy(alpha = SettingsDimens.disabledAlpha), - ), - ) { - Text(confirmLabel) - } - }, - dismissButton = { - TextButton( - onClick = onDismiss, - colors = ButtonDefaults.textButtonColors(contentColor = PrairieForeground), - ) { - Text(dismissLabel) - } - }, + style = if (destructive) PrairieDialogActionStyle.Destructive else PrairieDialogActionStyle.Primary, + enabled = confirmEnabled, + ), + PrairieDialogAction(label = dismissLabel, onClick = onDismiss), + ), ) } @@ -109,5 +256,6 @@ fun SignOutConfirmDialog( confirmLabel = "Sign out", onConfirm = onConfirm, onDismiss = onDismiss, + icon = Icons.AutoMirrored.Rounded.Logout, ) } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/PrairieMenu.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/PrairieMenu.kt index 3086ec33e4..f3ccbd78f6 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/PrairieMenu.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/PrairieMenu.kt @@ -1,54 +1,62 @@ package org.prairieserver.prairie.android.ui.components import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.clickable +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.PopupProperties import org.prairieserver.prairie.android.ui.theme.MenuDimens import org.prairieserver.prairie.android.ui.theme.SettingsTextStyles -import org.prairieserver.prairie.android.ui.theme.PrairieBorder +import org.prairieserver.prairie.android.ui.theme.PrairieDestructive import org.prairieserver.prairie.android.ui.theme.PrairieForeground -import org.prairieserver.prairie.android.ui.theme.PrairieSurfaceContainer -import org.prairieserver.prairie.android.ui.theme.siloRowTopDivider -// The popup half of the grouped-surface pass. -// -// A stock `DropdownMenu` full of `DropdownMenuItem`s renders on M3's own -// container colour at M3's own radius with M3's own `bodyLarge` label, which -// is why the profile menu read as visibly cheaper than the settings screen it -// opens. These two primitives put a menu on the same surface, radius, hairline -// and label type as a settings card — and nothing else: no descriptions, no -// leading icons, no trailing controls. A menu is terse by definition, and the -// settings rows dropped their leading icons for exactly the reason a menu -// should not gain them. +// Menus share one surface with the dialogs: the raised card colour, a faint +// hairline, a 20dp radius and a real shadow, because a popup floats over +// artwork. Rows are 48dp with an optional leading icon — the Apple clients +// label menu items with symbols, and an icon is how a viewer finds "Sign out" +// in a list without reading it. Destructive rows are red, icon and all. + +internal object PrairieMenuColors { + val Surface = Color(0xFF1C1C1F) + val Stroke = Color.White.copy(alpha = 0.07f) + val Icon = PrairieForeground.copy(alpha = 0.78f) + val Detail = PrairieForeground.copy(alpha = 0.58f) + val Separator = Color.White.copy(alpha = 0.07f) + val Selected = Color.White.copy(alpha = 0.07f) +} /** - * A [DropdownMenu] wearing the grouped-surface treatment. - * - * Every colour the popup paints is passed explicitly. M3 would otherwise - * default `containerColor` to `surfaceContainer` and `tonalElevation` to 3dp — - * currently harmless (the scheme's `surfaceContainer` *is* the card colour and - * its `surfaceTint` is transparent, so the tonal overlay resolves to nothing), - * but harmless by coincidence of three unrelated theme values. Naming them - * keeps a future tweak to any one of those from quietly re-tinting every menu. - * - * The drop shadow is left at the M3 default: a popup floats over artwork and - * needs the lift, and unlike the tonal overlay a shadow does not push the - * surface colour off-palette. + * A [DropdownMenu] wearing the Prairie menu surface. Takes the same parameters as + * the Material menu it wraps, so any stock menu can switch to it by name. */ @Composable fun PrairieDropdownMenu( @@ -56,58 +64,169 @@ fun PrairieDropdownMenu( onDismissRequest: () -> Unit, modifier: Modifier = Modifier, offset: DpOffset = DpOffset(0.dp, 0.dp), + scrollState: ScrollState = rememberScrollState(), + properties: PopupProperties = PopupProperties(focusable = true), content: @Composable ColumnScope.() -> Unit, ) { DropdownMenu( expanded = expanded, onDismissRequest = onDismissRequest, - modifier = modifier, + modifier = modifier.widthIn(min = MenuDimens.minWidth), offset = offset, + scrollState = scrollState, + properties = properties, shape = RoundedCornerShape(MenuDimens.cornerRadius), - containerColor = PrairieSurfaceContainer, + containerColor = PrairieMenuColors.Surface, tonalElevation = 0.dp, - border = BorderStroke(MenuDimens.borderThickness, PrairieBorder), + shadowElevation = 12.dp, + border = BorderStroke(MenuDimens.borderThickness, PrairieMenuColors.Stroke), content = content, ) } /** - * One row of a [PrairieDropdownMenu]: a label, and nothing else. + * Drop-in replacement for [DropdownMenuItem] with the Prairie row: 48dp, the + * menu label type, muted leading icon, and red when [destructive]. + */ +@Composable +fun PrairieDropdownMenuItem( + text: @Composable () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, + leadingIcon: (@Composable () -> Unit)? = null, + trailingIcon: (@Composable () -> Unit)? = null, + enabled: Boolean = true, + destructive: Boolean = false, +) { + val labelColor = if (destructive) PrairieDestructive else PrairieForeground + DropdownMenuItem( + // No color in the style: the label takes the row's content color, so a + // disabled row dims with disabledTextColor. + text = { ProvideTextStyle(SettingsTextStyles.menuLabel) { text() } }, + onClick = onClick, + modifier = modifier.heightIn(min = MenuDimens.rowMinHeight), + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + enabled = enabled, + colors = MenuDefaults.itemColors( + textColor = labelColor, + leadingIconColor = if (destructive) PrairieDestructive else PrairieMenuColors.Icon, + trailingIconColor = PrairieMenuColors.Detail, + disabledTextColor = labelColor.copy(alpha = 0.38f), + disabledLeadingIconColor = PrairieMenuColors.Icon.copy(alpha = 0.38f), + disabledTrailingIconColor = PrairieMenuColors.Detail.copy(alpha = 0.38f), + ), + contentPadding = PaddingValues(horizontal = MenuDimens.rowHorizontalPadding), + ) +} + +/** + * One row of a [PrairieDropdownMenu]: an optional leading [icon], the label, an + * optional [detail] line, and a check when [checked]. * - * @param showDivider Draws the settings hairline above this row. Menus use it - * to separate groups, not to rule every row — a settings card separates its - * groups by being a different card, which a single popup cannot do. + * @param showDivider Draws a hairline above this row, to separate groups. */ @Composable fun PrairieMenuItem( label: String, onClick: () -> Unit, modifier: Modifier = Modifier, - labelColor: Color = PrairieForeground, + icon: ImageVector? = null, + detail: String? = null, + checked: Boolean = false, + destructive: Boolean = false, + labelColor: Color = if (destructive) PrairieDestructive else PrairieForeground, showDivider: Boolean = false, +) { + if (showDivider) PrairieMenuDivider() + PrairieDropdownMenuItem( + text = { + Column { + Text( + text = label, + color = labelColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!detail.isNullOrBlank()) { + Text( + text = detail, + color = PrairieMenuColors.Detail, + fontSize = 12.5.sp, + lineHeight = 16.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + }, + onClick = onClick, + modifier = modifier + .fillMaxWidth() + .then(if (checked) Modifier.background(PrairieMenuColors.Selected) else Modifier), + leadingIcon = icon?.let { vector -> + { Icon(imageVector = vector, contentDescription = null, modifier = Modifier.size(22.dp)) } + }, + trailingIcon = if (checked) { + { Icon(Icons.Rounded.Check, contentDescription = "Selected", tint = PrairieForeground, modifier = Modifier.size(20.dp)) } + } else { + null + }, + destructive = destructive, + ) +} + +/** Hairline between groups of menu rows. */ +@Composable +fun PrairieMenuDivider() { + Box( + modifier = Modifier + .padding(vertical = 4.dp) + .fillMaxWidth() + .height(1.dp) + .background(PrairieMenuColors.Separator), + ) +} + +/** + * Who and where, above a menu's actions: an avatar or mark, a name, and a + * detail line such as the server it belongs to. + */ +@Composable +fun PrairieMenuHeader( + title: String, + modifier: Modifier = Modifier, + detail: String? = null, + leading: (@Composable () -> Unit)? = null, ) { Row( - // `fillMaxWidth` resolves against the menu's `IntrinsicSize.Max` - // column, so every row ends up as wide as the widest label rather - // than as wide as the window. modifier = modifier .fillMaxWidth() - .widthIn(min = MenuDimens.minWidth) - .heightIn(min = MenuDimens.rowMinHeight) - .siloRowTopDivider(showDivider) - .clickable(onClick = onClick) - .padding( - horizontal = MenuDimens.rowHorizontalPadding, - vertical = MenuDimens.rowVerticalPadding, - ), + .padding(start = MenuDimens.rowHorizontalPadding, end = MenuDimens.rowHorizontalPadding, top = 8.dp, bottom = 10.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = label, - style = SettingsTextStyles.rowLabel, - color = labelColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + if (leading != null) { + leading() + Box(Modifier.size(12.dp)) + } + Column(modifier = Modifier.weight(1f, fill = false)) { + Text( + text = title, + style = SettingsTextStyles.menuLabel.copy(fontSize = 15.sp), + color = PrairieForeground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!detail.isNullOrBlank()) { + Text( + text = detail, + color = PrairieMenuColors.Detail, + fontSize = 12.5.sp, + lineHeight = 16.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/PrairieTopBar.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/PrairieTopBar.kt index 1411e855c1..1a26851185 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/PrairieTopBar.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/PrairieTopBar.kt @@ -3,6 +3,7 @@ package org.prairieserver.prairie.android.ui.components import androidx.compose.foundation.layout.RowScope import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -12,6 +13,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight /** * Reusable top app bar for Prairie screens. @@ -22,6 +24,8 @@ import androidx.compose.ui.graphics.Color * @param containerColor Bar background. Defaults to the app's `surface`; the * grouped-settings screens pass their own lifted page ground so the bar and * the list below it are one continuous surface instead of two tones. + * @param centerTitle Centres the title, as Apple's inline navigation titles + * are. Settings sub-pages use it to match the Apple apps. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -30,30 +34,44 @@ fun PrairieTopBar( onBackClick: (() -> Unit)? = null, actions: @Composable RowScope.() -> Unit = {}, containerColor: Color = MaterialTheme.colorScheme.surface, + centerTitle: Boolean = false, ) { - TopAppBar( - title = { - Text( - text = title, - style = MaterialTheme.typography.titleLarge, - ) - }, - navigationIcon = { - if (onBackClick != null) { - IconButton(onClick = onBackClick) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Navigate back", - ) - } + val titleSlot: @Composable () -> Unit = { + Text( + text = title, + style = if (centerTitle) MaterialTheme.typography.titleMedium else MaterialTheme.typography.titleLarge, + fontWeight = if (centerTitle) FontWeight.SemiBold else null, + ) + } + val navigationSlot: @Composable () -> Unit = { + if (onBackClick != null) { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Navigate back", + ) } - }, - actions = actions, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = containerColor, - titleContentColor = MaterialTheme.colorScheme.onSurface, - navigationIconContentColor = MaterialTheme.colorScheme.onSurface, - actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant, - ), + } + } + val colors = TopAppBarDefaults.topAppBarColors( + containerColor = containerColor, + titleContentColor = MaterialTheme.colorScheme.onSurface, + navigationIconContentColor = MaterialTheme.colorScheme.onSurface, + actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant, ) + if (centerTitle) { + CenterAlignedTopAppBar( + title = titleSlot, + navigationIcon = navigationSlot, + actions = actions, + colors = colors, + ) + } else { + TopAppBar( + title = titleSlot, + navigationIcon = navigationSlot, + actions = actions, + colors = colors, + ) + } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/ProfileMenu.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/ProfileMenu.kt index ef76b99b9d..018f885ec5 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/ProfileMenu.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/ProfileMenu.kt @@ -5,7 +5,21 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import org.prairieserver.prairie.android.ui.theme.PrairieDestructive +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Logout +import androidx.compose.material.icons.rounded.Dns +import androidx.compose.material.icons.rounded.Groups +import androidx.compose.material.icons.rounded.Inbox +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material.icons.rounded.SwitchAccount +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.unit.dp +import org.koin.compose.koinInject +import org.prairieserver.prairie.android.ui.screens.profiles.ProfileAvatar +import org.prairieserver.prairie.common.ui.components.avatarRef +import org.prairieserver.prairie.common.ui.marquee.ServerBranding +import org.prairieserver.prairie.model.profile.Profile +import org.prairieserver.prairie.network.ServerRegistry /** * The profile-avatar dropdown, in one place. @@ -18,8 +32,8 @@ import org.prairieserver.prairie.android.ui.theme.PrairieDestructive * this. * * Item order and gating are unchanged. A null [onRequestsClick] is a server - * with `requests_enabled` off, and a null [onWatchTogetherClick] is the - * client-side Watch Together gate; neither is ever shown unconditionally, and + * with `requests_enabled` off, and a null [onWatchPartyClick] is the + * Settings → Experimental → Watch Party gate; neither is ever shown unconditionally, and * nothing new was added. Reading/ebooks are phone-only and reached from * Libraries, and Requests keeps its two entry points (this menu and search). * @@ -35,11 +49,15 @@ fun ProfileMenu( onSwitchServerClick: () -> Unit, onSignOutClick: () -> Unit, onRequestsClick: (() -> Unit)? = null, - onWatchTogetherClick: (() -> Unit)? = null, + onWatchPartyClick: (() -> Unit)? = null, // Prairie: Live TV, provided by MainScreen via LocalLiveTvMenuAction. onLiveTvClick: (() -> Unit)? = LocalLiveTvMenuAction.current, + // Who is signed in, for the header. Null hides the header. + activeProfile: Profile? = null, ) { var confirmSignOut by rememberSaveable { mutableStateOf(false) } + val serverRegistry: ServerRegistry = koinInject() + val activeServer by serverRegistry.activeEntry.collectAsState() // Whether anything sits above the account actions. // @@ -51,15 +69,28 @@ fun ProfileMenu( // old menu drew its divider unconditionally, so a server with requests // disabled opened onto a stray rule above its first item. val hasFeatureGroup = - onRequestsClick != null || onLiveTvClick != null || onWatchTogetherClick != null + onRequestsClick != null || onLiveTvClick != null || onWatchPartyClick != null PrairieDropdownMenu( expanded = expanded, onDismissRequest = onDismissRequest, ) { + if (activeProfile != null) { + PrairieMenuHeader( + title = activeProfile.name, + detail = activeServer?.let { server -> + listOf(server.displayName, ServerBranding.hostLabel(server.url)).distinct().joinToString(" · ") + }, + leading = { + ProfileAvatar(avatar = activeProfile.avatarRef(), name = activeProfile.name, size = 40.dp) + }, + ) + PrairieMenuDivider() + } if (onRequestsClick != null) { PrairieMenuItem( label = "Requests", + icon = Icons.Rounded.Inbox, onClick = { onDismissRequest() onRequestsClick() @@ -75,17 +106,19 @@ fun ProfileMenu( }, ) } - if (onWatchTogetherClick != null) { + if (onWatchPartyClick != null) { PrairieMenuItem( - label = "Watch together", + label = "Watch Party", + icon = Icons.Rounded.Groups, onClick = { onDismissRequest() - onWatchTogetherClick() + onWatchPartyClick() }, ) } PrairieMenuItem( label = "Settings", + icon = Icons.Rounded.Settings, showDivider = hasFeatureGroup, onClick = { onDismissRequest() @@ -94,6 +127,7 @@ fun ProfileMenu( ) PrairieMenuItem( label = "Switch profile", + icon = Icons.Rounded.SwitchAccount, onClick = { onDismissRequest() onSwitchProfileClick() @@ -101,6 +135,7 @@ fun ProfileMenu( ) PrairieMenuItem( label = "Switch server", + icon = Icons.Rounded.Dns, onClick = { onDismissRequest() onSwitchServerClick() @@ -108,7 +143,9 @@ fun ProfileMenu( ) PrairieMenuItem( label = "Sign out", - labelColor = PrairieDestructive, + icon = Icons.AutoMirrored.Rounded.Logout, + destructive = true, + showDivider = true, onClick = { onDismissRequest() confirmSignOut = true diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/SortFilterControls.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/SortFilterControls.kt index eed28bb1b7..884bf9fae2 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/SortFilterControls.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/SortFilterControls.kt @@ -19,8 +19,6 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedButton @@ -88,14 +86,14 @@ fun SortFilterControlsRow( trailingChevron = true, onClick = { sortMenuOpen = true }, ) - DropdownMenu( + PrairieDropdownMenu( expanded = sortMenuOpen, onDismissRequest = { sortMenuOpen = false }, ) { sortOptions.forEach { option -> val selected = option.id == selectedSortId if (option.dividerAbove) HorizontalDivider() - DropdownMenuItem( + PrairieDropdownMenuItem( text = { Text( text = if (selected) option.selectedLabel else option.label, diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/TopBarActions.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/TopBarActions.kt index a5193e27e4..1720ea9440 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/TopBarActions.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/TopBarActions.kt @@ -95,7 +95,7 @@ fun TopBarIconButton( fun TopBarProfileMenu( activeProfile: Profile?, onRequestsClick: (() -> Unit)?, - onWatchTogetherClick: (() -> Unit)?, + onWatchPartyClick: (() -> Unit)?, onSettingsClick: () -> Unit, onSwitchProfileClick: () -> Unit, onSwitchServerClick: () -> Unit, @@ -130,11 +130,12 @@ fun TopBarProfileMenu( expanded = menuExpanded, onDismissRequest = { menuExpanded = false }, onRequestsClick = onRequestsClick, - onWatchTogetherClick = onWatchTogetherClick, + onWatchPartyClick = onWatchPartyClick, onSettingsClick = onSettingsClick, onSwitchProfileClick = onSwitchProfileClick, onSwitchServerClick = onSwitchServerClick, onSignOutClick = onSignOutClick, + activeProfile = activeProfile, ) } } @@ -148,7 +149,7 @@ fun TabTopBarActions( activeProfile: Profile?, onSearchClick: () -> Unit, onRequestsClick: (() -> Unit)?, - onWatchTogetherClick: (() -> Unit)?, + onWatchPartyClick: (() -> Unit)?, onSettingsClick: () -> Unit, onSwitchProfileClick: () -> Unit, onSwitchServerClick: () -> Unit, @@ -171,7 +172,7 @@ fun TabTopBarActions( TopBarProfileMenu( activeProfile = activeProfile, onRequestsClick = onRequestsClick, - onWatchTogetherClick = onWatchTogetherClick, + onWatchPartyClick = onWatchPartyClick, onSettingsClick = onSettingsClick, onSwitchProfileClick = onSwitchProfileClick, onSwitchServerClick = onSwitchServerClick, diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/aurora/AuroraChrome.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/aurora/AuroraChrome.kt index 5c09cb487c..72e89c006c 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/aurora/AuroraChrome.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/aurora/AuroraChrome.kt @@ -1,131 +1,54 @@ package org.prairieserver.prairie.android.ui.components.aurora -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon -import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import org.prairieserver.prairie.android.ui.components.siloKeyboardActions import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp /** - * The Aurora first-run chrome — palette, eyebrow, glass panel, cream primary - * button, ghost button, text field, segment, field/error labels — ported from - * prairie-apple's `AuroraStyle.swift` + `AuroraTextField.swift`, scaled for the - * phone (iOS points ≈ Android dp 1:1). A warm, cinematic skin (gold accent, - * plum-night) layered over the onboarding screens. + * The Aurora chrome — palette, glass panel, cream primary button and ghost + * button — used by the onboarding tour, ported from prairie-apple's Aurora + * styles and scaled for the phone (iOS points ≈ Android dp 1:1). The other + * first-run screens use the Marquee components instead. */ // MARK: palette val AuroraInk = Color(0xFFF3EFE9) val AuroraAccent = Color(0xFFF3D3A0) val AuroraInkSecondary = AuroraInk.copy(alpha = 0.62f) -val AuroraInkTertiary = AuroraInk.copy(alpha = 0.40f) private val AuroraGlassTint = Color(0xFF171019) private val AuroraCreamTop = Color(0xFFFDF7EC) private val AuroraCreamBottom = Color(0xFFF1E3CD) private val AuroraCreamInk = Color(0xFF20160A) -private val AuroraError = Color(0xFFEF5350) // MARK: shared control metrics (iOS AuroraControl) internal val AuroraControlHeight = 52.dp internal val AuroraControlCorner = 14.dp -private val AuroraActiveFill = Color(0xFFF4EEE2) -private val AuroraActiveInk = Color(0xFF1A1206) -private val AuroraActivePlaceholder = Color(0xFF4A4035) - -/** Gold-hairline + mono-caps step label, e.g. "STEP 01 — CONNECT". */ -@Composable -fun AuroraEyebrow(text: String, modifier: Modifier = Modifier, centered: Boolean = false) { - Row( - modifier = if (centered) modifier.fillMaxWidth() else modifier, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = if (centered) Arrangement.Center else Arrangement.Start, - ) { - Box( - modifier = Modifier - .width(46.dp) - .height(1.dp) - .background( - Brush.horizontalGradient(listOf(AuroraAccent, AuroraAccent.copy(alpha = 0f))), - ), - ) - Spacer(Modifier.width(16.dp)) - Text( - text = text.uppercase(), - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.SemiBold, - fontSize = 17.sp, - letterSpacing = 3.5.sp, - color = AuroraAccent, - ) - } -} - -/** Mono-caps field label (e.g. "SERVER ADDRESS"). */ -@Composable -fun AuroraFieldLabel(text: String, modifier: Modifier = Modifier) { - Text( - modifier = modifier, - text = text.uppercase(), - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.SemiBold, - fontSize = 12.sp, - letterSpacing = 1.6.sp, - color = AuroraInkTertiary, - ) -} - -/** Inline error row. */ -@Composable -fun AuroraErrorLabel(text: String, modifier: Modifier = Modifier) { - Text( - modifier = modifier.fillMaxWidth(), - text = text, - fontSize = 13.sp, - color = AuroraError, - ) -} /** * Liquid-glass panel chrome (translucent plum tint + gradient hairline + top @@ -236,117 +159,3 @@ fun AuroraGhostButton( } } } - -/** - * Editable Aurora field — controlled input with the Aurora chrome. The - * placeholder is a controlled overlay so we own its contrast on the plum - * background. When focused it flips to the warm cream fill + gold ring used - * across the flow. - */ -@Composable -fun AuroraTextField( - label: String, - value: String, - onValueChange: (String) -> Unit, - modifier: Modifier = Modifier, - placeholder: String = "", - keyboardType: KeyboardType = KeyboardType.Text, - imeAction: ImeAction = ImeAction.Next, - onImeAction: (() -> Unit)? = null, - visualTransformation: VisualTransformation = VisualTransformation.None, - trailing: (@Composable () -> Unit)? = null, -) { - val interactionSource = remember { MutableInteractionSource() } - val isFocused by interactionSource.collectIsFocusedAsState() - val shape = RoundedCornerShape(AuroraControlCorner) - - Column(modifier = modifier.fillMaxWidth()) { - AuroraFieldLabel(label) - Spacer(Modifier.height(8.dp)) - Box( - modifier = Modifier - .fillMaxWidth() - .shadow( - elevation = if (isFocused) 16.dp else 0.dp, - shape = shape, - clip = false, - ambientColor = AuroraAccent, - spotColor = AuroraAccent, - ) - .clip(shape) - .background(if (isFocused) AuroraActiveFill else Color.White.copy(alpha = 0.06f)) - .border( - width = if (isFocused) 2.dp else 1.5.dp, - color = if (isFocused) AuroraAccent else Color.White.copy(alpha = 0.16f), - shape = shape, - ) - .heightIn(min = AuroraControlHeight) - .padding(horizontal = 16.dp), - contentAlignment = Alignment.CenterStart, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { - if (value.isEmpty()) { - Text( - text = placeholder, - fontSize = 17.sp, - color = if (isFocused) AuroraActivePlaceholder else AuroraInkTertiary, - ) - } - BasicTextField( - value = value, - onValueChange = onValueChange, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - textStyle = LocalTextStyle.current.merge( - TextStyle( - color = if (isFocused) AuroraActiveInk else AuroraInk, - fontSize = 17.sp, - ), - ), - cursorBrush = SolidColor(if (isFocused) AuroraActiveInk else AuroraAccent), - visualTransformation = visualTransformation, - keyboardOptions = KeyboardOptions(keyboardType = keyboardType, imeAction = imeAction), - keyboardActions = siloKeyboardActions(onImeAction), - interactionSource = interactionSource, - ) - } - if (trailing != null) { - Spacer(Modifier.width(10.dp)) - trailing() - } - } - } - } -} - -/** Equal-width, single-line segmented option chip (Aurora). */ -@Composable -fun AuroraSegment( - title: String, - isSelected: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val shape = RoundedCornerShape(AuroraControlCorner) - val fill = if (isSelected) AuroraAccent.copy(alpha = 0.18f) else Color.White.copy(alpha = 0.06f) - val stroke = if (isSelected) AuroraAccent.copy(alpha = 0.5f) else Color.White.copy(alpha = 0.14f) - val textColor = if (isSelected) AuroraInk else AuroraInkSecondary - Box( - modifier = modifier - .height(AuroraControlHeight) - .clip(shape) - .background(fill) - .border(1.5.dp, stroke, shape) - .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick), - contentAlignment = Alignment.Center, - ) { - Text( - text = title, - color = textColor, - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - maxLines = 1, - ) - } -} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/marquee/MarqueeControls.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/marquee/MarqueeControls.kt new file mode 100644 index 0000000000..e4060bd76d --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/marquee/MarqueeControls.kt @@ -0,0 +1,332 @@ +package org.prairieserver.prairie.android.ui.components.marquee + +import android.view.HapticFeedbackConstants +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.composed +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.svg.SvgDecoder +import org.prairieserver.prairie.common.ui.marquee.MarqueeColors +import org.prairieserver.prairie.model.auth.SignInProvider + +/** First-run sizes on the phone, matching the Apple apps' points. */ +object MarqueeMetrics { + val ButtonHeight = 52.dp + val ButtonFont = 17.sp + val ButtonPadding = 24.dp + val FieldHeight = 52.dp + val FieldFont = 17.sp + val FieldCorner = 14.dp + val HeroFont = 34.sp + val LeadFont = 16.sp +} + +/** + * The detail page's grammar: one white pill for the primary action, glass + * pills for alternatives, plain text for the way out. + */ +enum class MarqueeButtonKind { Primary, Glass, Plain } + +/** A light tap the moment a first-run control is pressed, so it feels immediate. */ +fun Modifier.marqueePressHaptic( + interactionSource: MutableInteractionSource, + feedback: Int = HapticFeedbackConstants.KEYBOARD_TAP, +): Modifier = composed { + val view = LocalView.current + val pressed by interactionSource.collectIsPressedAsState() + LaunchedEffect(pressed) { if (pressed) view.performHapticFeedback(feedback) } + this +} + +@Composable +fun MarqueeButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + kind: MarqueeButtonKind = MarqueeButtonKind.Primary, + fullWidth: Boolean = true, + isLoading: Boolean = false, + enabled: Boolean = true, + /** Smaller utility actions (change server, sign out). */ + compact: Boolean = false, + icon: ImageVector? = null, +) { + MarqueeButton(onClick, modifier, kind, fullWidth, isLoading, enabled, compact) { ink -> + if (icon != null) Icon(icon, contentDescription = null, tint = ink, modifier = Modifier.size(if (compact) 16.dp else 18.dp)) + Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + +@Composable +fun MarqueeButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + kind: MarqueeButtonKind = MarqueeButtonKind.Primary, + fullWidth: Boolean = true, + isLoading: Boolean = false, + enabled: Boolean = true, + compact: Boolean = false, + label: @Composable RowScope.(ink: Color) -> Unit, +) { + val interaction = remember { MutableInteractionSource() } + val pressed by interaction.collectIsPressedAsState() + val ink = when (kind) { + MarqueeButtonKind.Primary -> Color.Black + MarqueeButtonKind.Glass -> MarqueeColors.Ink + MarqueeButtonKind.Plain -> MarqueeColors.InkSecondary + } + val height = when { + compact -> MarqueeMetrics.ButtonHeight * 0.75f + kind == MarqueeButtonKind.Plain -> MarqueeMetrics.ButtonHeight - 8.dp + else -> MarqueeMetrics.ButtonHeight + } + val fontSize = if (compact) MarqueeMetrics.ButtonFont * 0.85f else MarqueeMetrics.ButtonFont + Row( + modifier = modifier + .then(if (fullWidth) Modifier.fillMaxWidth() else Modifier) + .height(height) + .clip(CircleShape) + .then( + when (kind) { + MarqueeButtonKind.Primary -> Modifier.background(MarqueeColors.Ink) + MarqueeButtonKind.Glass -> Modifier + .background(MarqueeColors.GlassFill) + .border(1.dp, MarqueeColors.Hairline, CircleShape) + MarqueeButtonKind.Plain -> Modifier + }, + ) + .clickable( + enabled = enabled, + role = Role.Button, + interactionSource = interaction, + indication = null, + onClick = onClick, + ) + .marqueePressHaptic( + interaction, + if (kind == MarqueeButtonKind.Primary) HapticFeedbackConstants.VIRTUAL_KEY else HapticFeedbackConstants.KEYBOARD_TAP, + ) + .alpha(if (!enabled) 0.45f else if (pressed) 0.7f else 1f) + .padding(horizontal = if (compact) MarqueeMetrics.ButtonPadding * 0.7f else MarqueeMetrics.ButtonPadding), + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isLoading) { + CircularProgressIndicator(color = ink, strokeWidth = 2.dp, modifier = Modifier.size(18.dp)) + } + androidx.compose.runtime.CompositionLocalProvider( + androidx.compose.material3.LocalContentColor provides ink, + androidx.compose.material3.LocalTextStyle provides androidx.compose.ui.text.TextStyle( + color = ink, + fontSize = fontSize, + fontWeight = if (kind == MarqueeButtonKind.Plain) FontWeight.Medium else FontWeight.SemiBold, + ), + ) { + label(ink) + } + } +} + +/** Round glass icon button (back, close), as on the detail pages. */ +@Composable +fun MarqueeIconButton( + icon: ImageVector, + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + val interaction = remember { MutableInteractionSource() } + val pressed by interaction.collectIsPressedAsState() + Box( + modifier = modifier + .size(44.dp) + .clip(CircleShape) + .background(MarqueeColors.GlassFill) + .border(1.dp, MarqueeColors.Hairline, CircleShape) + .clickable(enabled = enabled, role = Role.Button, interactionSource = interaction, indication = null, onClick = onClick) + .marqueePressHaptic(interaction) + .alpha(if (!enabled) 0.45f else if (pressed) 0.7f else 1f) + .semantics { this.contentDescription = contentDescription }, + contentAlignment = Alignment.Center, + ) { + Icon(icon, contentDescription = null, tint = MarqueeColors.Ink, modifier = Modifier.size(20.dp)) + } +} + +/** Big title with an optional lead line. */ +@Composable +fun MarqueeHeadline( + title: String, + modifier: Modifier = Modifier, + lead: String? = null, + centered: Boolean = false, +) { + val align = if (centered) TextAlign.Center else TextAlign.Start + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = if (centered) Alignment.CenterHorizontally else Alignment.Start, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = title, + modifier = Modifier.semantics { heading() }, + color = MarqueeColors.Ink, + fontSize = MarqueeMetrics.HeroFont, + fontWeight = FontWeight.ExtraBold, + letterSpacing = (-0.85).sp, + lineHeight = 38.sp, + textAlign = align, + ) + if (lead != null) { + Text( + text = lead, + color = MarqueeColors.InkSecondary, + fontSize = MarqueeMetrics.LeadFont, + lineHeight = 22.sp, + textAlign = align, + ) + } + } +} + +/** Inline error under a control. Errors never become banners. */ +@Composable +fun MarqueeErrorText(text: String, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .fillMaxWidth() + .clearAndSetSemantics { contentDescription = "Error: $text" }, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + Icons.Outlined.ErrorOutline, + contentDescription = null, + tint = MarqueeColors.Error, + modifier = Modifier.size(16.dp).offset(y = 1.dp), + ) + Text(text, color = MarqueeColors.Error, fontSize = 14.sp, lineHeight = 19.sp) + } +} + +/** A thin line with a word in the middle ("or"). */ +@Composable +fun MarqueeLabeledDivider(text: String, modifier: Modifier = Modifier) { + Row(modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.weight(1f).height(1.dp).background(MarqueeColors.Separator)) + Text(text, Modifier.padding(horizontal = 12.dp), color = MarqueeColors.InkTertiary, fontSize = 13.sp) + Box(Modifier.weight(1f).height(1.dp).background(MarqueeColors.Separator)) + } +} + +/** + * A provider's icon, or its initial on a warm gradient when it has none or + * the icon can't be drawn. + */ +@Composable +fun MarqueeProviderMark(provider: SignInProvider, size: Dp = 22.dp) { + val iconUrl = provider.iconUrl + var failed by remember(iconUrl) { mutableStateOf(iconUrl == null) } + val shape = RoundedCornerShape(size * 0.27f) + Box(Modifier.size(size).clip(shape), contentAlignment = Alignment.Center) { + if (failed) { + Box( + Modifier + .size(size) + .background(Brush.linearGradient(listOf(Color(0xFFFD4B2D), Color(0xFFA21D5C)))), + contentAlignment = Alignment.Center, + ) { + Text( + text = provider.displayName.firstOrNull()?.lowercase() ?: "s", + color = Color.White, + fontSize = (size.value * 0.55f).sp, + fontWeight = FontWeight.ExtraBold, + ) + } + } else { + val context = LocalContext.current + AsyncImage( + model = remember(iconUrl) { + ImageRequest.Builder(context) + .data(iconUrl) + .apply { + // The app's image loader has no SVG decoder; plugin + // icons (the OIDC plugin's sso.svg) often are SVG. + if (iconUrl.orEmpty().substringBefore('?').endsWith(".svg", ignoreCase = true)) { + decoderFactory(SvgDecoder.Factory()) + } + } + .build() + }, + contentDescription = null, + onError = { failed = true }, + modifier = Modifier.size(size), + ) + } + } +} + +/** + * An error buzz each time [trigger] changes to a failure (non-null, non-zero). + * The value it starts with doesn't buzz, so returning to a screen doesn't + * replay an old failure. + */ +@Composable +fun MarqueeErrorHaptic(trigger: Any?) { + val view = LocalView.current + val initial = remember { trigger } + LaunchedEffect(trigger) { + if (trigger != initial && trigger != null && trigger != 0) { + view.performHapticFeedback( + if (android.os.Build.VERSION.SDK_INT >= 30) HapticFeedbackConstants.REJECT else HapticFeedbackConstants.LONG_PRESS, + ) + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/marquee/MarqueeFields.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/marquee/MarqueeFields.kt new file mode 100644 index 0000000000..5edf31db8c --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/marquee/MarqueeFields.kt @@ -0,0 +1,191 @@ +package org.prairieserver.prairie.android.ui.components.marquee + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material.icons.outlined.VisibilityOff +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +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.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import org.prairieserver.prairie.android.ui.components.siloKeyboardActions +import org.prairieserver.prairie.common.ui.marquee.MarqueeColors + +private val LocalMarqueeFieldGrouped = compositionLocalOf { false } + +/** + * Frosted text field with a leading icon. Inside [MarqueeFieldGroup] it drops + * its own background so the group draws one rounded block with separators. + */ +@Composable +fun MarqueeTextField( + value: String, + onValueChange: (String) -> Unit, + icon: ImageVector, + placeholder: String, + modifier: Modifier = Modifier, + keyboardType: KeyboardType = KeyboardType.Text, + imeAction: ImeAction = ImeAction.Next, + onImeAction: (() -> Unit)? = null, + isSecure: Boolean = false, + showsClearButton: Boolean = false, + isError: Boolean = false, + enabled: Boolean = true, + focusRequester: FocusRequester? = null, +) { + val grouped = LocalMarqueeFieldGrouped.current + val interaction = remember { MutableInteractionSource() } + val focused by interaction.collectIsFocusedAsState() + var reveal by remember { mutableStateOf(false) } + val shape = RoundedCornerShape(MarqueeMetrics.FieldCorner) + val textStyle = TextStyle(color = MarqueeColors.Ink, fontSize = MarqueeMetrics.FieldFont) + + Row( + modifier = modifier + .fillMaxWidth() + .height(MarqueeMetrics.FieldHeight) + .then( + if (grouped) { + if (isError) Modifier.background(MarqueeColors.Error.copy(alpha = 0.08f)) else Modifier + } else { + Modifier + .clip(shape) + .background( + when { + isError -> MarqueeColors.Error.copy(alpha = 0.08f) + focused -> Color.White.copy(alpha = 0.10f) + else -> Color.White.copy(alpha = 0.07f) + }, + ) + .border( + 1.dp, + when { + isError -> MarqueeColors.Error.copy(alpha = 0.65f) + focused -> Color.White.copy(alpha = 0.45f) + else -> Color.White.copy(alpha = 0.12f) + }, + shape, + ) + }, + ) + .alpha(if (enabled) 1f else 0.5f) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon(icon, contentDescription = null, tint = MarqueeColors.InkTertiary, modifier = Modifier.width(20.dp).size(20.dp)) + Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { + if (value.isEmpty()) { + Text(placeholder, style = textStyle.copy(color = MarqueeColors.InkTertiary), maxLines = 1) + } + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier + .fillMaxWidth() + .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) + .semantics { contentDescription = placeholder }, + enabled = enabled, + singleLine = true, + textStyle = textStyle, + cursorBrush = SolidColor(Color(0xFF0A84FF)), + visualTransformation = if (isSecure && !reveal) PasswordVisualTransformation() else VisualTransformation.None, + keyboardOptions = KeyboardOptions( + keyboardType = if (isSecure) KeyboardType.Password else keyboardType, + imeAction = imeAction, + autoCorrectEnabled = false, + ), + keyboardActions = siloKeyboardActions(onImeAction), + interactionSource = interaction, + ) + } + if (isSecure) { + Icon( + imageVector = if (reveal) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + contentDescription = if (reveal) "Hide password" else "Show password", + tint = MarqueeColors.InkTertiary, + modifier = Modifier + .clip(RoundedCornerShape(50)) + .clickable { reveal = !reveal } + .padding(4.dp) + .size(20.dp), + ) + } else if (showsClearButton && value.isNotEmpty() && enabled) { + Icon( + imageVector = Icons.Filled.Cancel, + contentDescription = "Clear", + tint = Color.White.copy(alpha = 0.3f), + modifier = Modifier + .clip(RoundedCornerShape(50)) + .clickable { onValueChange("") } + .padding(4.dp) + .size(18.dp), + ) + } + } +} + +/** Username and password as one block, like grouped fields on iOS. */ +@Composable +fun MarqueeFieldGroup( + modifier: Modifier = Modifier, + isError: Boolean = false, + content: @Composable ColumnScope.() -> Unit, +) { + val shape = RoundedCornerShape(MarqueeMetrics.FieldCorner) + Column( + modifier = modifier + .fillMaxWidth() + .clip(shape) + .background(Color.White.copy(alpha = 0.07f)) + .border(1.dp, if (isError) MarqueeColors.Error.copy(alpha = 0.65f) else Color.White.copy(alpha = 0.12f), shape), + ) { + CompositionLocalProvider(LocalMarqueeFieldGrouped provides true) { content() } + } +} + +/** The hairline between rows of a [MarqueeFieldGroup] or another grouped list. */ +@Composable +fun MarqueeSeparator(modifier: Modifier = Modifier) { + Box(modifier.fillMaxWidth().height(1.dp).background(MarqueeColors.Separator)) +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/marquee/MarqueeServerChip.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/marquee/MarqueeServerChip.kt new file mode 100644 index 0000000000..42fb0d90b8 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/marquee/MarqueeServerChip.kt @@ -0,0 +1,107 @@ +package org.prairieserver.prairie.android.ui.components.marquee + +import org.prairieserver.prairie.android.ui.components.PrairieDropdownMenuItem +import org.prairieserver.prairie.android.ui.components.PrairieDropdownMenu +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.outlined.Dns +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +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.unit.sp +import org.prairieserver.prairie.common.ui.marquee.MarqueeColors +import org.prairieserver.prairie.common.ui.marquee.MarqueeServerMark + +/** + * Compact server capsule for the top bar. Tapping it opens a menu headed by + * the server's address with one action, [actionLabel]. + */ +@Composable +fun MarqueeServerChip( + name: String, + hostLabel: String, + markUrl: String?, + actionLabel: String, + onAction: () -> Unit, + enabled: Boolean = true, +) { + var expanded by remember { mutableStateOf(false) } + val interaction = remember { MutableInteractionSource() } + Box { + Row( + modifier = Modifier + .height(36.dp) + .widthIn(max = 220.dp) + .clip(CircleShape) + .background(MarqueeColors.GlassFill) + .border(1.dp, MarqueeColors.Hairline, CircleShape) + .clickable(enabled = enabled, role = Role.Button, interactionSource = interaction, indication = null) { + expanded = true + } + .marqueePressHaptic(interaction) + .alpha(if (enabled) 1f else 0.45f) + .semantics { contentDescription = "$name, server. Double-tap to change." } + .padding(start = 6.dp, end = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MarqueeServerMark(name = name, imageUrl = markUrl, size = 24.dp) + Text( + name, + color = MarqueeColors.Ink, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null, tint = MarqueeColors.InkTertiary, modifier = Modifier.size(16.dp)) + } + PrairieDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + Text( + hostLabel, + color = MarqueeColors.InkTertiary, + fontSize = 13.sp, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + PrairieDropdownMenuItem( + text = { Text(actionLabel, color = MarqueeColors.Ink) }, + leadingIcon = { Icon(Icons.Outlined.Dns, contentDescription = null, tint = MarqueeColors.Ink) }, + onClick = { + expanded = false + onAction() + }, + ) + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/marquee/MarqueeStage.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/marquee/MarqueeStage.kt new file mode 100644 index 0000000000..6c93a7bcd2 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/marquee/MarqueeStage.kt @@ -0,0 +1,116 @@ +package org.prairieserver.prairie.android.ui.components.marquee + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.union +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.prairieserver.prairie.android.R +import org.prairieserver.prairie.common.ui.marquee.MarqueeScrim +import org.prairieserver.prairie.common.ui.marquee.MarqueeScrimStyle + +/** + * A first-run screen on the phone: transparent over the shared backdrop, a + * scrim for legibility, a top bar, and content anchored to the bottom like a + * streaming app's welcome screen. The column scrolls, so large text and the + * keyboard push the title up instead of clipping the controls. + * + * @param onBack what system Back does; null leaves Back to the navigation + * host. Pass null while a sign-in is in flight so leaving can't race it. + */ +@Composable +fun MarqueeStage( + modifier: Modifier = Modifier, + scrim: MarqueeScrimStyle = MarqueeScrimStyle.Bottom, + frostStart: Float? = 0.42f, + maxWidth: Dp = 440.dp, + onBack: (() -> Unit)? = null, + blockBack: Boolean = false, + topBar: @Composable RowScope.() -> Unit = {}, + content: @Composable ColumnScope.() -> Unit, +) { + BackHandler(enabled = onBack != null || blockBack) { if (!blockBack) onBack?.invoke() } + Box(modifier.fillMaxSize()) { + MarqueeScrim(scrim, frostStart = frostStart) + Column( + Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.statusBars), + ) { + Row( + Modifier + .fillMaxWidth() + .heightIn(min = 44.dp) + .padding(start = 24.dp, end = 24.dp, top = 6.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + content = topBar, + ) + BoxWithConstraints( + Modifier + .weight(1f) + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.navigationBars.union(WindowInsets.ime)), + ) { + val viewport = maxHeight + Column( + Modifier + .fillMaxSize() + // Keep the primary action in view when the room changes. + .verticalScroll(rememberScrollState(), reverseScrolling = true), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column( + Modifier + .heightIn(min = viewport) + .widthIn(max = maxWidth) + .fillMaxWidth() + .padding(start = 24.dp, end = 24.dp, bottom = 20.dp), + verticalArrangement = Arrangement.Bottom, + content = content, + ) + } + } + } + } +} + +/** The Prairie wordmark for a first-run top bar. */ +@Composable +fun MarqueeWordmark(width: Dp = 92.dp) { + Image( + painter = painterResource(R.drawable.prairie_wordmark), + contentDescription = "Prairie", + contentScale = ContentScale.Fit, + modifier = Modifier.width(width), + ) +} + +/** Pushes the trailing top-bar item to the end. */ +@Composable +fun RowScope.MarqueeTopBarSpacer() = Spacer(Modifier.weight(1f)) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/layout/ResponsiveControlLayout.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/layout/ResponsiveControlLayout.kt index bfa760098c..397c74aba2 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/layout/ResponsiveControlLayout.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/layout/ResponsiveControlLayout.kt @@ -9,9 +9,6 @@ private const val PreferredTransportSpacingDp = 28f private const val MinimumTransportSpacingDp = 8f private const val ChapterTransportControlWidthDp = 278f private const val CompactTransportControlWidthDp = 182f -private const val MinimumToolbarTitleWidthDp = 96f -private const val ToolbarButtonWidthDp = 48f -private const val ToolbarSpacingDp = 12f internal fun resolveAudiobookTransportLayout( availableWidthDp: Float, @@ -31,16 +28,3 @@ internal fun resolveAudiobookTransportLayout( requiresHorizontalScroll = availableWidthDp < controlWidth + MinimumTransportSpacingDp * gapCount, ) } - -internal fun useCompactPlayerToolbar( - availableWidthDp: Float, - trailingActionCount: Int, -): Boolean { - val buttonCount = trailingActionCount + 1 - val gapCount = trailingActionCount + 1 - val expandedWidth = buttonCount * ToolbarButtonWidthDp + - gapCount * ToolbarSpacingDp + - MinimumToolbarTitleWidthDp - - return expandedWidth > availableWidthDp -} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/AppNavigation.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/AppNavigation.kt index 3677cc3f3c..0b7cdc0805 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/AppNavigation.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/AppNavigation.kt @@ -1,6 +1,7 @@ package org.prairieserver.prairie.android.ui.navigation import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.ExitTransition import androidx.compose.animation.SharedTransitionLayout @@ -14,13 +15,32 @@ import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.rememberCoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import org.prairieserver.prairie.android.auth.ActiveProfileReset +import org.prairieserver.prairie.android.profiles.ProfileLaunchAction +import org.prairieserver.prairie.android.profiles.profileLaunchAction +import org.prairieserver.prairie.common.downloads.DownloadStorage +import org.prairieserver.prairie.common.network.ServerReachabilityMonitor +import org.prairieserver.prairie.common.network.ServerReachabilityStatus +import org.prairieserver.prairie.common.profiles.ProfileAwayTracker +import org.prairieserver.prairie.common.profiles.ProfileLaunchPreferences import org.prairieserver.prairie.model.profile.ActiveProfileStore import org.prairieserver.prairie.android.ui.components.rememberVideoSeekIntervals import org.prairieserver.prairie.android.cast.GOOGLE_CAST_LEGACY_SEEK_INTERVALS import org.prairieserver.prairie.android.ui.screens.auth.DevicePairingWrongServerScreen import org.prairieserver.prairie.android.ui.screens.auth.DevicePairingUnknownServerScreen import androidx.compose.runtime.collectAsState +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import org.prairieserver.prairie.android.ui.components.LoadingIndicator import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect @@ -34,7 +54,10 @@ import androidx.compose.runtime.key import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModelStoreOwner +import androidx.navigation.NavBackStackEntry import androidx.navigation.NavHostController +import org.prairieserver.prairie.common.ui.marquee.MarqueeBackdrop import androidx.lifecycle.DEFAULT_ARGS_KEY import androidx.lifecycle.viewmodel.MutableCreationExtras import androidx.core.os.bundleOf @@ -49,6 +72,7 @@ import kotlinx.coroutines.flow.map import org.prairieserver.prairie.android.cast.GoogleCastMiniBar import org.prairieserver.prairie.android.cast.PrairieCastController import org.prairieserver.prairie.android.cast.PrairieCastSessionManager +import org.prairieserver.prairie.android.ui.screens.cast.PrairieCastLaunchHaptics import org.prairieserver.prairie.android.ui.screens.cast.PrairieCastMiniBar import org.prairieserver.prairie.android.ui.screens.cast.PrairieCastRemoteScreen import org.prairieserver.prairie.android.ui.screens.MainScreen @@ -67,8 +91,13 @@ import org.prairieserver.prairie.android.ui.screens.collections.CollectionsScree import org.prairieserver.prairie.android.ui.screens.collections.LibraryCollectionsScreen import org.prairieserver.prairie.android.ui.screens.detail.ItemDetailScreen import org.prairieserver.prairie.android.ui.screens.detail.ItemDetailViewModel -import org.prairieserver.prairie.android.ui.screens.watchtogether.WatchTogetherEntrySheet -import org.prairieserver.prairie.android.ui.screens.watchtogether.WatchTogetherLobbyScreen +import org.prairieserver.prairie.android.ui.screens.watchparty.WatchPartyHandoff +import org.prairieserver.prairie.android.ui.screens.watchparty.WatchPartyHubScreen +import org.prairieserver.prairie.android.ui.screens.watchparty.WatchPartyLobbyScreen +import org.prairieserver.prairie.android.ui.screens.watchparty.WatchPartySoloGuardDialog +import org.prairieserver.prairie.android.ui.screens.watchparty.rememberWatchPartySoloGuard +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.repository.WatchTogetherRepository import org.prairieserver.prairie.android.ui.screens.people.PersonDetailScreen import org.prairieserver.prairie.android.ui.screens.people.PersonDetailViewModel import org.prairieserver.prairie.android.ui.screens.notifications.InboxScreen @@ -85,6 +114,7 @@ import org.prairieserver.prairie.android.ui.screens.profiles.ProfileSelectionScr import org.prairieserver.prairie.android.ui.screens.livetv.LiveTvPlayerScreen import org.prairieserver.prairie.android.ui.screens.livetv.LiveTvScreen import org.prairieserver.prairie.android.ui.screens.requests.MyRequestsScreen +import org.prairieserver.prairie.android.ui.screens.requests.RequestApprovalsScreen import org.prairieserver.prairie.android.ui.screens.requests.RequestDetailScreen import org.prairieserver.prairie.android.ui.screens.requests.RequestsScreen import org.prairieserver.prairie.android.ui.screens.search.MobileSearchMediaType @@ -92,7 +122,13 @@ import org.prairieserver.prairie.android.ui.screens.search.SearchScreen import org.prairieserver.prairie.android.ui.screens.search.SearchViewModel import org.prairieserver.prairie.android.ui.screens.servers.ServerListScreen import org.prairieserver.prairie.android.ui.screens.servers.ServerSwitchDestination +import org.prairieserver.prairie.android.ui.screens.settings.SettingsDownloadsScreen +import org.prairieserver.prairie.android.ui.screens.settings.SettingsInterfaceScreen +import org.prairieserver.prairie.android.ui.screens.settings.SettingsNotificationsScreen +import org.prairieserver.prairie.android.ui.screens.settings.SettingsProfileSelectionScreen +import org.prairieserver.prairie.android.ui.screens.settings.SettingsPlaybackScreen import org.prairieserver.prairie.android.ui.screens.settings.SettingsScreen +import org.prairieserver.prairie.android.ui.screens.settings.SettingsSubtitlesScreen import org.prairieserver.prairie.android.ui.screens.settings.diagnostics.DiagnosticsPromptDialog import org.prairieserver.prairie.common.diagnostics.DiagnosticsLifecycleLogger import org.prairieserver.prairie.android.ui.screens.settings.diagnostics.DiagnosticsReportScreen @@ -100,11 +136,16 @@ import org.prairieserver.prairie.android.ui.screens.settings.diagnostics.Diagnos import org.prairieserver.prairie.android.ui.screens.settings.diagnostics.DiagnosticsViewModel import org.prairieserver.prairie.cast.PrairieCastPlaybackRequest import org.prairieserver.prairie.common.cards.ProvideCardPresentation +import org.prairieserver.prairie.common.cards.ProvideEpisodeSpoilerPrefs +import org.prairieserver.prairie.common.settings.ProvideTitleArt +import org.prairieserver.prairie.common.settings.TitleArtStore import org.prairieserver.prairie.common.overlays.ProvideCardOverlays import org.prairieserver.prairie.common.player.video.VideoPlayerRouteArgs import org.prairieserver.prairie.common.settings.CardPresentationStore import org.prairieserver.prairie.common.settings.OverlayPrefsStore import org.prairieserver.prairie.network.TokenManager +import org.prairieserver.prairie.repository.ProfilePromptAction +import org.prairieserver.prairie.repository.ProfileVerificationRecovery import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel @@ -114,9 +155,42 @@ import org.koin.compose.viewmodel.koinViewModel */ private const val DisplayedDetailContentIdKey = "displayedDetailContentId" +/** The profile picker and its screens, where a prompt has nothing to add. */ +private val ProfilePromptPickerRoutes: Set = setOf( + Route.ProfileSelection.route, + Route.CreateProfile.route, + Route.EditProfile.ROUTE, +) + +/** + * Destinations a profile-verification prompt waits behind: playback and reading + * keep going on the session they started with, and the sign-in and server + * screens have no profile in use. + */ +private val ProfilePromptDeferredRoutes: Set = setOf( + Route.Player.ROUTE, + Route.AudiobookPlayer.ROUTE, + Route.BookReader.ROUTE, + Route.PrairieCastRemote.route, + Route.Login.route, + Route.ServerSetup.route, + Route.ServerSetupPrefilled.ROUTE, + Route.ServerList.route, + Route.Setup.route, + Route.Signup.route, + Route.PairDevice.ROUTE, + Route.InviteClaim.ROUTE, +) + /** Page-to-page cross-fade duration (ms). Snappier than Compose Nav's 700ms default. */ private const val PageFadeDurationMs = 200 +/** Longest the Profile Selection cover stays up while Who's Watching replaces the previous profile's screens. */ +private const val PROFILE_COVER_MAX_MS = 2_000L + +/** Screens that show no profile: sign-in, server setup, and the profile picker itself. */ +private val ProfileFreeRoutes: Set get() = MarqueeRoutes + ProfilePromptPickerRoutes + Route.ServerList.route + internal class PlayerTargetProviderRegistration( val backStackEntryId: String, val target: () -> MobilePlayerRouteTarget?, @@ -132,6 +206,17 @@ internal fun currentPlayerTargetOrNull( return registration.target() } +/** First-run routes, drawn transparent over the shared [MarqueeBackdrop]. */ +private val MarqueeRoutes = setOf( + Route.ServerSetup.route, + Route.ServerSetupPrefilled.ROUTE, + Route.Login.route, + Route.Setup.route, + Route.Signup.route, + Route.InviteClaim.ROUTE, + Route.ProfileSelection.route, +) + @OptIn(ExperimentalSharedTransitionApi::class) @Composable fun AppNavigation( @@ -152,22 +237,75 @@ fun AppNavigation( val overlayPrefsStore: OverlayPrefsStore = koinInject() val activeProfileStore: ActiveProfileStore = koinInject() val cardPresentationStore: CardPresentationStore = koinInject() + val episodeSpoilerStore: org.prairieserver.prairie.common.settings.EpisodeSpoilerStore = koinInject() val seekIntervalStore: org.prairieserver.prairie.common.settings.SeekIntervalStore = koinInject() + val titleArtStore: TitleArtStore = koinInject() + val signOutTeardown: org.prairieserver.prairie.android.auth.SignOutTeardown = koinInject() + val profileSwitchTeardown: org.prairieserver.prairie.android.auth.ProfileSwitchTeardown = koinInject() val siloCastController: PrairieCastController = koinInject() // Lives as long as the nav host, so work started from a destination that is // popped in the same gesture (re-hydrating after a profile switch) is not // cancelled with that destination's own scope. val navScope = rememberCoroutineScope() + val profileSwitching by profileSwitchTeardown.switching.collectAsState() + // Both "Switch Profile" entry points (profile menu, Settings) run here, so + // leaving a tab while settings are pushed cannot cancel the switch; the + // teardown ignores a repeat request while one is in flight. + val switchProfile: () -> Unit = remember(navController, profileSwitchTeardown) { + { + navScope.launch { + profileSwitchTeardown.switchProfile { + navController.navigate(Route.ProfileSelection.route) + } + } + } + } val diagnosticsViewModel = koinViewModel() val diagnosticsState by diagnosticsViewModel.state.collectAsState() var activePlayerTargetProvider by remember { mutableStateOf(null) } + val watchTogetherRepository: WatchTogetherRepository = koinInject() + val watchPartyHandoff: WatchPartyHandoff = koinInject() + // D5: an external link that starts solo playback asks to leave a party first. + val externalSoloGuard = rememberWatchPartySoloGuard() + + // The party player loads from the room itself; the route's content + // arguments are placeholders only. There is one party player at a time: + // when this room's player is already in the back stack, return to it; it + // follows the room's selection on its own. + fun openPartyPlayer(room: RoomSnapshot, replacing: String?) { + val topPlayer = runCatching { navController.getBackStackEntry(Route.Player.ROUTE) }.getOrNull() + if (topPlayer?.arguments?.getString("roomId") == room.roomId) { + navController.popBackStack(Route.Player.ROUTE, inclusive = false) + return + } + navController.navigate( + Route.Player( + contentId = room.selectedContentId?.takeIf { it.isNotBlank() } ?: PARTY_PLAYER_PLACEHOLDER_ID, + fileId = room.selectedFileId, + libraryId = room.selectedLibraryId, + roomId = room.roomId, + ).route, + ) { + if (replacing != null) popUpTo(replacing) { inclusive = true } + } + } + + // Back to the hub below, or replace [replacing] with a new hub. + fun openPartyHub(replacing: String) { + if (!navController.popBackStack(Route.WatchPartyHub.ROUTE, inclusive = false)) { + navController.navigate(Route.WatchPartyHub().route) { + popUpTo(replacing) { inclusive = true } + } + } + } DisposableEffect(siloCastController) { siloCastController.startBrowsing() onDispose { siloCastController.stopBrowsing() } } + PrairieCastLaunchHaptics(siloCastController) // Graceful handling of server-side session invalidation (refresh 401'd). // The TokenManager has already wiped the active server's tokens by the @@ -182,6 +320,232 @@ fun AppNavigation( } } + // The server stopped accepting the active profile's PIN proof (an admin + // changed the account's access). The recovery has already cleared the + // stale profile selection, but not the sign-in; send the user to the + // profile picker to choose a profile and enter its PIN again. Playback and + // the sign-in screens are not interrupted: the prompt waits until the user + // leaves them (or a remote-playback overlay ends), and is dropped after a + // sign-out, a server switch, or a profile pick. + val profileVerificationRecovery: ProfileVerificationRecovery = koinInject() + LaunchedEffect(Unit) { + // promptChecks re-emits when a remote-playback overlay ends, so a + // prompt deferred behind it does not wait for the next navigation. + kotlinx.coroutines.flow.combine( + profileVerificationRecovery.promptChecks, + navController.currentBackStackEntryFlow, + ) { prompt, entry -> prompt to entry.destination.route } + .collect { (prompt, route) -> + if (prompt == null) return@collect + when ( + profileVerificationRecovery.actionFor( + prompt = prompt, + currentRoute = route, + deferRoutes = ProfilePromptDeferredRoutes, + pickerRoutes = ProfilePromptPickerRoutes, + ) + ) { + ProfilePromptAction.Defer -> Unit + // Already on the picker: it reloads itself, and a PIN + // being typed is kept. + ProfilePromptAction.AlreadyThere, + ProfilePromptAction.Drop, + -> profileVerificationRecovery.consume(prompt) + ProfilePromptAction.Navigate -> { + profileVerificationRecovery.consume(prompt) + navController.navigate(Route.ProfileSelection.route) { + popUpTo(0) { inclusive = true } + launchSingleTop = true + } + // Same per-profile teardown as Switch Profile, after + // leaving the shell so it does not repaint first. + overlayPrefsStore.clear() + activeProfileStore.reset() + cardPresentationStore.clear() + seekIntervalStore.clear() + episodeSpoilerStore.clear() + titleArtStore.clear() + } + } + } + } + + // Android can restore Prairie's previous back stack over the start route + // MainActivity chose: after its process was killed in the background. + // When Profile Selection has already cleared the profile at launch, that + // brings back the previous profile's screens with no profile behind them, + // so go to Who's Watching instead. Look beneath the top too: Manage + // Servers over Home would otherwise let Back reach Home with no profile + // chosen, and every stack of profile screens has a tab at its root. + LaunchedEffect(navController) { + val restoredRoute = navController.currentBackStackEntryFlow.first().destination.route + val restoresProfileScreens = (restoredRoute != null && restoredRoute !in ProfileFreeRoutes) || + Tab.entries.any { tab -> runCatching { navController.getBackStackEntry(tab.route) }.isSuccess } + if (startDestination == Route.ProfileSelection.route && + restoresProfileScreens && + !tokenManager.getAccessToken().isNullOrBlank() && + tokenManager.getProfileId().isNullOrBlank() + ) { + navController.navigate(Route.ProfileSelection.route) { + popUpTo(0) { inclusive = true } + launchSingleTop = true + } + } + } + + // Profile Selection's return rule (prairie-apple `applyProfileReturnPolicy`): + // when Prairie comes back from the background and the setting asks, Who's + // Watching replaces whatever was on screen. Playback that kept going in + // the background never raises the request (see ProfileAwayTracker). + val profileAwayTracker: ProfileAwayTracker = koinInject() + val profileLaunchPreferences: ProfileLaunchPreferences = koinInject() + val activeProfileReset: ActiveProfileReset = koinInject() + val downloadStorage: DownloadStorage = koinInject() + val reachabilityMonitor: ServerReachabilityMonitor = koinInject() + LaunchedEffect(profileAwayTracker) { + kotlinx.coroutines.flow.combine( + profileAwayTracker.selectionRequired, + navController.currentBackStackEntryFlow, + ) { required, entry -> required to entry.destination.route }.collect { (required, route) -> + if (!required) return@collect + // Nothing to replace when signed out, or when no profile is in + // use and none of its screens are showing (already choosing a + // profile, or adding a server). + val profileId = tokenManager.getProfileId() + if (route == null || + tokenManager.getAccessToken().isNullOrBlank() || + (profileId.isNullOrBlank() && route in ProfileFreeRoutes) + ) { + profileAwayTracker.onSelectionHandled() + return@collect + } + val serverId = serverRegistry.activeEntry.value?.id + val offlineWithDownloads = withContext(Dispatchers.IO) { + hasLocalDownloadsForScope(downloadStorage, serverId, profileId) && + !reachabilityMonitor.retryNow().canUseServer + } + // Re-check after the wait, as prairie-apple's return policy does. + val action = profileLaunchAction( + requiresSelection = profileLaunchPreferences.requiresSelectionAfterBackground(), + startsOfflineOnDownloads = offlineWithDownloads, + ) + if (action != ProfileLaunchAction.AskWhoIsWatching) { + profileAwayTracker.onSelectionHandled() + return@collect + } + // Leave the profile's screens before clearing its state, as + // Switch Profile does. The reset pushes pending device-setting + // writes under this profile before it drops it. + navController.navigate(Route.ProfileSelection.route) { + popUpTo(0) { inclusive = true } + launchSingleTop = true + } + activeProfileReset.clearActiveProfile() + // MainActivity covers the screen while the request is up; lift + // it once the previous profile's screens have faded out. + withTimeoutOrNull(PROFILE_COVER_MAX_MS) { + navController.visibleEntries.first { entries -> + entries.all { it.destination.route in ProfileFreeRoutes } + } + } + profileAwayTracker.onSelectionHandled() + } + } + + // The offline exception above keeps a profile for its downloads while the + // server cannot be reached, with the away interval still recorded. Ask + // once the server answers again, without waiting for the next return. + // Playback and reading keep going until the user leaves them, as the + // profile-verification prompt waits. + LaunchedEffect(reachabilityMonitor) { + kotlinx.coroutines.flow.combine( + reachabilityMonitor.state.map { it.status == ServerReachabilityStatus.Reachable }.distinctUntilChanged(), + navController.currentBackStackEntryFlow, + ) { reachable, entry -> reachable to entry.destination.route }.collect { (reachable, route) -> + if (!reachable || route == null || route in ProfilePromptDeferredRoutes || route in ProfileFreeRoutes) { + return@collect + } + // In use with a profile, a recorded interval that requires + // selection is only left behind by that exception: every other + // path ends the interval or asks. + if (profileAwayTracker.selectionRequired.value || + !profileLaunchPreferences.requiresSelectionAfterBackground() || + tokenManager.getProfileId().isNullOrBlank() + ) { + return@collect + } + profileAwayTracker.requestSelection() + } + } + + fun navigateExternalRoute(route: String) { + // An external link to a TAB (prairie://downloads) must switch tabs, + // not push a second copy of that tab. A duplicate tab entry also + // makes the tab anchor ambiguous: popUpTo(route) resolves to the + // NEWEST match, so the older anchor entry would survive and Back + // could loop through a hidden tab. + if (tabForRoute(route) != null) { + // Tear the player down BEFORE the tab switch, and without + // saving it. tabSwitchNavOptions saves state so a tab keeps + // its stack, which is right for a tab — but a saved player + // entry keeps its ViewModelStore alive, so onCleared never + // runs and the playback session it owns is never stopped. + // The save is also keyed to the LOWEST popped destination, + // so a later clearBackStack on the player route would not + // even find it. Popping first means the player's teardown + // runs the ordinary way. + // ONLY when the player is the current destination. An + // inclusive pop also removes everything above its target, + // so a player sitting BELOW other entries — an external + // item link pushed over it, say — would take those with it + // and silently discard state the viewer expected back. + // Leaving that rarer case saved is the pre-existing + // behaviour; destroying history to fix it is worse. + if (shouldPopPlayerBeforeExternalTab( + navController.currentBackStackEntry?.destination?.route, + ) + ) { + navController.popBackStack( + route = Route.Player.ROUTE, + inclusive = true, + saveState = false, + ) + } + navController.navigate(route) { + tabSwitchNavOptions(navController.bottomMostTabRoute()) + } + } else { + val replaceCurrentPlayer = shouldReplaceCurrentPlayer( + currentDestinationRoute = navController.currentBackStackEntry + ?.destination?.route, + targetRoute = route, + ) + // Single-top only when the arguments agree it really is the + // same screen. AndroidX matches the destination NODE, so an + // external link to item B while item A's detail is showing + // reused A's entry and Back skipped A entirely — the same + // defect this branch fixes for in-app navigation. + val useSingleTop = navController.currentBackStackEntry?.arguments?.getString("libraryId") == null && + shouldLaunchExternalRouteSingleTop( + currentDestinationRoute = navController.currentBackStackEntry + ?.destination?.route, + currentContentId = navController.currentBackStackEntry + ?.savedStateHandle?.get(DisplayedDetailContentIdKey) + ?: navController.currentBackStackEntry?.arguments?.getString("contentId"), + targetRoute = route, + ) + navController.navigate(route) { + if (replaceCurrentPlayer) { + popUpTo(Route.Player.ROUTE) { inclusive = true } + } + // Decided from the finite external-route producer set, + // not punctuation. A route's spelling does not say + // whether its arguments identify a distinct request. + launchSingleTop = useSingleTop + } + } + } + // Keyed on request identity, not route text, so delivering the same deep // link again after Back still restarts the wait. The back-stack flow emits // the current entry immediately, covering both "request arrives on Main" @@ -189,8 +553,17 @@ fun AppNavigation( LaunchedEffect(pendingExternalRoute?.generation) { consumeExternalRouteOnce( pendingExternalRoute = pendingExternalRoute, - currentDestinationRoutes = navController.currentBackStackEntryFlow - .map { entry -> entry.destination.route }, + // While Profile Selection is replacing the previous profile's + // screens, those screens are not a place to deliver a link: it + // would open (or start playing) under the profile being cleared, + // then be wiped. Wait until the request is handled; Who's + // Watching then holds the link as it does at launch. + currentDestinationRoutes = kotlinx.coroutines.flow.combine( + navController.currentBackStackEntryFlow, + profileAwayTracker.selectionRequired, + ) { entry, selectionRequired -> entry.destination.route to selectionRequired } + .filter { (_, selectionRequired) -> !selectionRequired } + .map { (route, _) -> route }, isAlreadyAtRoute = { route -> navController.isDisplayingExactPlayerRoute( route = route, @@ -201,70 +574,11 @@ fun AppNavigation( ) }, navigate = { route -> - // An external link to a TAB (prairie://downloads) must switch tabs, - // not push a second copy of that tab. A duplicate tab entry also - // makes the tab anchor ambiguous: popUpTo(route) resolves to the - // NEWEST match, so the older anchor entry would survive and Back - // could loop through a hidden tab. - if (tabForRoute(route) != null) { - // Tear the player down BEFORE the tab switch, and without - // saving it. tabSwitchNavOptions saves state so a tab keeps - // its stack, which is right for a tab — but a saved player - // entry keeps its ViewModelStore alive, so onCleared never - // runs and the playback session it owns is never stopped. - // The save is also keyed to the LOWEST popped destination, - // so a later clearBackStack on the player route would not - // even find it. Popping first means the player's teardown - // runs the ordinary way. - // ONLY when the player is the current destination. An - // inclusive pop also removes everything above its target, - // so a player sitting BELOW other entries — an external - // item link pushed over it, say — would take those with it - // and silently discard state the viewer expected back. - // Leaving that rarer case saved is the pre-existing - // behaviour; destroying history to fix it is worse. - if (shouldPopPlayerBeforeExternalTab( - navController.currentBackStackEntry?.destination?.route, - ) - ) { - navController.popBackStack( - route = Route.Player.ROUTE, - inclusive = true, - saveState = false, - ) - } - navController.navigate(route) { - tabSwitchNavOptions(navController.bottomMostTabRoute()) - } + // D5: solo playback from a link asks to leave a party first. + if (route.startsWith("player/") && !route.contains("roomId=")) { + externalSoloGuard.run { navigateExternalRoute(route) } } else { - val replaceCurrentPlayer = shouldReplaceCurrentPlayer( - currentDestinationRoute = navController.currentBackStackEntry - ?.destination?.route, - targetRoute = route, - ) - // Single-top only when the arguments agree it really is the - // same screen. AndroidX matches the destination NODE, so an - // external link to item B while item A's detail is showing - // reused A's entry and Back skipped A entirely — the same - // defect this branch fixes for in-app navigation. - val useSingleTop = navController.currentBackStackEntry?.arguments?.getString("libraryId") == null && - shouldLaunchExternalRouteSingleTop( - currentDestinationRoute = navController.currentBackStackEntry - ?.destination?.route, - currentContentId = navController.currentBackStackEntry - ?.savedStateHandle?.get(DisplayedDetailContentIdKey) - ?: navController.currentBackStackEntry?.arguments?.getString("contentId"), - targetRoute = route, - ) - navController.navigate(route) { - if (replaceCurrentPlayer) { - popUpTo(Route.Player.ROUTE) { inclusive = true } - } - // Decided from the finite external-route producer set, - // not punctuation. A route's spelling does not say - // whether its arguments identify a distinct request. - launchSingleTop = useSingleTop - } + navigateExternalRoute(route) } }, isStillValidForScope = { scope -> @@ -306,6 +620,8 @@ fun AppNavigation( ProvideCardOverlays(store = overlayPrefsStore, sessionKey = overlaySessionKey) { ProvideCardPresentation(store = cardPresentationStore, sessionKey = overlaySessionKey) { + ProvideEpisodeSpoilerPrefs(store = episodeSpoilerStore, sessionKey = overlaySessionKey) { + ProvideTitleArt(store = titleArtStore, sessionKey = overlaySessionKey) { // Shared-element host: lets a tapped poster morph into the item-detail // backdrop. The scope is published via CompositionLocal so deep descendants // (a poster card, the detail hero) can opt in without threading it through @@ -317,8 +633,18 @@ fun AppNavigation( CompositionLocalProvider( LocalSharedTransitionScope provides this, LocalHeroSourceHandoff provides heroSourceHandoff, + LocalProfileSwitch provides switchProfile, ) { Box(modifier = Modifier.fillMaxSize()) { + // The first-run screens draw over one shared brand-light backdrop that + // lives outside the destinations, so it keeps moving while they fade. + AnimatedVisibility( + visible = currentEntry?.destination?.route in MarqueeRoutes, + enter = fadeIn(tween(PageFadeDurationMs)), + exit = fadeOut(tween(PageFadeDurationMs)), + ) { + MarqueeBackdrop() + } NavHost( navController = navController, startDestination = startDestination, @@ -348,6 +674,34 @@ fun AppNavigation( popUpTo(Route.ServerSetup.route) { inclusive = true } } }, + onNavigateToProfiles = { + navController.navigate(Route.ProfileSelection.route) { + popUpTo(Route.ServerSetup.route) { inclusive = true } + } + }, + ) + } + composable( + route = Route.ServerSetupPrefilled.ROUTE, + arguments = listOf(navArgument("url") { type = NavType.StringType }), + ) { backStackEntry -> + ServerSetupScreen( + prefillUrl = backStackEntry.arguments?.getString("url"), + onNavigateToSetup = { + navController.navigate(Route.Setup.route) { + popUpTo(Route.ServerSetupPrefilled.ROUTE) { inclusive = true } + } + }, + onNavigateToLogin = { _ -> + navController.navigate(Route.Login.route) { + popUpTo(Route.ServerSetupPrefilled.ROUTE) { inclusive = true } + } + }, + onNavigateToProfiles = { + navController.navigate(Route.ProfileSelection.route) { + popUpTo(Route.ServerSetupPrefilled.ROUTE) { inclusive = true } + } + }, ) } composable(Route.Login.route) { @@ -444,6 +798,16 @@ fun AppNavigation( nullable = true defaultValue = null }, + navArgument("serverId") { + type = NavType.StringType + nullable = true + defaultValue = null + }, + navArgument("serverUrl") { + type = NavType.StringType + nullable = true + defaultValue = null + }, ), // Deliberately NO navDeepLink registrations. While they existed, // Navigation matched the Activity's launch Intent itself when the @@ -456,78 +820,170 @@ fun AppNavigation( val token = backStackEntry.arguments?.getString("token") val code = backStackEntry.arguments?.getString("code") val requiredOrigin = backStackEntry.arguments?.getString("serverOrigin") + val requiredServerId = backStackEntry.arguments?.getString("serverId") + val linkServerUrl = backStackEntry.arguments?.getString("serverUrl") val knownServers by serverRegistry.entries.collectAsState() val activeServer by serverRegistry.activeEntry.collectAsState() - val match = remember(requiredOrigin, activeServer, knownServers) { - deviceLoginServerMatch( - requiredOrigin = requiredOrigin, - activeServerUrl = activeServer?.url, - entries = knownServers, - ) + val serverIdentities: org.prairieserver.prairie.repository.ServerIdentityRepository = koinInject() + // A `prairie://device?server=` link names its server by deployment + // identity; matching it may need a probe, so it resolves async. + // Origin-scoped and unscoped links resolve at once, as before. + val match by produceState( + initialValue = if (requiredServerId == null) { + deviceLoginServerMatch(requiredOrigin, activeServer?.url, knownServers) + } else { + null + }, + requiredServerId, requiredOrigin, activeServer?.id, knownServers.map { it.id to it.url }, + ) { + value = if (requiredServerId == null) { + deviceLoginServerMatch(requiredOrigin, activeServer?.url, knownServers) + } else { + deviceLoginServerMatchByIdentity( + requiredServerId = requiredServerId, + linkUrl = linkServerUrl, + activeEntry = activeServer, + entries = knownServers, + identityOf = { entry -> serverIdentities.identityOf(entry) }, + entriesWithIdentity = serverIdentities::entriesFor, + ) + } } + /** This request again, with [pendingCode] as its code: for re-queueing it. */ + fun pairDeviceRoute(pendingCode: String?) = Route.PairDevice( + token = token, + code = pendingCode, + serverOrigin = requiredOrigin, + serverId = requiredServerId, + serverUrl = linkServerUrl, + ).route + val requeueRoute = pairDeviceRoute(code) val pairingScope = rememberCoroutineScope() + val pairingDone: () -> Unit = { + if (!navController.popBackStack()) { + navController.navigate(Route.Home.route) { + popUpTo(0) { inclusive = true } + } + } + } + /** + * Sign in on [serverId]'s server (the active one when null), then + * come back to [pendingCode]: signing in ends at profile selection, + * whose popUpTo(0) wipes this destination, so the request is + * re-queued. [signOutFirst] is "Not you? Switch account": sign out + * of that server's account so a different one can sign in, and + * have its next provider sign-in ask the provider to offer + * another account (`prompt=select_account`); [startSignIn] (a + * provider that takes it) has the login screen start that + * sign-in by itself, as on prairie-apple. Sign-in + * runs on the active server, so a different server becomes active. + * Runs in the nav host's scope: it outlives this destination. + */ + fun signInThenReturn(serverId: String?, pendingCode: String?, signOutFirst: Boolean, startSignIn: Boolean = false) { + navScope.launch { + val switchTo = serverId?.takeIf { it != serverRegistry.activeServerId.value } + // Settings still waiting to sync go to the server they were + // made on, before the switch. + if (signOutFirst) signOutTeardown.flushPendingSettings() + if (switchTo != null) authRepository.switchToServer(switchTo) + val switching = switchTo != null + // Same teardown as every other sign-out, which also has + // the next provider sign-in offer another account. + if (signOutFirst) signOutTeardown.signOut(flushFirst = false, startSignIn = startSignIn) + onRequeueExternalRoute(pairDeviceRoute(pendingCode ?: code)) + navController.navigate(Route.Login.route) { + if (switching || signOutFirst) popUpTo(0) { inclusive = true } + } + } + } when (val resolved = match) { - is DeviceLoginServerMatch.SwitchRequired -> - DevicePairingWrongServerScreen( - serverName = resolved.entry.displayName, - onSwitch = { - pairingScope.launch { - authRepository.switchToServer(resolved.entry.id) - // Re-queue ONLY if the target server will send - // the user through auth: that flow ends at - // profile selection, whose popUpTo(0) wipes this - // destination and the code would have to be - // scanned again. Re-queueing unconditionally was - // worse — with no sign-in needed the request just - // waited for this screen to close and then - // reopened it. - val authRoute = pairingAuthRouteOrNull( - tokenManager = tokenManager, - activeEntryProfileId = serverRegistry.activeEntry.value - ?.profileId, - ) - if (authRoute != null) { - onRequeueExternalRoute( - Route.PairDevice( - token = token, - code = code, - serverOrigin = requiredOrigin, - ).route, + null -> org.prairieserver.prairie.android.ui.screens.auth.AuthStage { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.align(androidx.compose.ui.Alignment.Center), + ) + } + is DeviceLoginServerMatch.SwitchRequired -> { + val inPlace = resolved.inPlaceApprovalServer(token, code) + if (inPlace != null) { + // The link's code is approved on the saved server it + // names, with that server's own credentials: the phone + // stays on its active server (prairie-apple parity). + DevicePairingScreen( + token = null, + code = code, + serverId = inPlace.id, + lockServer = true, + onDone = pairingDone, + onSignIn = { selectedServerId, typedCode -> + signInThenReturn(selectedServerId ?: inPlace.id, typedCode, signOutFirst = false) + }, + onSwitchAccount = { selectedServerId, typedCode, startSignIn -> + signInThenReturn(selectedServerId ?: inPlace.id, typedCode, signOutFirst = true, startSignIn) + }, + ) + } else { + // A `token` link: only the active server's lookup takes it. + DevicePairingWrongServerScreen( + serverName = resolved.entry.displayName, + onSwitch = { + pairingScope.launch { + authRepository.switchToServer(resolved.entry.id) + // Profile Selection covers this switch as + // it does the server list's: a saved + // profile there needs choosing (and its + // PIN) again, which routes through the + // picker below. + activeProfileReset.clearIfSelectionRequired() + // Re-queue ONLY if the target server will send + // the user through auth: that flow ends at + // profile selection, whose popUpTo(0) wipes this + // destination and the code would have to be + // scanned again. Re-queueing unconditionally was + // worse — with no sign-in needed the request just + // waited for this screen to close and then + // reopened it. + val authRoute = pairingAuthRouteOrNull( + tokenManager = tokenManager, + activeEntryProfileId = serverRegistry.activeEntry.value + ?.profileId, ) - // Requeueing alone left the user sitting on - // a pairing screen for a server they are not - // signed in to; the queued request only - // fires once something else takes them - // somewhere authenticated. Send them. - navController.navigate(authRoute) { - popUpTo(0) { inclusive = true } + if (authRoute != null) { + onRequeueExternalRoute(requeueRoute) + // Requeueing alone left the user sitting on + // a pairing screen for a server they are not + // signed in to; the queued request only + // fires once something else takes them + // somewhere authenticated. Send them. + navController.navigate(authRoute) { + popUpTo(0) { inclusive = true } + } } } - } - }, - onCancel = { - if (!navController.popBackStack()) { - navController.navigate(Route.Home.route) { - popUpTo(0) { inclusive = true } + }, + onCancel = { + if (!navController.popBackStack()) { + navController.navigate(Route.Home.route) { + popUpTo(0) { inclusive = true } + } } - } - }, - ) + }, + ) + } + } is DeviceLoginServerMatch.UnknownServer -> DevicePairingUnknownServerScreen( origin = resolved.origin, onAddServer = { // Adding a server always runs setup and login, which // clear this destination — so this one always - // re-queues. - onRequeueExternalRoute( - Route.PairDevice( - token = token, - code = code, - serverOrigin = requiredOrigin, - ).route, + // re-queues. The link's address is filled in. + onRequeueExternalRoute(requeueRoute) + val linkOrigin = resolved.origin.takeIf { + it.startsWith("https://", ignoreCase = true) || it.startsWith("http://", ignoreCase = true) + } + navController.navigate( + linkOrigin?.let { Route.ServerSetupPrefilled(it).route } ?: Route.ServerSetup.route, ) - navController.navigate(Route.ServerSetup.route) }, onCancel = { if (!navController.popBackStack()) { @@ -540,26 +996,19 @@ fun AppNavigation( DeviceLoginServerMatch.Active -> DevicePairingScreen( token = token, code = code, - onDone = { - if (!navController.popBackStack()) { - navController.navigate(Route.Home.route) { - popUpTo(0) { inclusive = true } - } - } + serverId = activeServer?.id, + // A link that names this server keeps the code on it. + lockServer = requiredServerId != null || requiredOrigin != null, + onDone = pairingDone, + // "Sign in" after a 401 on the chosen server: sign in there + // (it becomes active) and come back to the typed code. + onSignIn = { selectedServerId, typedCode -> + signInThenReturn(selectedServerId, typedCode, signOutFirst = false) }, - onSignIn = { - // Same preservation as the switch path: signing in ends - // at profile selection, whose popUpTo(0) wipes this - // destination, and the code would have to be scanned - // again. - onRequeueExternalRoute( - Route.PairDevice( - token = token, - code = code, - serverOrigin = requiredOrigin, - ).route, - ) - navController.navigate(Route.Login.route) + onSwitchAccount = { selectedServerId, typedCode, startSignIn -> + // "Not you? Switch account": sign out of the server the + // card names, then sign in again and come back to this code. + signInThenReturn(selectedServerId, typedCode, signOutFirst = true, startSignIn) }, ) } @@ -600,9 +1049,10 @@ fun AppNavigation( overlayPrefsStore.clear() activeProfileStore.reset() cardPresentationStore.clear() - // Not seekIntervalStore.clear(): the registry switch has already - // reset it and started hydrating the new server's profile through - // its identity flow, and a clear here would discard that load. + // Not seekIntervalStore.clear() or episodeSpoilerStore.clear(): + // the registry switch has already reset them and started + // hydrating the new server's profile through their identity + // flow, and a clear here would discard that load. navController.navigate(target) { popUpTo(0) { inclusive = true } launchSingleTop = true @@ -621,8 +1071,28 @@ fun AppNavigation( // ---- Profile selection ---- composable(Route.ProfileSelection.route) { + // Switch Profile opens the picker above Main so Back can cancel it. + // Once profile recovery has cleared the selection (a stale PIN + // proof), Main has no profile to return to and every request there + // would fail, so Back leaves the app, as it does when the picker is + // the first screen. A picker opened from the server list or a + // sign-in screen (adding a server) has no profile yet either; Back + // there still returns to that screen. + val pickerActivity = androidx.activity.compose.LocalActivity.current + val selectedProfileId = serverRegistry.activeEntry.collectAsState().value?.profileId + val previousRoute = navController.previousBackStackEntry?.destination?.route + androidx.activity.compose.BackHandler( + enabled = selectedProfileId.isNullOrBlank() && + previousRoute != null && + previousRoute !in ProfilePromptDeferredRoutes, + ) { + pickerActivity?.finish() + } ProfileSelectionScreen( onNavigateToHome = { + // A chosen profile ends any away interval (prairie-apple + // `remember`). + profileLaunchPreferences.clearBackgroundedAt() // The switch-profile paths dropped the per-profile card // and seek-interval caches; re-hydrate for the profile just picked. The // providers above the graph only re-run when the profile id @@ -633,6 +1103,8 @@ fun AppNavigation( overlayPrefsStore.hydrateIfNeeded() cardPresentationStore.hydrateIfNeeded() seekIntervalStore.hydrateIfNeeded() + episodeSpoilerStore.hydrateIfNeeded() + titleArtStore.hydrateIfNeeded() } // Route through the tour gate: OnboardingTourScreen checks // server-side state and immediately hands off to Home when @@ -647,6 +1119,15 @@ fun AppNavigation( onNavigateToEditProfile = { profileId -> navController.navigate(Route.EditProfile(profileId).route) }, + onChangeServer = { navController.navigate(Route.ServerList.route) }, + onSignOut = { + navScope.launch { + signOutTeardown.signOut() + navController.navigate(Route.Login.route) { + popUpTo(0) { inclusive = true } + } + } + }, ) } composable(Route.CreateProfile.route) { @@ -746,21 +1227,21 @@ fun AppNavigation( } composable(Route.Settings.route) { SettingsScreen( + onOpenInterface = { navController.navigate(Route.SettingsInterface.route) }, + onOpenPlayback = { navController.navigate(Route.SettingsPlayback.route) }, + onOpenSubtitles = { navController.navigate(Route.SettingsSubtitles.route) }, + onOpenDownloads = { navController.navigate(Route.SettingsDownloads.route) }, + onOpenNotifications = { navController.navigate(Route.SettingsNotifications.route) }, + onOpenProfileSelection = { navController.navigate(Route.SettingsProfileSelection.route) }, onNavigateToServers = { navController.navigate(Route.ServerList.route) }, onPairDevice = { navController.navigate(Route.PairDevice().route) }, - onSwitchProfile = { - // Leave the shell first, then drop the per-profile caches - // (see the profile-menu path in MainScreen). - navController.navigate(Route.ProfileSelection.route) - overlayPrefsStore.clear() - activeProfileStore.reset() - cardPresentationStore.clear() - seekIntervalStore.clear() - }, + // Same switch as the profile menu: push pending settings, + // leave the shell, then drop the per-profile caches. + onSwitchProfile = switchProfile, onNavigateToWatchlist = { navController.navigate(Route.Watchlist.route) }, onNavigateToFavorites = { navController.navigate(Route.Favorites.route) }, onNavigateToHistory = { navController.navigate(Route.History.route) }, @@ -773,10 +1254,45 @@ fun AppNavigation( popUpTo(0) { inclusive = true } } }, - showTopBar = true, onBackClick = { navController.popBackStack() }, ) } + // Settings sub-pages share the overview's view model, so the value a + // page changes is the value its overview row shows on the way back. + composable(Route.SettingsInterface.route) { entry -> + SettingsInterfaceScreen( + viewModel = koinViewModel(viewModelStoreOwner = settingsOwner(navController, entry)), + onBackClick = { navController.popBackStack() }, + ) + } + composable(Route.SettingsPlayback.route) { entry -> + SettingsPlaybackScreen( + viewModel = koinViewModel(viewModelStoreOwner = settingsOwner(navController, entry)), + onBackClick = { navController.popBackStack() }, + ) + } + composable(Route.SettingsSubtitles.route) { entry -> + SettingsSubtitlesScreen( + viewModel = koinViewModel(viewModelStoreOwner = settingsOwner(navController, entry)), + onBackClick = { navController.popBackStack() }, + ) + } + composable(Route.SettingsDownloads.route) { entry -> + SettingsDownloadsScreen( + viewModel = koinViewModel(viewModelStoreOwner = settingsOwner(navController, entry)), + downloadsViewModel = koinViewModel(), + onBackClick = { navController.popBackStack() }, + ) + } + composable(Route.SettingsNotifications.route) { entry -> + SettingsNotificationsScreen( + viewModel = koinViewModel(viewModelStoreOwner = settingsOwner(navController, entry)), + onBackClick = { navController.popBackStack() }, + ) + } + composable(Route.SettingsProfileSelection.route) { + SettingsProfileSelectionScreen(onBackClick = { navController.popBackStack() }) + } composable(Route.Diagnostics.route) { DiagnosticsSettingsScreen( onBackClick = { navController.popBackStack() }, @@ -815,8 +1331,8 @@ fun AppNavigation( onPersonClick = { personId -> navController.navigate(Route.PersonDetail(personId).route) }, - onRequestMediaClick = { item -> - navController.navigate(Route.RequestDetail(item.mediaType, item.tmdbId).route) + onRequestMediaClick = { mediaType, tmdbId -> + navController.navigate(Route.RequestDetail(mediaType, tmdbId).route) }, onRequestLibraryItemClick = { contentId -> navController.navigate(Route.ItemDetail(contentId).route) @@ -864,26 +1380,34 @@ fun AppNavigation( } // ---- Requests ---- + val openRequestDetail: (String, Int) -> Unit = { mediaType, tmdbId -> + navController.navigate(Route.RequestDetail(mediaType, tmdbId).route) + } + val openRequestLibraryItem: (String) -> Unit = { contentId -> + navController.navigate(Route.ItemDetail(contentId).route) + } composable(Route.Requests.route) { RequestsScreen( onBackClick = { navController.popBackStack() }, onMyRequestsClick = { navController.navigate(Route.MyRequests.route) }, - onMediaClick = { item -> - navController.navigate(Route.RequestDetail(item.mediaType, item.tmdbId).route) - }, - onLibraryItemClick = { contentId -> - navController.navigate(Route.ItemDetail(contentId).route) - }, + onApprovalsClick = { navController.navigate(Route.RequestApprovals.route) }, + onRequestDetailClick = openRequestDetail, + onLibraryItemClick = openRequestLibraryItem, ) } composable(Route.MyRequests.route) { MyRequestsScreen( onBackClick = { navController.popBackStack() }, - onRequestClick = { request -> - request.libraryContentId?.takeIf { it.isNotBlank() }?.let { contentId -> - navController.navigate(Route.ItemDetail(contentId).route) - } ?: navController.navigate(Route.RequestDetail(request.mediaType, request.tmdbId).route) - }, + onApprovalsClick = { navController.navigate(Route.RequestApprovals.route) }, + onRequestDetailClick = openRequestDetail, + onLibraryItemClick = openRequestLibraryItem, + ) + } + composable(Route.RequestApprovals.route) { + RequestApprovalsScreen( + onBackClick = { navController.popBackStack() }, + onRequestDetailClick = openRequestDetail, + onLibraryItemClick = openRequestLibraryItem, ) } composable( @@ -899,12 +1423,8 @@ fun AppNavigation( mediaType = mediaType, tmdbId = tmdbId, onBackClick = { navController.popBackStack() }, - onMediaClick = { item -> - navController.navigate(Route.RequestDetail(item.mediaType, item.tmdbId).route) - }, - onLibraryItemClick = { contentId -> - navController.navigate(Route.ItemDetail(contentId).route) - }, + onRequestDetailClick = openRequestDetail, + onLibraryItemClick = openRequestLibraryItem, ) } @@ -948,19 +1468,27 @@ fun AppNavigation( nullable = true defaultValue = null }, + navArgument("mediaScope") { + type = NavType.StringType + nullable = true + defaultValue = null + }, ), ) { backStackEntry -> CollectionDetailScreen( collectionId = backStackEntry.arguments?.getString("collectionId") ?: "", onBackClick = { navController.popBackStack() }, - onItemClick = { contentId -> + // Collection items can live in other libraries, and a + // library-scoped item read 404s for those. Open them, and + // shuffle picks, unscoped, like the web client. + onShuffleStarted = { shuffle -> navController.navigate( - Route.ItemDetail( - contentId, - libraryId = backStackEntry.arguments?.getString("libraryId")?.toIntOrNull(), - ).route, + org.prairieserver.prairie.android.ui.screens.shuffle.shufflePlayerRoute(shuffle), ) }, + onItemClick = { contentId -> + navController.navigate(Route.ItemDetail(contentId).route) + }, ) } @@ -990,7 +1518,6 @@ fun AppNavigation( // Keep the destination scope available to media cards nested in // the detail page so the poster shared-element hand-off still runs. CompositionLocalProvider(LocalNavAnimatedVisibilityScope provides this) { - var wtTarget by remember { mutableStateOf?>(null) } val initialContentId = backStackEntry.arguments?.getString("contentId").orEmpty() val openingArtworkUrl = remember(initialContentId) { heroSourceHandoff.pendingArtworkUrl } val openingArtworkThumbhash = remember(initialContentId) { @@ -1076,6 +1603,11 @@ fun AppNavigation( resolvedEpisodeId = episodeId resolvedSeriesId = seriesId }, + onShuffleStarted = { shuffle -> + navController.navigate( + org.prairieserver.prairie.android.ui.screens.shuffle.shufflePlayerRoute(shuffle, libraryId), + ) + }, onPersonClick = { personId -> personId.toLongOrNull()?.let { id -> navController.navigate(Route.PersonDetail(id).route) @@ -1089,46 +1621,78 @@ fun AppNavigation( onBookReadClick = { contentId, fileId -> navController.navigate(Route.BookReader(contentId, fileId, libraryId).route) }, - onWatchTogether = { contentId, fileId -> wtTarget = contentId to fileId }, + libraryId = libraryId, + onWatchParty = { item -> + navController.navigate(Route.WatchPartyHub(host = watchPartyHandoff.offerHost(item)).route) + }, + onPartySelected = { room -> openPartyPlayer(room, replacing = null) }, onOpenCastRemote = { navController.navigate(Route.PrairieCastRemote.route) { launchSingleTop = true } }, viewModel = detailViewModel, ) } - wtTarget?.let { (cid, fid) -> - WatchTogetherEntrySheet( - contentId = cid, - fileId = fid, - onNavigate = { route -> navController.navigate(route) }, - onDismiss = { wtTarget = null }, - ) - } } } } - // ---- Watch Together lobby ---- + // ---- Watch Party ---- composable( - route = Route.WatchTogetherLobby.ROUTE, + route = Route.WatchPartyHub.ROUTE, arguments = listOf( - navArgument(Route.WatchTogetherLobby.ARG_ROOM_ID) { type = NavType.StringType }, + navArgument(Route.WatchPartyHub.ARG_INVITE) { + type = NavType.StringType + nullable = true + defaultValue = null + }, + navArgument(Route.WatchPartyHub.ARG_HOST) { + type = NavType.StringType + nullable = true + defaultValue = null + }, ), ) { backStackEntry -> - val roomId = backStackEntry.arguments - ?.getString(Route.WatchTogetherLobby.ARG_ROOM_ID) - .orEmpty() - WatchTogetherLobbyScreen( - roomId = roomId, - onNavigateToPlayer = { route -> - navController.navigate(route) { - popUpTo(Route.WatchTogetherLobby.ROUTE) { inclusive = true } - } - }, + WatchPartyHubScreen( + inviteId = backStackEntry.arguments?.getString(Route.WatchPartyHub.ARG_INVITE), + hostId = backStackEntry.arguments?.getString(Route.WatchPartyHub.ARG_HOST), onBack = { navController.popBackStack() }, + // The hub stays below the lobby: it owns a create that may + // still be staging the host's item. + onOpenLobby = { roomId -> navController.navigate(Route.WatchPartyLobby(roomId).route) }, + onOpenPlayer = { room -> openPartyPlayer(room, replacing = null) }, ) } + composable( + route = Route.WatchPartyLobby.ROUTE, + arguments = listOf( + navArgument(Route.WatchPartyLobby.ARG_ROOM_ID) { type = NavType.StringType }, + ), + ) { backStackEntry -> + val roomId = backStackEntry.arguments + ?.getString(Route.WatchPartyLobby.ARG_ROOM_ID) + .orEmpty() + // A lobby restored without a live membership (process death): + // the room token was memory-only, so open the hub and its Rejoin + // instead of re-adopting silently. + val live = remember(roomId) { + watchTogetherRepository.roomSnapshot.value?.roomId == roomId || + watchTogetherRepository.ended.value?.roomId == roomId + } + if (!live) { + LaunchedEffect(roomId) { openPartyHub(replacing = Route.WatchPartyLobby.ROUTE) } + } else { + WatchPartyLobbyScreen( + roomId = roomId, + onBack = { navController.popBackStack() }, + onOpenPlayer = { room -> openPartyPlayer(room, replacing = Route.WatchPartyLobby.ROUTE) }, + onPartyEnded = { openPartyHub(replacing = Route.WatchPartyLobby.ROUTE) }, + onLeft = { navController.popBackStack() }, + onOpenDetail = { contentId -> navController.navigate(Route.ItemDetail(contentId).route) }, + ) + } + } + // ---- Audiobook player (audio-only UI) ---- composable( route = Route.AudiobookPlayer.ROUTE, @@ -1238,6 +1802,11 @@ fun AppNavigation( nullable = true defaultValue = null }, + navArgument("shuffleId") { + type = NavType.StringType + nullable = true + defaultValue = null + }, ), ) { backStackEntry -> val playerViewModel = koinViewModel() @@ -1266,6 +1835,7 @@ fun AppNavigation( backStackEntry.arguments?.getString("resumePosition"), ), roomId = backStackEntry.arguments?.getString("roomId"), + shuffleId = backStackEntry.arguments?.getString("shuffleId"), navController = navController, viewModel = playerViewModel, ) @@ -1412,6 +1982,53 @@ fun AppNavigation( .padding(bottom = if (currentRoute in tabRoutes) 80.dp else 0.dp), ) } + + WatchPartySoloGuardDialog(externalSoloGuard) + + // Nearby-TV offer, app-wide once signed in (iOS parity). Not over the + // sign-in chain, where the phone has no session to approve with, nor + // over playback or reading. + val companionHiddenRoutes = setOf( + Route.Login.route, + Route.ServerSetup.route, + Route.ServerSetupPrefilled.ROUTE, + Route.Setup.route, + Route.Signup.route, + Route.ProfileSelection.route, + Route.CreateProfile.route, + Route.InviteClaim.ROUTE, + Route.PairDevice.ROUTE, + Route.Player.ROUTE, + Route.AudiobookPlayer.ROUTE, + Route.BookReader.ROUTE, + Route.PrairieCastRemote.route, + ) + org.prairieserver.prairie.android.ui.screens.pairing.CompanionPairingHost( + enabled = currentRoute != null && currentRoute !in companionHiddenRoutes, + ) + org.prairieserver.prairie.android.ui.screens.profiles.HouseholdReverifyHost() + + // While a profile switch pushes pending settings (bounded at a few + // seconds), show that it is under way and take input, so nothing else + // can be opened over a switch that is about to leave the shell. + if (profileSwitching) { + // System Back too: composed after the NavHost, this handler wins, so + // Back can neither pop the screen under the switch nor close the + // app and cancel it. + BackHandler {} + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.4f)) + .pointerInput(Unit) { + awaitPointerEventScope { while (true) awaitPointerEvent().changes.forEach { it.consume() } } + }, + ) { + LoadingIndicator() + } + } + } + } } } } @@ -1419,6 +2036,9 @@ fun AppNavigation( } } +/** Route placeholder for a party player; the room, not the route, decides what plays. */ +private const val PARTY_PLAYER_PLACEHOLDER_ID = "watch-party" + /** * Exact player redelivery is idempotent. Navigating the same concrete route * with launchSingleTop replaces the top entry and tears down active playback; @@ -1432,7 +2052,7 @@ private fun NavHostController.isDisplayingExactPlayerRoute( if (entry.destination.route != Route.Player.ROUTE) return false val arguments = entry.arguments ?: return false // A normal prairie://play link is a solo-playback request. Never swallow it - // merely because a Watch Together room currently happens to play the same + // merely because a Watch Party room currently happens to play the same // content/file. if (!arguments.getString("roomId").isNullOrBlank()) return false if (arguments.getString("libraryId") != null) return false @@ -1456,3 +2076,14 @@ private suspend fun pairingAuthRouteOrNull( val profileId = activeEntryProfileId ?: tokenManager.getProfileId() return if (profileId.isNullOrBlank()) Route.ProfileSelection.route else null } + +/** + * The Settings overview's back-stack entry, which owns the SettingsViewModel + * its sub-pages share. Falls back to the page's own entry should the overview + * ever be missing from the stack, so the page still renders. + */ +@Composable +private fun settingsOwner(navController: NavHostController, entry: NavBackStackEntry): ViewModelStoreOwner = + remember(entry) { + runCatching { navController.getBackStackEntry(Route.Settings.route) }.getOrNull() ?: entry + } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/DeviceLoginRouteParser.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/DeviceLoginRouteParser.kt index 78e7b682c5..669afff8fc 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/DeviceLoginRouteParser.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/DeviceLoginRouteParser.kt @@ -5,12 +5,18 @@ import java.net.URLDecoder import java.net.URLEncoder /** - * Maps scanned/opened device-login URLs into the in-app pairing route. + * Maps scanned/opened device-login URLs into the in-app "Sign in a TV" route. * - * TV QR sessions may surface either app-scheme links (`prairie://device?...`) - * or server HTTPS links (`/device`, `/auth/device`). - * Android App Links for arbitrary self-hosted server domains are best-effort, - * but whenever the OS delivers a URI to us this parser keeps routing identical. + * TV sign-in links arrive as: + * - the web approval page's app link, + * `prairie://device?server=&url=&code=`: the server is + * named by its deployment identity, matched against saved servers by + * verified identity rather than URL spelling. Without `server` the link + * is scoped by `url`'s origin, like an HTTPS link; + * - older app links, `prairie://device?token=|code=`, which name no server; + * - server HTTPS links (`/activate`, `/device`, `/auth/device`), named by + * their origin. Android App Links for arbitrary self-hosted domains are + * best-effort, but whenever the OS delivers one this keeps routing identical. */ internal fun deviceLoginPairRouteOrNull(rawUri: String?): String? { val uri = rawUri @@ -33,13 +39,37 @@ internal fun deviceLoginPairRouteOrNull(rawUri: String?): String? { val code = params["code"]?.takeIf { it.isNotBlank() } if (token == null && code == null) return null + // The app link names its server by identity, plus the base URL to add it + // from when this phone doesn't have it yet. A `url` that isn't a readable + // http(s) origin is dropped rather than trusted. + val unscoped = scope as? DeviceLoginScope.Unscoped + val serverId = unscoped?.let { params["server"]?.trim()?.takeIf { it.isNotEmpty() } } + val linkBase = unscoped?.let { params["url"] } + ?.let { runCatching { URI(it.trim()) }.getOrNull() } + ?.takeIf { it.scheme?.lowercase() in setOf("http", "https") } + // The web page leaves `server` out when it doesn't know the identity; the + // link is then scoped by `url`'s origin, as an http(s) link is, instead of + // approving on whichever server is active. + val serverOrigin = (scope as? DeviceLoginScope.Origin)?.origin + ?: linkBase?.takeIf { serverId == null }?.normalizedOrigin() + val serverUrl = linkBase?.takeIf { serverId != null }?.let { base -> + // Keep a reverse-proxy path prefix: it is part of the server's address. + base.normalizedOrigin()?.let { origin -> origin + base.path.orEmpty().trimEnd('/') } + } + return buildPairDeviceRoute( token = token, code = if (token == null) code else null, - serverOrigin = (scope as? DeviceLoginScope.Origin)?.origin, + serverOrigin = serverOrigin, + serverId = serverId, + serverUrl = serverUrl, ) } +/** `scheme://host[:port]` of [url], or null when it can't be read. */ +internal fun deviceLoginOriginOf(url: String?): String? = + url?.takeIf { it.isNotBlank() }?.let { runCatching { URI(it.trim()) }.getOrNull() }?.normalizedOrigin() + /** * What server, if any, a device link names. * @@ -112,7 +142,7 @@ private fun URI.isDeviceLoginUri(): Boolean { val scheme = scheme?.lowercase() return when (scheme) { "prairie" -> host.equals("device", ignoreCase = true) - "http", "https" -> normalizedPath.endsWith("/device") + "http", "https" -> normalizedPath.endsWith("/device") || normalizedPath.endsWith("/activate") else -> false } } @@ -140,12 +170,16 @@ private fun buildPairDeviceRoute( token: String?, code: String?, serverOrigin: String?, + serverId: String? = null, + serverUrl: String? = null, ): String = buildString { append("pair_device") val params = listOfNotNull( token?.takeIf { it.isNotBlank() }?.let { "token=${it.routeEncode()}" }, code?.takeIf { it.isNotBlank() }?.let { "code=${it.routeEncode()}" }, serverOrigin?.takeIf { it.isNotBlank() }?.let { "serverOrigin=${it.routeEncode()}" }, + serverId?.takeIf { it.isNotBlank() }?.let { "serverId=${it.routeEncode()}" }, + serverUrl?.takeIf { it.isNotBlank() }?.let { "serverUrl=${it.routeEncode()}" }, ) if (params.isNotEmpty()) { append("?") diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/DeviceLoginServerMatch.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/DeviceLoginServerMatch.kt index c40a1eaaa8..83a815ed41 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/DeviceLoginServerMatch.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/DeviceLoginServerMatch.kt @@ -22,6 +22,15 @@ sealed interface DeviceLoginServerMatch { data class UnknownServer(val origin: String) : DeviceLoginServerMatch } +/** + * The saved server this link's code is approved on in place: through that + * server's own account scope, without switching the phone's active server + * (prairie-apple parity). Null for a `token` link, which only the active + * server's lookup accepts, so the "Different server" screen offers the switch. + */ +fun DeviceLoginServerMatch.SwitchRequired.inPlaceApprovalServer(token: String?, code: String?): ServerEntry? = + entry.takeIf { token.isNullOrBlank() && !code.isNullOrBlank() } + /** * Resolves [requiredOrigin] against the known servers. * @@ -44,3 +53,57 @@ fun deviceLoginServerMatch( ?.let(DeviceLoginServerMatch::SwitchRequired) ?: DeviceLoginServerMatch.UnknownServer(requiredOrigin) } + +/** + * Resolves a `prairie://device?server=&url=` link against the + * saved servers by deployment identity (multi-deployment rule 3): one server + * has several addresses, so URL spelling can't decide which saved server a + * link means, and two servers can issue the same code at the same time. + * + * - The active server has that identity → proceed. + * - Another saved server has it → the "Different server" screen. + * - None does → the "Unknown server" screen, offering to add [linkUrl]. + * + * When no saved server's identity can be verified (offline, or a server that + * predates the identity contract), the link's origin is matched instead, the + * way origin-scoped links always were. A verified identity that differs always + * wins over a matching address. + */ +suspend fun deviceLoginServerMatchByIdentity( + requiredServerId: String, + linkUrl: String?, + activeEntry: ServerEntry?, + entries: List, + identityOf: suspend (ServerEntry) -> String?, + /** + * The saved servers among the candidates with an identity, matching + * recorded identities first and probing the rest in parallel. Null checks + * them one at a time through [identityOf]. + */ + entriesWithIdentity: (suspend (String, List) -> List)? = null, +): DeviceLoginServerMatch { + // No server yet: the user is adding one; the normal setup gates apply. + val active = activeEntry ?: return DeviceLoginServerMatch.Active + val verified = mutableMapOf() + suspend fun idOf(entry: ServerEntry): String? = + if (entry.id in verified) verified[entry.id] else identityOf(entry).also { verified[entry.id] = it } + + if (idOf(active) == requiredServerId) return DeviceLoginServerMatch.Active + val others = entries.filter { it.id != active.id } + val matching = if (entriesWithIdentity != null) { + entriesWithIdentity(requiredServerId, others) + } else { + listOfNotNull(others.firstOrNull { idOf(it) == requiredServerId }) + } + matching.firstOrNull()?.let { return DeviceLoginServerMatch.SwitchRequired(it) } + + val origin = deviceLoginOriginOf(linkUrl) + if (origin != null) { + when (val byOrigin = deviceLoginServerMatch(origin, active.url, entries)) { + DeviceLoginServerMatch.Active -> if (idOf(active) == null) return byOrigin + is DeviceLoginServerMatch.SwitchRequired -> if (idOf(byOrigin.entry) == null) return byOrigin + is DeviceLoginServerMatch.UnknownServer -> Unit + } + } + return DeviceLoginServerMatch.UnknownServer(linkUrl?.takeIf { it.isNotBlank() } ?: origin ?: requiredServerId) +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/ExternalRouteNavigation.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/ExternalRouteNavigation.kt index 6264226de4..bc4316adaf 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/ExternalRouteNavigation.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/ExternalRouteNavigation.kt @@ -145,11 +145,11 @@ internal fun shouldPopPlayerBeforeExternalTab(currentDestinationRoute: String?): * Whether AndroidX may reuse the current destination node for [targetRoute]. * * External requests currently produce only Inbox, item detail, player, - * pairing, invitation, and the Downloads tab (handled before this function). - * Inbox has no arguments. Item detail is reusable only for the same decoded - * content id. A player target is replaced explicitly when a player is on top. - * Pairing and invitation routes carry one-shot arguments, so each delivery - * must retain its own entry. + * pairing, invitation, the Watch Party hub, and the Downloads tab (handled + * before this function). Inbox has no arguments. Item detail is reusable only + * for the same decoded content id. A player target is replaced explicitly when + * a player is on top. Pairing, invitation, and Watch Party hub routes carry + * one-shot arguments, so each delivery must retain its own entry. */ internal fun shouldLaunchExternalRouteSingleTop( currentDestinationRoute: String?, @@ -180,7 +180,7 @@ internal fun playerRouteIntentOrNull(route: String): MobilePlayerRouteIntent? { .filter(String::isNotBlank) .associate { part -> part.substringBefore('=') to part.substringAfter('=', "") } // This provider describes solo playback only. A room-scoped target must be - // handled by Watch Together even if every media choice happens to match. + // handled by Watch Party even if every media choice happens to match. if ("roomId" in query) return null val fileId = query["fileId"]?.toIntOrNull() @@ -258,6 +258,7 @@ private fun optionalTargetMatches( private val preAuthenticationDestinationRoutes = setOf( Route.Login.route, Route.ServerSetup.route, + Route.ServerSetupPrefilled.ROUTE, Route.ServerList.route, Route.Setup.route, Route.Signup.route, diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/LocalProfileSwitch.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/LocalProfileSwitch.kt new file mode 100644 index 0000000000..bb5d79e532 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/LocalProfileSwitch.kt @@ -0,0 +1,10 @@ +package org.prairieserver.prairie.android.ui.navigation + +import androidx.compose.runtime.staticCompositionLocalOf + +/** + * Starts "Switch Profile" in the navigation host's scope, which outlives the + * tab screen the request came from. Run from a tab's own scope, opening a + * title while settings were still being pushed cancelled the switch. + */ +val LocalProfileSwitch = staticCompositionLocalOf<() -> Unit> { {} } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/Routes.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/Routes.kt index 52d03a4bd2..b8e7376e74 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/Routes.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/Routes.kt @@ -18,6 +18,17 @@ sealed class Route(val route: String) { // --- Auth flow (no bottom nav) --- data object ServerSetup : Route("server_setup") + + /** + * Server setup with the address field prefilled, for an app link naming a + * server this phone hasn't saved. A separate destination so the plain + * [ServerSetup] route (a start destination) keeps its exact route string. + */ + data class ServerSetupPrefilled(val url: String) : Route("server_setup_prefilled?url=${Uri.encode(url)}") { + companion object { + const val ROUTE = "server_setup_prefilled?url={url}" + } + } data object ServerList : Route("server_list") data object Login : Route("login") data object Setup : Route("setup") @@ -47,6 +58,10 @@ sealed class Route(val route: String) { * than looking the code up against whichever server is active. */ val serverOrigin: String? = null, + /** Deployment identity from a `prairie://device?server=` link, matched by verified identity. */ + val serverId: String? = null, + /** The link's server origin, to add the server from when it isn't saved. */ + val serverUrl: String? = null, ) : Route( buildString { append("pair_device") @@ -55,6 +70,8 @@ sealed class Route(val route: String) { code?.takeIf { it.isNotBlank() }?.let { "code=${Uri.encode(it)}" }, serverOrigin?.takeIf { it.isNotBlank() } ?.let { "serverOrigin=${Uri.encode(it)}" }, + serverId?.takeIf { it.isNotBlank() }?.let { "serverId=${Uri.encode(it)}" }, + serverUrl?.takeIf { it.isNotBlank() }?.let { "serverUrl=${Uri.encode(it)}" }, ) if (params.isNotEmpty()) { append("?") @@ -63,7 +80,8 @@ sealed class Route(val route: String) { }, ) { companion object { - const val ROUTE = "pair_device?token={token}&code={code}&serverOrigin={serverOrigin}" + const val ROUTE = + "pair_device?token={token}&code={code}&serverOrigin={serverOrigin}&serverId={serverId}&serverUrl={serverUrl}" } } @@ -91,6 +109,15 @@ sealed class Route(val route: String) { } } data object Settings : Route("settings") + + // Settings sub-pages, pushed from the overview as on the Apple apps. They + // share the overview's SettingsViewModel through its back-stack entry. + data object SettingsInterface : Route("settings/interface") + data object SettingsPlayback : Route("settings/playback") + data object SettingsSubtitles : Route("settings/subtitles") + data object SettingsDownloads : Route("settings/downloads") + data object SettingsNotifications : Route("settings/notifications") + data object SettingsProfileSelection : Route("settings/profile_selection") data object Diagnostics : Route("settings/diagnostics") data class DiagnosticsReport(val reportId: String) : Route("settings/diagnostics/report/${Uri.encode(reportId)}") { @@ -112,6 +139,7 @@ sealed class Route(val route: String) { // --- Requests --- data object Requests : Route("requests") data object MyRequests : Route("requests/mine") + data object RequestApprovals : Route("requests/approvals") data class RequestDetail( val mediaType: String, val tmdbId: Int, @@ -184,18 +212,20 @@ sealed class Route(val route: String) { val collectionId: String, val libraryId: Int? = null, val source: String? = null, + val mediaScope: String? = null, ) : Route( buildString { append("collection/${collectionId.routeEncode()}") val parameters = buildList { libraryId?.let { add("libraryId=$it") } source?.takeIf { it.isNotBlank() }?.let { add("source=${it.routeEncode()}") } + mediaScope?.let { add("mediaScope=${it.routeEncode()}") } } if (parameters.isNotEmpty()) append("?${parameters.joinToString("&")}") }, ) { companion object { - const val ROUTE = "collection/{collectionId}?libraryId={libraryId}&source={source}" + const val ROUTE = "collection/{collectionId}?libraryId={libraryId}&source={source}&mediaScope={mediaScope}" } } @@ -211,6 +241,8 @@ sealed class Route(val route: String) { val resumePositionSeconds: Double? = null, val roomId: String? = null, val libraryId: Int? = null, + /** The running shuffle this item is a pick of; picks play from the beginning. */ + val shuffleId: String? = null, ) : Route( buildString { append("player/${contentId.routeEncode()}") @@ -225,6 +257,7 @@ sealed class Route(val route: String) { VideoPlayerRouteArgs.encodeResumePosition(resumePositionSeconds) ?.let { "${VideoPlayerRouteArgs.RESUME_POSITION}=$it" }, roomId?.takeIf { it.isNotBlank() }?.let { "roomId=${Uri.encode(it)}" }, + shuffleId?.takeIf { it.isNotBlank() }?.let { "shuffleId=${Uri.encode(it)}" }, ) if (queryParams.isNotEmpty()) { append("?") @@ -234,14 +267,40 @@ sealed class Route(val route: String) { ) { companion object { const val ROUTE = - "player/{contentId}?libraryId={libraryId}&fileId={fileId}&quality={quality}&audioTrackIndex={audioTrackIndex}&subtitleTrackIndex={subtitleTrackIndex}&resumePosition={resumePosition}&roomId={roomId}" + "player/{contentId}?libraryId={libraryId}&fileId={fileId}&quality={quality}&audioTrackIndex={audioTrackIndex}&subtitleTrackIndex={subtitleTrackIndex}&resumePosition={resumePosition}&roomId={roomId}&shuffleId={shuffleId}" + } + } + + // --- Watch Party (synchronized playback rooms) --- + + /** + * The Watch Party hub. [invite] and [host] are opaque handoff ids for a + * pending invitation or item held in memory by `WatchPartyHandoff`; the + * join token itself never appears in a route. + */ + data class WatchPartyHub(val invite: String? = null, val host: String? = null) : Route( + buildString { + append("watch_party") + val params = listOfNotNull( + invite?.takeIf { it.isNotBlank() }?.let { "$ARG_INVITE=${Uri.encode(it)}" }, + host?.takeIf { it.isNotBlank() }?.let { "$ARG_HOST=${Uri.encode(it)}" }, + ) + if (params.isNotEmpty()) { + append("?") + append(params.joinToString("&")) + } + }, + ) { + companion object { + const val ARG_INVITE = "invite" + const val ARG_HOST = "host" + const val ROUTE = "watch_party?$ARG_INVITE={$ARG_INVITE}&$ARG_HOST={$ARG_HOST}" } } - // --- Watch Together (synchronized playback rooms) --- - data class WatchTogetherLobby(val roomId: String) : Route("watch_together/${Uri.encode(roomId)}") { + data class WatchPartyLobby(val roomId: String) : Route("watch_party/lobby/${Uri.encode(roomId)}") { companion object { - const val ROUTE = "watch_together/{roomId}" + const val ROUTE = "watch_party/lobby/{roomId}" const val ARG_ROOM_ID = "roomId" } } @@ -309,9 +368,11 @@ sealed class Route(val route: String) { fun libraryCollectionDetailRoute( collection: LibraryCollection, libraryId: Int, + mediaScope: String? = null, ): String = Route.CollectionDetail( collectionId = collection.id, libraryId = libraryId, + mediaScope = mediaScope, source = if (collection.kind == "user_collections") { "user_collection" } else { diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/MainScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/MainScreen.kt index 55297449a7..e349010c7b 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/MainScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/MainScreen.kt @@ -34,7 +34,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController -import org.prairieserver.prairie.model.profile.ActiveProfileStore import org.prairieserver.prairie.android.ui.components.LocalLiveTvMenuAction import org.prairieserver.prairie.android.ui.components.MainAppHeaderBodyHeight import org.prairieserver.prairie.android.ui.components.MainAppTopBar @@ -66,9 +65,10 @@ import org.prairieserver.prairie.android.ui.screens.recommendations.ForYouList import org.prairieserver.prairie.android.ui.screens.recommendations.RecommendationsScreen import org.prairieserver.prairie.viewmodel.RecommendationsViewModel import org.prairieserver.prairie.android.ui.screens.recommendations.headerTitle -import org.prairieserver.prairie.android.ui.screens.watchtogether.WatchTogetherMenuEntrySheet +import org.prairieserver.prairie.android.ui.screens.watchparty.WatchPartySoloGuardDialog +import org.prairieserver.prairie.android.ui.screens.watchparty.rememberWatchPartySoloGuard import org.prairieserver.prairie.cast.PrairieCastPlaybackRequest -import org.prairieserver.prairie.model.feature.CLIENT_WATCH_TOGETHER_SURFACE_ENABLED +import org.prairieserver.prairie.model.feature.WatchPartyExposure import org.prairieserver.prairie.model.navigation.MediaMode import org.prairieserver.prairie.model.navigation.MediaModeCapabilities import org.prairieserver.prairie.model.navigation.mobileMediaModeCapabilities @@ -77,17 +77,17 @@ import org.prairieserver.prairie.model.feature.LiveTvFeatureStore import org.prairieserver.prairie.model.feature.RequestsFeatureStore import org.prairieserver.prairie.common.network.ServerReachabilityMonitor import org.prairieserver.prairie.common.network.ServerReachabilityStatus -import org.prairieserver.prairie.common.settings.CardPresentationStore -import org.prairieserver.prairie.common.settings.OverlayPrefsStore import org.prairieserver.prairie.android.ui.theme.prairiePageBackdrop import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.ServerRegistry -import org.prairieserver.prairie.repository.AuthRepository import org.prairieserver.prairie.repository.PersonalDataRepository import org.prairieserver.prairie.viewmodel.HomeViewModel import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.common.ui.OnViewerAccessChanged +import org.prairieserver.prairie.common.ui.rememberViewerAccessKey +import org.prairieserver.prairie.network.AccessChangeSignals /** * Main scaffold that hosts the bottom navigation bar and tab content. @@ -104,9 +104,12 @@ fun MainScreen( val siloCastController: PrairieCastController = koinInject() val siloCastState by siloCastController.state.collectAsState() var showPrairieCastTargetPicker by rememberSaveable { mutableStateOf(false) } - var showWatchTogetherEntry by rememberSaveable { mutableStateOf(false) } + val watchPartyExposure: WatchPartyExposure = koinInject() + val watchPartyEnabled by watchPartyExposure.enabled.collectAsState() + // D5: quick play from these tabs asks to leave a Watch Party first. + val soloGuard = rememberWatchPartySoloGuard() - fun playVideo(contentId: String, fileId: Int? = null, resumePositionSeconds: Double? = null) { + fun startVideo(contentId: String, fileId: Int?, resumePositionSeconds: Double?) { val launchedRemotely = siloCastController.launchOnConnectedTarget( PrairieCastPlaybackRequest( contentId = contentId, @@ -127,6 +130,10 @@ fun MainScreen( ) } } + + fun playVideo(contentId: String, fileId: Int? = null, resumePositionSeconds: Double? = null) = + soloGuard.run { startVideo(contentId, fileId, resumePositionSeconds) } + val librariesViewModel = if (currentTab == Tab.Libraries) { koinViewModel() } else { @@ -155,15 +162,13 @@ fun MainScreen( val downloadsRepository: org.prairieserver.prairie.repository.DownloadsRepository = koinInject() val downloadStorage: org.prairieserver.prairie.common.downloads.DownloadStorage = koinInject() val serverRegistry: ServerRegistry = koinInject() - val authRepository: AuthRepository = koinInject() val reachabilityMonitor: ServerReachabilityMonitor = koinInject() val requestsFeatureStore: RequestsFeatureStore = koinInject() val liveTvFeatureStore: LiveTvFeatureStore = koinInject() val metadataAiFeatureStore: MetadataAiFeatureStore = koinInject() - val overlayPrefsStore: OverlayPrefsStore = koinInject() - val activeProfileStore: ActiveProfileStore = koinInject() - val cardPresentationStore: CardPresentationStore = koinInject() - val seekIntervalStore: org.prairieserver.prairie.common.settings.SeekIntervalStore = koinInject() + val shuffleFeatureStore: org.prairieserver.prairie.model.feature.ShuffleFeatureStore = koinInject() + val signOutTeardown: org.prairieserver.prairie.android.auth.SignOutTeardown = koinInject() + val switchProfile = org.prairieserver.prairie.android.ui.navigation.LocalProfileSwitch.current val reachabilityState by reachabilityMonitor.state.collectAsState() val requestsEnabled by requestsFeatureStore.isEnabled.collectAsState() val liveTvEnabled by liveTvFeatureStore.isEnabled.collectAsState() @@ -175,6 +180,15 @@ fun MainScreen( LaunchedEffect(activeEntry?.id, activeEntry?.profileId) { headerViewModel.refresh() } + // An access change can add or remove whole libraries: re-derive the tabs + // and reload the Libraries hub under the new policy. The hub's ViewModel + // outlives tab switches and pushed routes, so its cursor catches up on a + // change reported while another tab or route was showing. + val accessChangeSignals: AccessChangeSignals = koinInject() + val viewerAccessKey = rememberViewerAccessKey(accessChangeSignals) + librariesViewModel?.let { viewModel -> + OnViewerAccessChanged(accessChangeSignals, viewModel.accessChanges) { viewModel.refreshAfterAccessChange() } + } val mediaCapabilities by produceState( initialValue = MediaModeCapabilities( listOf( @@ -184,6 +198,7 @@ fun MainScreen( ), ), personalDataRepository, + viewerAccessKey, ) { value = when (val result = personalDataRepository.listUserLibraries()) { is ApiResult.Success -> result.data.mobileMediaModeCapabilities() @@ -282,21 +297,18 @@ fun MainScreen( liveTvFeatureStore.refresh() metadataAiFeatureStore.reset() metadataAiFeatureStore.refresh() + shuffleFeatureStore.reset() + shuffleFeatureStore.refresh() } fun signOutFromProfileMenu() { reachabilityScope.launch { - authRepository.logout() - requestsFeatureStore.reset() + // Prairie: Live TV's gate is not part of SignOutTeardown. liveTvFeatureStore.reset() - metadataAiFeatureStore.reset() - // Per-profile card caches, same teardown the Settings sign-out - // does — otherwise the next user's shell renders (and can write - // back) the previous profile's overlays and card presentation. - overlayPrefsStore.clear() - activeProfileStore.reset() - cardPresentationStore.clear() - seekIntervalStore.clear() + // Same teardown as the Settings sign-out: otherwise the next + // user's shell renders (and can write back) the previous + // profile's overlays and card presentation. + signOutTeardown.signOut() navController.navigate(Route.Login.route) { popUpTo(0) { inclusive = true } launchSingleTop = true @@ -305,20 +317,12 @@ fun MainScreen( } /** - * Profile-menu "Switch Profile". Overlays and card presentation are cached - * per profile and the app never backgrounds during an in-app switch, so - * drop them here or the next profile keeps rendering — and writing back — - * the previous one's values. Navigate first: clearing while the shell is - * still composed repaints it with default cards behind the picker (the - * TV shell's switch-profile path takes the same order). + * Profile-menu "Switch Profile": pushes pending settings while this + * profile is still active, then leaves the shell and drops the per-profile + * caches (see [org.prairieserver.prairie.android.auth.ProfileSwitchTeardown]). + * Runs in the navigation host's scope ([org.prairieserver.prairie.android.ui.navigation.LocalProfileSwitch]). */ - fun switchProfileFromMenu() { - navController.navigate(Route.ProfileSelection.route) - overlayPrefsStore.clear() - activeProfileStore.reset() - cardPresentationStore.clear() - seekIntervalStore.clear() - } + fun switchProfileFromMenu() = switchProfile() val requestsMenuAction: (() -> Unit)? = if (requestsEnabled) { { navController.navigate(Route.Requests.route) } } else { @@ -329,9 +333,9 @@ fun MainScreen( } else { null } - val watchTogetherMenuAction: (() -> Unit)? = - if (CLIENT_WATCH_TOGETHER_SURFACE_ENABLED) { - { showWatchTogetherEntry = true } + val watchPartyMenuAction: (() -> Unit)? = + if (watchPartyEnabled) { + { navController.navigate(Route.WatchPartyHub().route) { launchSingleTop = true } } } else { null } @@ -443,7 +447,7 @@ fun MainScreen( onRemoteDisconnectClick = { siloCastController.disconnect() }, isRemoteControlActive = siloCastState.hasActiveSession, onRequestsClick = requestsMenuAction, - onWatchTogetherClick = watchTogetherMenuAction, + onWatchPartyClick = watchPartyMenuAction, onSettingsClick = { navController.navigate(Route.Settings.route) }, onSwitchProfileClick = ::switchProfileFromMenu, onSwitchServerClick = { @@ -453,13 +457,22 @@ fun MainScreen( ) } Tab.Libraries -> { + val shuffleLauncher = org.prairieserver.prairie.android.ui.screens.shuffle.rememberShuffleLauncher { shuffle -> + navController.navigate( + org.prairieserver.prairie.android.ui.screens.shuffle.shufflePlayerRoute( + shuffle, + libraryId = shuffle.scope.id.toIntOrNull(), + ), + ) + } LibrariesScreen( + shuffleLauncher = shuffleLauncher, onItemClick = { contentId, libraryId -> navController.navigate(Route.ItemDetail(contentId, libraryId = libraryId).route) }, - onCollectionClick = { collection, libraryId -> + onCollectionClick = { collection, libraryId, mediaScope -> navController.navigate( - libraryCollectionDetailRoute(collection, libraryId), + libraryCollectionDetailRoute(collection, libraryId, mediaScope), ) }, viewModel = requireNotNull(librariesViewModel), @@ -467,7 +480,7 @@ fun MainScreen( onLibrarySelectorClick = { showLibrarySelector = true }, onSearchClick = { navController.navigate(Route.Search().route) }, onRequestsClick = requestsMenuAction, - onWatchTogetherClick = watchTogetherMenuAction, + onWatchPartyClick = watchPartyMenuAction, onSettingsClick = { navController.navigate(Route.Settings.route) }, onSwitchProfileClick = ::switchProfileFromMenu, onSwitchServerClick = { @@ -500,7 +513,7 @@ fun MainScreen( activeProfile = headerState.activeProfile, onSearchClick = { navController.navigate(Route.Search().route) }, onRequestsClick = requestsMenuAction, - onWatchTogetherClick = watchTogetherMenuAction, + onWatchPartyClick = watchPartyMenuAction, onSettingsClick = { navController.navigate(Route.Settings.route) }, onSwitchProfileClick = ::switchProfileFromMenu, onSwitchServerClick = { @@ -520,19 +533,21 @@ fun MainScreen( // audiobook UI + offline resume; everything else uses // the video player's offline-first tryLocalPlayback. onItemClick = { item -> - if (item.mediaType == org.prairieserver.prairie.model.download.DownloadMediaType.Audiobook) { - navController.navigate( - Route.AudiobookPlayer(item.contentId, item.fileId).route, - ) - } else { - // Downloads are explicitly local/offline; - // bypass playVideo's active-cast redirect. - navController.navigate( - Route.Player( - contentId = item.contentId, - fileId = item.fileId, - ).route, - ) + soloGuard.run { + if (item.mediaType == org.prairieserver.prairie.model.download.DownloadMediaType.Audiobook) { + navController.navigate( + Route.AudiobookPlayer(item.contentId, item.fileId).route, + ) + } else { + // Downloads are explicitly local/offline; + // bypass playVideo's active-cast redirect. + navController.navigate( + Route.Player( + contentId = item.contentId, + fileId = item.fileId, + ).route, + ) + } } }, onReadEbook = { contentId, fileId -> @@ -559,7 +574,7 @@ fun MainScreen( hazeState = hazeState, onSearchClick = { navController.navigate(Route.Search().route) }, onRequestsClick = requestsMenuAction, - onWatchTogetherClick = watchTogetherMenuAction, + onWatchPartyClick = watchPartyMenuAction, onSettingsClick = { navController.navigate(Route.Settings.route) }, onSwitchProfileClick = ::switchProfileFromMenu, onSwitchServerClick = { @@ -613,16 +628,7 @@ fun MainScreen( ) } - if (showWatchTogetherEntry) { - WatchTogetherMenuEntrySheet( - onNavigate = { route -> - navController.navigate(route) { - launchSingleTop = true - } - }, - onDismiss = { showWatchTogetherEntry = false }, - ) - } + WatchPartySoloGuardDialog(soloGuard) } } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/audiobook/AudiobookDetailContent.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/audiobook/AudiobookDetailContent.kt index f8024f41ed..fbc1cc7d63 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/audiobook/AudiobookDetailContent.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/audiobook/AudiobookDetailContent.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.prairieserver.prairie.android.ui.screens.detail.CircleActionButton import org.prairieserver.prairie.audiobook.buildAudiobookTimeline +import org.prairieserver.prairie.common.ui.components.DefaultArtworkKind import org.prairieserver.prairie.common.ui.components.ThumbhashImage import org.prairieserver.prairie.model.audiobook.AudiobookNarration import org.prairieserver.prairie.model.catalog.FileVersion @@ -191,6 +192,7 @@ fun AudiobookDetailContent( modifier = Modifier .size(AudiobookCoverSizeDp.dp) .clip(RoundedCornerShape(12.dp)), + defaultArtwork = DefaultArtworkKind.Audiobook, ) Text( text = detail.title, diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/DevicePairingScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/DevicePairingScreen.kt index 7b4fda0eff..82ab02b8dd 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/DevicePairingScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/DevicePairingScreen.kt @@ -1,6 +1,12 @@ package org.prairieserver.prairie.android.ui.screens.auth +import androidx.compose.material.icons.rounded.Dns +import org.prairieserver.prairie.common.ui.marquee.ServerBranding +import org.prairieserver.prairie.android.ui.components.PrairieMenuItem +import org.prairieserver.prairie.android.ui.components.PrairieDropdownMenu +import android.text.format.DateUtils import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -8,206 +14,644 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.Info import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import org.prairieserver.prairie.viewmodel.DevicePairingViewModel +import androidx.compose.ui.unit.sp +import androidx.compose.runtime.produceState +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.android.ui.components.PrairieConfirmDialog +import org.prairieserver.prairie.network.ServerRegistry +import org.prairieserver.prairie.repository.ExternalSignInRepository import org.koin.core.parameter.parametersOf import androidx.compose.material.icons.automirrored.filled.Login -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import org.prairieserver.prairie.android.R +import org.prairieserver.prairie.model.auth.DeviceCodeFormat +import org.prairieserver.prairie.model.auth.DeviceLoginLookupResponse +import org.prairieserver.prairie.model.auth.SignInOptions +import org.prairieserver.prairie.util.parseRfc3339ToEpochMillis +import org.prairieserver.prairie.viewmodel.DeviceFollowOutcome +import org.prairieserver.prairie.viewmodel.DevicePairingError +import org.prairieserver.prairie.viewmodel.DevicePairingUiState +import org.prairieserver.prairie.viewmodel.DevicePairingViewModel +/** + * "Sign in a TV": approve a TV's device sign-in from this phone, by typing + * the code the TV shows or from the web page's `prairie://device` link. Codes are + * unique only per server, so the code is looked up on one chosen server (the + * active one preselected), never on every saved server. + */ @Composable fun DevicePairingScreen( token: String?, code: String?, onDone: () -> Unit, - onSignIn: () -> Unit, + /** + * "Sign in" after the chosen server answered 401: sign in on [serverId]'s + * server (the one the code was looked up on), keeping [code] for afterwards. + */ + onSignIn: (serverId: String?, code: String?) -> Unit, + serverId: String? = null, + /** + * The link named [serverId]: approve on that saved server with its own + * credentials, without switching the active server, and offer no other. + */ + lockServer: Boolean = false, + /** + * "Not you?": sign out of [serverId]'s account and return to sign-in, + * keeping [code] for afterwards. `startSignIn` is true when the provider + * is asked to offer another account, so the login screen starts that + * sign-in by itself. + */ + onSwitchAccount: (serverId: String?, code: String?, startSignIn: Boolean) -> Unit, viewModel: DevicePairingViewModel = koinViewModel( - parameters = { parametersOf(token to code) }, + parameters = { parametersOf(token to code, serverId, lockServer) }, ), ) { val state by viewModel.uiState.collectAsState() + // "Not you?" signs out of Prairie only. Its wording follows what the next + // sign-in offers (prairie-apple `TVApprovalAccountSwitch`). + val externalSignIn: ExternalSignInRepository = koinInject() + val serverRegistry: ServerRegistry = koinInject() + val activeEntry by serverRegistry.activeEntry.collectAsState() + val switchServerUrl = state.selectedServer?.url ?: activeEntry?.url + val accountSwitch by produceState(initialValue = ApprovalAccountSwitch.SignOut, switchServerUrl) { + value = ApprovalAccountSwitch.SignOut + value = ApprovalAccountSwitch.of(switchServerUrl?.let { externalSignIn.signInOptions(it) }) + } AuthStage { Column( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) - .padding(horizontal = 24.dp), + .padding(horizontal = 24.dp, vertical = 24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { PrairieLogo() - Spacer(modifier = Modifier.height(18.dp)) - Text( - text = "Quick Connect", - style = MaterialTheme.typography.headlineMedium, - color = AuthColors.OnBackground, - fontWeight = FontWeight.SemiBold, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = state.lookup?.deviceName?.let { deviceName -> - "Approve sign-in for $deviceName" - } ?: "Approve sign-in on another screen.", - style = MaterialTheme.typography.bodyMedium, - color = AuthColors.OnSurfaceVariant, - textAlign = TextAlign.Center, - ) - - Spacer(modifier = Modifier.height(24.dp)) - - OutlinedTextField( - value = state.lookup?.userCode ?: state.code, - onValueChange = viewModel::onCodeChanged, - enabled = state.token.isNullOrBlank() && state.completedStatus == null, - label = { Text("Code") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - - state.lookup?.let { lookup -> - Spacer(modifier = Modifier.height(18.dp)) - PairingDetailRow(label = "Match", value = lookup.matchCode.orEmpty()) - PairingDetailRow(label = "Device", value = lookup.deviceName.orEmpty()) - PairingDetailRow(label = "Platform", value = lookup.devicePlatform.orEmpty()) - lookup.ipAddressHint?.takeIf { it.isNotBlank() }?.let { - PairingDetailRow(label = "IP", value = it) - } + val lookup = state.lookup + when { + state.completedStatus != null -> DecisionResult(state = state, onDone = onDone) + lookup != null -> ApprovalCard( + lookup = lookup, + state = state, + onApprove = viewModel::approve, + onDeny = viewModel::deny, + onRetryAccount = viewModel::retryAccount, + accountSwitch = accountSwitch, + onSwitchAccount = { + val (server, typed) = state.signInRequest() + onSwitchAccount(server, typed, accountSwitch == ApprovalAccountSwitch.ChooseAccount) + }, + ) + else -> CodeEntry( + state = state, + onCodeChanged = viewModel::onCodeChanged, + onLookup = viewModel::lookup, + onSelectServer = viewModel::selectServer, + ) } state.error?.let { error -> Spacer(modifier = Modifier.height(18.dp)) - AuthErrorBanner(message = error) - if (error.startsWith("Sign in")) { + AuthErrorBanner( + message = error.message(), + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + ) + if (error == DevicePairingError.SignInFirst) { Spacer(modifier = Modifier.height(12.dp)) - OutlinedButton(onClick = onSignIn, modifier = Modifier.fillMaxWidth()) { + OutlinedButton( + onClick = { + val (server, typed) = state.signInRequest() + onSignIn(server, typed) + }, + modifier = Modifier.fillMaxWidth(), + ) { Icon( imageVector = Icons.AutoMirrored.Filled.Login, contentDescription = null, modifier = Modifier.size(18.dp), ) Spacer(modifier = Modifier.width(8.dp)) - Text("Sign In") + Text(stringResource(R.string.sign_in_tv_sign_in_first)) + } + } + if (state.notFound) { + Spacer(modifier = Modifier.height(8.dp)) + state.otherServers.forEach { server -> + TextButton( + onClick = { viewModel.selectServer(server.id) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.sign_in_tv_try_server, server.displayName)) + } } } } + } + } +} - state.completedStatus?.let { status -> - Spacer(modifier = Modifier.height(18.dp)) - Text( - text = if (status == "approved") "Device approved." else "Device denied.", - style = MaterialTheme.typography.bodyLarge, - color = AuthColors.OnBackground, - textAlign = TextAlign.Center, - ) - } +/** + * What "Sign in" and "Switch account" carry to the sign-in flow: the server + * the code was looked up on (the chooser's pick, not necessarily the active + * server) and the typed code, so the person comes back to it afterwards. + */ +internal fun DevicePairingUiState.signInRequest(): Pair = + selectedServerId to code.takeIf { it.isNotBlank() } - Spacer(modifier = Modifier.height(24.dp)) +@Composable +private fun CodeEntry( + state: DevicePairingUiState, + onCodeChanged: (String) -> Unit, + onLookup: () -> Unit, + onSelectServer: (String) -> Unit, +) { + Text( + // Prairie names this flow "Quick Connect" (Jellyfin parity), not "Sign in a TV". + text = "Quick Connect", + style = MaterialTheme.typography.headlineMedium, + color = AuthColors.OnBackground, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.sign_in_tv_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = AuthColors.OnSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(24.dp)) - if (state.completedStatus == null) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - OutlinedButton( - onClick = viewModel::lookup, - enabled = !state.isLoading && !state.isSubmitting, - modifier = Modifier.weight(1f), - ) { - if (state.isLoading) { - CircularProgressIndicator(modifier = Modifier.padding(end = 8.dp)) - } else { - Icon(Icons.Default.Refresh, contentDescription = null) - } - Text("Check") - } - OutlinedButton( - onClick = viewModel::deny, - enabled = state.canDecide, - modifier = Modifier.weight(1f), - ) { - Icon(Icons.Default.Close, contentDescription = null) - Text("Deny") - } - } + if (state.canChooseServer) { + ServerChooser(state = state, onSelectServer = onSelectServer) + Spacer(modifier = Modifier.height(16.dp)) + } - Spacer(modifier = Modifier.height(12.dp)) + // A token link carries no code, so Continue retries its lookup. + val canSubmit = (!state.token.isNullOrBlank() || state.code.length == DeviceCodeFormat.LENGTH) && !state.isLoading + OutlinedTextField( + value = state.code, + onValueChange = onCodeChanged, + enabled = state.token.isNullOrBlank(), + label = { Text(stringResource(R.string.sign_in_tv_code_label)) }, + placeholder = { Text("4821 7730") }, + singleLine = true, + textStyle = MaterialTheme.typography.headlineSmall.copy( + fontFamily = FontFamily.Monospace, + letterSpacing = 2.sp, + ), + visualTransformation = GroupedCodeTransformation, + // A text keyboard, not a keypad: current servers issue digits, but + // older ones issue letters and digits (DeviceCodeFormat). + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Characters, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Ascii, + imeAction = ImeAction.Go, + ), + keyboardActions = KeyboardActions(onGo = { if (canSubmit) onLookup() }), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(20.dp)) + Button( + onClick = onLookup, + enabled = canSubmit, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = AuthColors.Primary), + ) { + if (state.isLoading) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Text(stringResource(R.string.sign_in_tv_continue)) + } + } +} - Button( - onClick = viewModel::approve, - enabled = state.canDecide, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors(containerColor = AuthColors.Primary), - ) { - Icon(Icons.Default.Check, contentDescription = null) - Text("Approve") +@Composable +private fun ServerChooser( + state: DevicePairingUiState, + onSelectServer: (String) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + val selected = state.selectedServer + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.sign_in_tv_server_label), + style = MaterialTheme.typography.labelMedium, + color = AuthColors.OnSurfaceVariant, + ) + Box { + OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.weight(1f)) { + Text(text = selected?.displayName.orEmpty(), color = AuthColors.OnBackground) + // Saved servers often share a name ("Prairie"); the address + // is what tells them apart. + selected?.let { server -> + Text( + text = ServerBranding.hostLabel(server.url), + style = MaterialTheme.typography.bodySmall, + color = AuthColors.OnSurfaceVariant, + ) + } } - } else { - Button( - onClick = onDone, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors(containerColor = AuthColors.Primary), - ) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(18.dp), + Icon(Icons.Default.ArrowDropDown, contentDescription = null, tint = AuthColors.OnBackground) + } + PrairieDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + state.servers.forEach { server -> + PrairieMenuItem( + label = server.displayName, + detail = ServerBranding.hostLabel(server.url), + icon = Icons.Rounded.Dns, + checked = server.id == selected?.id, + onClick = { + expanded = false + onSelectServer(server.id) + }, ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Done") } } } + Text( + text = stringResource(R.string.sign_in_tv_server_hint), + style = MaterialTheme.typography.bodySmall, + color = AuthColors.OnSurfaceVariant, + ) } } +/** + * The approval card: which TV, the code to check, which server and account, + * what approving grants, and the device-code phishing warning (RFC 8628 §5.4). + */ @Composable -private fun PairingDetailRow(label: String, value: String) { - if (value.isBlank()) return - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 3.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, +private fun ApprovalCard( + lookup: DeviceLoginLookupResponse, + state: DevicePairingUiState, + onApprove: () -> Unit, + onDeny: () -> Unit, + onRetryAccount: () -> Unit, + accountSwitch: ApprovalAccountSwitch, + onSwitchAccount: () -> Unit, +) { + val deviceName = lookup.deviceName?.takeIf { it.isNotBlank() } + var confirmsSwitchAccount by remember { mutableStateOf(false) } + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = AuthColors.Surface), ) { - Text( - text = label, - style = MaterialTheme.typography.bodyMedium, - color = AuthColors.OnSurfaceVariant, - ) - Text( - text = value, - style = MaterialTheme.typography.bodyMedium, - color = AuthColors.OnBackground, - fontWeight = FontWeight.Medium, - textAlign = TextAlign.End, + Column( + modifier = Modifier.padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = deviceName?.let { stringResource(R.string.sign_in_tv_card_title, it) } + ?: stringResource(R.string.sign_in_tv_card_title_generic), + style = MaterialTheme.typography.titleLarge, + color = AuthColors.OnBackground, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + ) + // "Android TV · requested just now", as on the web approval page. + val platform = platformLabel(lookup.devicePlatform) + val requested = requestedLabel(lookup.requestedAt) + val deviceLine = when { + platform != null && requested != null -> + stringResource(R.string.sign_in_tv_platform_requested, platform, requested) + platform != null -> platform + requested != null -> stringResource(R.string.sign_in_tv_requested, requested) + else -> null + } + deviceLine?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = AuthColors.OnSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.sign_in_tv_check_code), + style = MaterialTheme.typography.bodyMedium, + color = AuthColors.OnSurfaceVariant, + ) + val code = lookup.userCode?.takeIf { it.isNotBlank() } ?: state.code + val spoken = DeviceCodeFormat.spoken(code) + Text( + text = DeviceCodeFormat.display(code), + style = MaterialTheme.typography.headlineLarge.copy(fontFamily = FontFamily.Monospace), + color = AuthColors.OnBackground, + fontWeight = FontWeight.Bold, + letterSpacing = 3.sp, + modifier = Modifier.semantics(mergeDescendants = true) { + contentDescription = spoken + }, + ) + Spacer(modifier = Modifier.height(4.dp)) + // Name and host, so a same-code collision or a lookup on the wrong + // server is visible before anyone approves. + val serverName = lookup.serverName?.takeIf { it.isNotBlank() } + ?: state.selectedServer?.displayName + val serverHost = state.selectedServer?.url?.let(DeviceCodeFormat::host) + val serverLabel = tvSignInServerLabel(serverName, serverHost) + val serverLine = if (state.accountUnconfirmed) { + null + } else { + tvSignInLine(serverName, serverHost, state.accountName) + } + serverLine?.let { + Text(text = it, style = MaterialTheme.typography.bodyMedium, color = AuthColors.OnBackground, textAlign = TextAlign.Center) + } + // A saved server other than the active one whose account couldn't + // be read: approving would sign the TV in to an account nobody saw. + // A provider outage says so rather than blaming the account. + if (state.accountUnconfirmed) { + Text( + text = state.accountError?.message() ?: stringResource( + R.string.sign_in_tv_account_unconfirmed, + serverLabel ?: state.selectedServer?.displayName.orEmpty(), + ), + style = MaterialTheme.typography.bodyMedium, + color = Color(0xFFFFC107), + textAlign = TextAlign.Center, + ) + TextButton(onClick = onRetryAccount) { + Text(stringResource(R.string.sign_in_tv_account_retry)) + } + } + TextButton(onClick = { confirmsSwitchAccount = true }, enabled = !state.isSubmitting) { + Text( + stringResource( + if (accountSwitch == ApprovalAccountSwitch.SignOut) { + R.string.sign_in_tv_sign_out_account + } else { + R.string.sign_in_tv_switch_account + }, + ), + ) + } + if (confirmsSwitchAccount) { + // Only an account choice the provider is asked for is promised. + val choosing = accountSwitch == ApprovalAccountSwitch.ChooseAccount + val switchServerName = serverName ?: state.selectedServer?.displayName + ?: stringResource(R.string.sign_in_tv_switch_this_server) + PrairieConfirmDialog( + title = stringResource(if (choosing) R.string.sign_in_tv_switch_title else R.string.sign_in_tv_sign_out_title), + body = stringResource( + if (choosing) R.string.sign_in_tv_switch_body else R.string.sign_in_tv_sign_out_body, + switchServerName, + ), + confirmLabel = stringResource( + if (choosing) R.string.sign_in_tv_switch_confirm else R.string.sign_in_tv_sign_out_confirm, + ), + onConfirm = { + confirmsSwitchAccount = false + onSwitchAccount() + }, + onDismiss = { confirmsSwitchAccount = false }, + ) + } + Text( + text = stringResource(R.string.sign_in_tv_profiles_note), + style = MaterialTheme.typography.bodySmall, + color = AuthColors.OnSurfaceVariant, + textAlign = TextAlign.Center, + ) + Text( + text = stringResource(R.string.sign_in_tv_warning), + style = MaterialTheme.typography.bodySmall, + color = Color(0xFFFFC107), + textAlign = TextAlign.Center, + ) + lookup.ipAddressHint?.takeIf { it.isNotBlank() }?.let { network -> + Text( + text = stringResource(R.string.sign_in_tv_network, network), + style = MaterialTheme.typography.bodySmall, + color = AuthColors.OnSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = onApprove, + enabled = state.canApprove, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = AuthColors.Primary), + ) { + if (state.isSubmitting) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Text(stringResource(R.string.sign_in_tv_approve)) + } + } + TextButton(onClick = onDeny, enabled = state.canDecide, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.sign_in_tv_not_now), color = AuthColors.OnSurfaceVariant) + } + } + } +} + +@Composable +private fun DecisionResult(state: DevicePairingUiState, onDone: () -> Unit) { + val approved = state.completedStatus == "approved" + val outcome = state.followOutcome + val succeeded = approved && (outcome == null || outcome == DeviceFollowOutcome.SignedIn) + Icon( + imageVector = if (succeeded) Icons.Outlined.CheckCircle else Icons.Outlined.Info, + contentDescription = null, + tint = if (succeeded) Color(0xFF34C759) else AuthColors.OnSurfaceVariant, + modifier = Modifier.size(48.dp), + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource( + when { + !approved -> R.string.sign_in_tv_declined + outcome == null -> R.string.sign_in_tv_signing_in + else -> when (outcome) { + DeviceFollowOutcome.SignedIn -> R.string.sign_in_tv_signed_in + DeviceFollowOutcome.Canceled -> R.string.sign_in_tv_follow_canceled + DeviceFollowOutcome.Expired -> R.string.sign_in_tv_follow_expired + DeviceFollowOutcome.Denied -> R.string.sign_in_tv_follow_denied + DeviceFollowOutcome.TimedOut -> R.string.sign_in_tv_follow_timed_out + } + }, + ), + style = MaterialTheme.typography.titleMedium, + color = AuthColors.OnBackground, + textAlign = TextAlign.Center, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + ) + Spacer(modifier = Modifier.height(24.dp)) + Button( + onClick = onDone, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = AuthColors.Primary), + ) { + Text(stringResource(R.string.sign_in_tv_done)) + } +} + +@Composable +private fun DevicePairingError.message(): String = when (this) { + DevicePairingError.EnterCode -> stringResource(R.string.sign_in_tv_error_enter_code) + DevicePairingError.SignInFirst -> stringResource(R.string.sign_in_tv_error_sign_in_first) + DevicePairingError.NotFound -> stringResource(R.string.sign_in_tv_error_not_found) + is DevicePairingError.NotFoundOn -> stringResource(R.string.sign_in_tv_error_not_found_on, serverName) + DevicePairingError.Expired -> stringResource(R.string.sign_in_tv_error_expired) + DevicePairingError.AlreadySignedIn -> stringResource(R.string.sign_in_tv_error_already_signed_in) + DevicePairingError.Declined -> stringResource(R.string.sign_in_tv_error_declined) + DevicePairingError.Canceled -> stringResource(R.string.sign_in_tv_error_canceled) + DevicePairingError.Network -> stringResource(R.string.sign_in_tv_error_network) + DevicePairingError.ProviderUnavailable -> stringResource(R.string.sign_in_tv_error_provider_unavailable) + is DevicePairingError.Server -> message ?: stringResource(R.string.sign_in_tv_error_generic) +} + +/** "just now" / "5 minutes ago" for a lookup's `requested_at`; null when absent or unreadable. */ +@Composable +private fun requestedLabel(requestedAt: String?): String? { + val at = requestedAt?.let(::parseRfc3339ToEpochMillis) ?: return null + val now = System.currentTimeMillis() + // Clocks differ a little between phone and server: a time "in the future" + // is just now too. + return if (now - at < DateUtils.MINUTE_IN_MILLIS) { + stringResource(R.string.sign_in_tv_requested_just_now) + } else { + DateUtils.getRelativeTimeSpanString(at, now, DateUtils.MINUTE_IN_MILLIS).toString() + } +} + +/** "Android TV" / "Apple TV" for the platform strings TVs send; anything else as sent. */ +@Composable +private fun platformLabel(platform: String?): String? { + val raw = platform?.trim()?.takeIf { it.isNotEmpty() } ?: return null + return when (raw.lowercase().replace("_", "-")) { + "android-tv", "androidtv" -> stringResource(R.string.platform_android_tv) + "tvos", "apple-tv", "appletv" -> stringResource(R.string.platform_apple_tv) + else -> raw + } +} + +/** Shows eight typed characters grouped 4+4 (`4821 7730`) without storing the space. */ +private object GroupedCodeTransformation : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText { + val raw = text.text + if (raw.length <= 4) return TransformedText(text, OffsetMapping.Identity) + val grouped = raw.substring(0, 4) + " " + raw.substring(4) + return TransformedText( + AnnotatedString(grouped), + object : OffsetMapping { + override fun originalToTransformed(offset: Int) = if (offset <= 4) offset else offset + 1 + override fun transformedToOriginal(offset: Int) = if (offset <= 4) offset else offset - 1 + }, ) } } + +/** + * "You'll sign it in to () as .", or "…with your + * account" when the account couldn't be read. The host is left out when it is + * blank or the same as the name. Null when neither is known. Shared by the + * code-entry approval and the nearby-TV card. + */ +@Composable +internal fun tvSignInLine(serverName: String?, serverHost: String?, accountName: String?): String? { + val server = tvSignInServerLabel(serverName, serverHost) ?: return null + return if (accountName != null) { + stringResource(R.string.sign_in_tv_server_account, server, accountName) + } else { + stringResource(R.string.sign_in_tv_server_only, server) + } +} + +/** " ()", the name alone when the host is blank or the same, or null when neither is known. */ +@Composable +internal fun tvSignInServerLabel(serverName: String?, serverHost: String?): String? { + val name = serverName?.takeIf { it.isNotBlank() } + val host = serverHost?.takeIf { it.isNotBlank() } + return when { + name == null -> host + host == null || host.equals(name, ignoreCase = true) -> name + else -> stringResource(R.string.sign_in_tv_server_with_host, name, host) + } +} + +/** + * What "Not you?" on the approval card offers, from the server's sign-in + * options (prairie-apple `TVApprovalAccountSwitch`). + */ +internal enum class ApprovalAccountSwitch { + /** A browser provider that takes `select_account`: it's asked to offer another account. */ + ChooseAccount, + + /** No browser provider: the password form that follows lets the person choose the account. */ + SwitchAccount, + + /** + * A browser provider without `select_account`, or options that couldn't + * be read: the provider may sign the same person straight back in, so + * only a sign-out is promised. + */ + SignOut, + ; + + companion object { + fun of(options: SignInOptions?): ApprovalAccountSwitch = when { + options == null -> SignOut + options.oauthProviders.isEmpty() -> SwitchAccount + options.selectAccount -> ChooseAccount + else -> SignOut + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/DevicePairingWrongServerScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/DevicePairingWrongServerScreen.kt index a59ab12645..677018eecc 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/DevicePairingWrongServerScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/DevicePairingWrongServerScreen.kt @@ -36,7 +36,7 @@ fun DevicePairingWrongServerScreen( ) { DevicePairingNoticeStage( title = "Different server", - body = "This pairing request is for $serverName. Switch to it to continue.", + body = "This TV is signing in to $serverName. Switch to it to continue.", primaryLabel = "Switch to $serverName", onPrimary = onSwitch, onCancel = onCancel, @@ -52,7 +52,7 @@ fun DevicePairingUnknownServerScreen( ) { DevicePairingNoticeStage( title = "Unknown server", - body = "This pairing request is for $origin, which isn't one of your servers. " + + body = "This TV is signing in to $origin, which isn't one of your servers. " + "Add it to continue.", primaryLabel = "Add server", onPrimary = onAddServer, diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/InviteClaimScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/InviteClaimScreen.kt index 8a91edb9cd..6b2699dac2 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/InviteClaimScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/InviteClaimScreen.kt @@ -1,52 +1,48 @@ package org.prairieserver.prairie.android.ui.screens.auth +import org.prairieserver.prairie.android.ui.components.PrairieConfirmDialog import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material3.AlertDialog +import androidx.compose.material.icons.outlined.Email +import androidx.compose.material.icons.outlined.Lock import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import org.koin.compose.viewmodel.koinViewModel -import org.prairieserver.prairie.android.ui.components.aurora.AuroraErrorLabel -import org.prairieserver.prairie.android.ui.components.aurora.AuroraEyebrow -import org.prairieserver.prairie.android.ui.components.aurora.AuroraGhostButton -import org.prairieserver.prairie.android.ui.components.aurora.AuroraPrimaryButton -import org.prairieserver.prairie.android.ui.components.aurora.AuroraScreen -import org.prairieserver.prairie.android.ui.components.aurora.AuroraScrim -import org.prairieserver.prairie.android.ui.components.aurora.AuroraTextField -import org.prairieserver.prairie.android.ui.components.aurora.AuroraVariant -import org.prairieserver.prairie.android.ui.components.aurora.auroraGlass +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeButton +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeButtonKind +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeErrorHaptic +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeErrorText +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeFieldGroup +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeHeadline +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeMetrics +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeSeparator +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeStage +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeTextField +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeWordmark +import org.prairieserver.prairie.common.ui.marquee.MarqueeColors +import org.prairieserver.prairie.common.ui.marquee.MarqueeScrimStyle +import org.prairieserver.prairie.common.ui.marquee.ServerBranding /** * Emailed-invitation claim: the deep link carried the server and token, so - * this screen asks for a password and nothing else. Everything visual - * mirrors SignupScreen's Aurora treatment. + * this screen asks for a password and nothing else. Laid out like the other + * first-run screens. */ @Composable fun InviteClaimScreen( @@ -57,7 +53,6 @@ fun InviteClaimScreen( viewModel: InviteClaimViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() - var showPassword by remember { mutableStateOf(false) } LaunchedEffect(serverUrl, token) { viewModel.load(serverUrl, token) @@ -68,228 +63,136 @@ fun InviteClaimScreen( LaunchedEffect(state.claimSuccess) { if (state.claimSuccess) onClaimComplete() } + MarqueeErrorHaptic(state.error) state.pendingCleartextOrigin?.let { origin -> - AlertDialog( - onDismissRequest = viewModel::onCancelCleartext, - title = { Text("Use unencrypted HTTP?") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text(origin, fontWeight = FontWeight.SemiBold) - Text( - "This connection is not encrypted. Anyone on the network may see or change " + - "traffic, including your new password. Continue only on a network you trust.", - ) - } - }, - confirmButton = { - AuroraPrimaryButton( - label = "Use HTTP", - onClick = viewModel::onConfirmCleartext, - ) - }, - dismissButton = { - AuroraGhostButton( - label = "Cancel", - onClick = viewModel::onCancelCleartext, - ) - }, + PrairieConfirmDialog( + title = "Connect without encryption?", + body = "Your new password will be sent unencrypted to ${ServerBranding.hostLabel(origin)}. " + + "Only do this on a network you trust.", + confirmLabel = "Connect", + destructive = false, + onConfirm = viewModel::onConfirmCleartext, + onDismiss = viewModel::onCancelCleartext, ) } - AuroraScreen(variant = AuroraVariant.SignIn, scrim = AuroraScrim.Soft) { - PrairieLogo() - Spacer(Modifier.height(30.dp)) - + MarqueeStage( + scrim = MarqueeScrimStyle.BottomDeep, + frostStart = 0.40f, + topBar = { MarqueeWordmark() }, + ) { when { state.signInRequiredUsername != null || state.acceptanceUncertain -> { - Text(if (state.signInRequiredUsername != null) "Account created" else "Check sign-in", - color = Color(0xFFF3EFE9), fontSize = 30.sp) - Spacer(Modifier.height(16.dp)) - Text(if (state.signInRequiredUsername != null) - "Sign in to $serverUrl as ${state.signInRequiredUsername} using the password you chose. Use Change server on the sign-in screen if needed." - else state.error.orEmpty(), color = Color.White.copy(alpha = 0.8f)) - Spacer(Modifier.height(24.dp)) - AuroraPrimaryButton(label = "Go to sign in", onClick = onNavigateToLogin, - modifier = Modifier.fillMaxWidth()) + MarqueeHeadline( + title = if (state.signInRequiredUsername != null) "Account created" else "Check sign-in", + lead = if (state.signInRequiredUsername != null) { + "Sign in to $serverUrl as ${state.signInRequiredUsername} using the password you chose. " + + "Use Change server on the sign-in screen if needed." + } else { + state.error.orEmpty() + }, + ) + MarqueeButton("Go to sign in", onClick = onNavigateToLogin, modifier = Modifier.padding(top = 24.dp)) } state.acceptanceUnavailable || state.invitation?.acceptanceAvailable == false -> { - Text("Invitation acceptance is unavailable on this server.", color = Color(0xFFF3EFE9)) - Spacer(Modifier.height(24.dp)) - AuroraGhostButton(label = "Back to sign in", onClick = onNavigateToLogin, fillMaxWidth = true) + MarqueeHeadline(title = "Can't accept here", lead = "Invitation acceptance is unavailable on this server.") + MarqueeButton( + "Back to sign in", + onClick = onNavigateToLogin, + kind = MarqueeButtonKind.Glass, + modifier = Modifier.padding(top = 24.dp), + ) } state.isLoadingInvitation -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Spacer(Modifier.height(60.dp)) - CircularProgressIndicator(color = Color(0xFFF3EFE9)) + Box(Modifier.fillMaxWidth().height(120.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = MarqueeColors.Ink, strokeWidth = 2.dp, modifier = Modifier.size(28.dp)) } } state.lookupFailed -> { - AuroraEyebrow(text = "Invitation", centered = true) - Spacer(Modifier.height(12.dp)) - Text( - text = "Couldn't check the invitation", - fontSize = 30.sp, - fontWeight = FontWeight.SemiBold, - color = Color(0xFFF3EFE9), - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(12.dp)) - Text( - text = "Your invite is probably still fine — we just couldn't check it. " + + MarqueeHeadline( + title = "Couldn't check\nthe invitation", + lead = "Your invite is probably still fine — we just couldn't check it. " + "Make sure you're online and try again.", - fontSize = 15.sp, - color = Color.White.copy(alpha = 0.65f), - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - ) - Spacer(Modifier.height(24.dp)) - AuroraPrimaryButton( - label = "Try again", - onClick = viewModel::onRetryLookup, - modifier = Modifier.fillMaxWidth(), ) - Spacer(Modifier.height(10.dp)) - AuroraGhostButton( - label = "Back to sign in", + MarqueeButton("Try again", onClick = viewModel::onRetryLookup, modifier = Modifier.padding(top = 24.dp)) + MarqueeButton( + "Back to sign in", onClick = onNavigateToLogin, - fillMaxWidth = true, + kind = MarqueeButtonKind.Plain, + modifier = Modifier.padding(top = 6.dp), ) } state.invitationInvalid -> { - AuroraEyebrow(text = "Invitation", centered = true) - Spacer(Modifier.height(12.dp)) - Text( - text = "This invite has expired", - fontSize = 30.sp, - fontWeight = FontWeight.SemiBold, - color = Color(0xFFF3EFE9), - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(12.dp)) - Text( - text = "The link may have been used already, revoked, or simply expired. " + + MarqueeHeadline( + title = "This invite\nhas expired", + lead = "The link may have been used already, revoked, or simply expired. " + "Ask whoever invited you to send a fresh one.", - fontSize = 15.sp, - color = Color.White.copy(alpha = 0.65f), - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), ) - Spacer(Modifier.height(24.dp)) - AuroraGhostButton( - label = "Back to sign in", + MarqueeButton( + "Back to sign in", onClick = onNavigateToLogin, - fillMaxWidth = true, + kind = MarqueeButtonKind.Glass, + modifier = Modifier.padding(top = 24.dp), ) } else -> { - val invitation = state.invitation ?: return@AuroraScreen - invitation.inviterName?.let { - AuroraEyebrow(text = "Invited by $it", centered = true) - Spacer(Modifier.height(12.dp)) - } - Text( - text = "Welcome to ${invitation.serverName}", - fontSize = 30.sp, - fontWeight = FontWeight.SemiBold, - color = Color(0xFFF3EFE9), - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(8.dp)) - Text( - text = "Choose a password and you're in. You'll sign in with your email address.", - fontSize = 15.sp, - color = Color.White.copy(alpha = 0.65f), - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), + val invitation = state.invitation ?: return@MarqueeStage + MarqueeHeadline( + title = "Welcome to\n${invitation.serverName}", + lead = listOfNotNull( + invitation.inviterName?.let { "$it invited you." }, + "Choose a password and you're in. You'll sign in with your email address.", + ).joinToString(" "), ) - Spacer(Modifier.height(24.dp)) - - Column( - modifier = Modifier - .fillMaxWidth() - .auroraGlass(cornerRadius = 24.dp, emphasized = true) - .padding(22.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { + MarqueeFieldGroup(isError = state.error != null, modifier = Modifier.padding(top = 24.dp)) { // The address is fixed by the invitation; show it, don't edit it. - Column { - Text( - text = "EMAIL", - fontSize = 11.sp, - fontWeight = FontWeight.SemiBold, - color = Color.White.copy(alpha = 0.55f), - ) - Spacer(Modifier.height(8.dp)) + Row( + Modifier.fillMaxWidth().height(MarqueeMetrics.FieldHeight).padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Outlined.Email, contentDescription = null, tint = MarqueeColors.InkTertiary, modifier = Modifier.size(20.dp)) Text( - text = invitation.email, - fontSize = 16.sp, - color = Color.White.copy(alpha = 0.8f), + invitation.email, + color = MarqueeColors.InkSecondary, + fontSize = MarqueeMetrics.FieldFont, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } - AuroraTextField( - label = "Password", + MarqueeSeparator() + MarqueeTextField( value = state.password, onValueChange = viewModel::onPasswordChanged, - placeholder = "••••••", - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Next, - visualTransformation = if (showPassword) { - VisualTransformation.None - } else { - PasswordVisualTransformation() - }, - trailing = { - IconButton(onClick = { showPassword = !showPassword }) { - Icon( - imageVector = if (showPassword) { - Icons.Filled.VisibilityOff - } else { - Icons.Filled.Visibility - }, - contentDescription = if (showPassword) "Hide password" else "Show password", - tint = Color.White.copy(alpha = 0.62f), - ) - } - }, + icon = Icons.Outlined.Lock, + placeholder = "Password", + isSecure = true, ) - AuroraTextField( - label = "Confirm password", + MarqueeSeparator() + MarqueeTextField( value = state.confirmPassword, onValueChange = viewModel::onConfirmPasswordChanged, - placeholder = "••••••", - keyboardType = KeyboardType.Password, + icon = Icons.Outlined.Lock, + placeholder = "Confirm password", + isSecure = true, imeAction = ImeAction.Go, onImeAction = viewModel::onClaimClick, - visualTransformation = PasswordVisualTransformation(), - ) - - state.error?.let { AuroraErrorLabel(it) } - - AuroraPrimaryButton( - label = if (state.isSubmitting) "Creating…" else "Create account", - onClick = viewModel::onClaimClick, - isLoading = state.isSubmitting, - modifier = Modifier.fillMaxWidth(), ) } + state.error?.let { MarqueeErrorText(it, Modifier.padding(top = 10.dp)) } + MarqueeButton( + text = if (state.isSubmitting) "Creating…" else "Create account", + onClick = viewModel::onClaimClick, + isLoading = state.isSubmitting, + enabled = !state.isSubmitting, + modifier = Modifier.padding(top = 14.dp), + ) } } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/LoginScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/LoginScreen.kt index 12966eb9cb..4be7bd4d2f 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/LoginScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/LoginScreen.kt @@ -1,60 +1,101 @@ package org.prairieserver.prairie.android.ui.screens.auth +import org.prairieserver.prairie.android.ui.components.PrairieDialogAction +import org.prairieserver.prairie.android.ui.components.PrairieDialog +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Login -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton +import androidx.compose.material.icons.outlined.Key +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.Person import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import org.prairieserver.prairie.android.ui.components.aurora.AuroraEyebrow -import org.prairieserver.prairie.android.ui.components.aurora.AuroraErrorLabel -import org.prairieserver.prairie.android.ui.components.aurora.AuroraGhostButton -import org.prairieserver.prairie.android.ui.components.aurora.AuroraInkTertiary -import org.prairieserver.prairie.android.ui.components.aurora.AuroraPrimaryButton -import org.prairieserver.prairie.android.ui.components.aurora.AuroraScreen -import org.prairieserver.prairie.android.ui.components.aurora.AuroraScrim -import org.prairieserver.prairie.android.ui.components.aurora.AuroraTextField -import org.prairieserver.prairie.android.ui.components.aurora.AuroraVariant -import org.prairieserver.prairie.android.ui.components.aurora.auroraGlass +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.android.auth.openNativeSignIn +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeButton +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeButtonKind +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeErrorHaptic +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeErrorText +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeFieldGroup +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeHeadline +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeIconButton +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeLabeledDivider +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeMetrics +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeProviderMark +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeSeparator +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeServerChip +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeStage +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeTextField +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeTopBarSpacer +import org.prairieserver.prairie.common.ui.marquee.marqueeShake +import org.prairieserver.prairie.common.ui.marquee.MarqueeColors +import org.prairieserver.prairie.common.ui.marquee.MarqueeScene +import org.prairieserver.prairie.common.ui.marquee.MarqueeScrimStyle +import org.prairieserver.prairie.common.ui.marquee.ServerBrandingLoader +import org.prairieserver.prairie.common.ui.marquee.SignInHandoff +import org.prairieserver.prairie.common.ui.marquee.rememberReduceMotion +import org.prairieserver.prairie.common.ui.marquee.rememberSignInServer +import org.prairieserver.prairie.model.auth.SignInProvider +import org.prairieserver.prairie.network.ServerRegistry /** - * Password-first sign-in. Mirrors prairie-apple iOS phone `LoginView` (Aurora): - * wordmark, "Step 02 — Sign in" eyebrow + "Welcome back", then a glass card with - * Username + Password fields, the cream "Sign in" button, and the - * Create-account / Change-server ghost buttons. + * Sign-in, laid out like prairie-apple's phone `LoginView` (Marquee): "Sign in" + * with the server's branded subtitle, then "Continue as " first when + * the server lists a network provider for this device (such as Tailscale), + * a "Sign in with " button per external provider (OIDC, run in a + * Custom Tab), "Use a different account" when the server takes + * `prompt=select_account`, and the username and password form when any + * listed provider takes a password. With two or more providers the password + * form waits behind "Sign in with a password". [LoginViewModel] owns the + * sign-in rules; this screen lays them out. * - * @param signupEnabled Whether the "Create account" ghost is shown. + * @param signupEnabled Whether "Create account" is shown. * @param onNavigateToSignup Tapped "Create account". - * @param onNavigateToProfiles Called after a successful login. - * @param onChangeServer Tapped "Change server" (back to server setup). + * @param onNavigateToProfiles Called after a successful sign-in. + * @param onChangeServer Back, or "Use a different server" from the server chip. */ @Composable fun LoginScreen( @@ -63,113 +104,340 @@ fun LoginScreen( onNavigateToProfiles: () -> Unit, onChangeServer: () -> Unit = {}, viewModel: LoginViewModel = koinViewModel(), + brandingLoader: ServerBrandingLoader = koinInject(), + serverRegistry: ServerRegistry = koinInject(), ) { val state by viewModel.uiState.collectAsState() - var showPassword by remember { mutableStateOf(false) } - - // Coming from server setup the next action is always typing a username; - // focus it immediately so the keyboard stays up across the transition. + val activeEntry by serverRegistry.activeEntry.collectAsState() + val server = rememberSignInServer( + serverUrl = activeEntry?.url.orEmpty(), + savedName = activeEntry?.fetchedName, + loader = brandingLoader, + ) + val focusManager = LocalFocusManager.current val usernameFocus = remember { FocusRequester() } - LaunchedEffect(Unit) { usernameFocus.requestFocus() } + var expandsPasswordForm by remember { mutableStateOf(false) } + var passwordFailures by remember { mutableIntStateOf(0) } + + LaunchedEffect(Unit) { + MarqueeScene.focus = MarqueeScene.Focus.Account + MarqueeScene.personalTint = null + } + + // A provider's native start opens in a Custom Tab; the app redirect + // brings the result back through NativeSignInCoordinator. + val context = LocalContext.current + LaunchedEffect(state.browserLaunch) { + val url = state.browserLaunch ?: return@LaunchedEffect + viewModel.onBrowserLaunchHandled(context.openNativeSignIn(url)) + } LaunchedEffect(signupEnabled) { viewModel.setSignupEnabled(signupEnabled) } LaunchedEffect(state.loginSuccess) { if (state.loginSuccess) { viewModel.onLoginSuccessConsumed() + SignInHandoff.skipsSingleProfilePicker = true onNavigateToProfiles() } } + // Each rejected password shakes the fields; an error already showing + // when the screen comes back doesn't. + val initialError = remember { state.error } + LaunchedEffect(state.error) { + if (state.error != null && state.error != initialError && state.showPasswordForm && state.password.isNotEmpty()) { + passwordFailures++ + } + } + MarqueeErrorHaptic(state.error) - AuroraScreen(variant = AuroraVariant.SignIn, scrim = AuroraScrim.Soft) { - PrairieLogo() - - Spacer(Modifier.height(30.dp)) - AuroraEyebrow(text = "Step 02 — Sign in", centered = true) - Spacer(Modifier.height(12.dp)) - Text( - text = "Welcome back", - fontSize = 30.sp, - fontWeight = FontWeight.SemiBold, - color = Color(0xFFF3EFE9), + // Browser sign-in results arrive after the Custom Tab closes, away from + // where focus is: announce them. + val announced = Modifier.semantics { liveRegion = LiveRegionMode.Polite } + + if (state.choosingAccountProvider) { + AccountProviderChooser( + providers = state.providers, + onChoose = viewModel::onAccountProviderChosen, + onDismiss = viewModel::onAccountChoiceDismissed, ) - state.serverHostLabel?.let { host -> - Spacer(Modifier.height(8.dp)) - Text( - text = host, - modifier = Modifier.fillMaxWidth(), - fontFamily = FontFamily.Monospace, - fontSize = 14.sp, - color = AuroraInkTertiary, - textAlign = TextAlign.Center, - maxLines = 1, + } + + // With two or more providers the password form waits behind a button, + // so the providers stay the first thing on screen. + val foldsPasswordForm = state.providers.size >= 2 && !expandsPasswordForm + // Coming from server setup the next action is typing a username; focus + // it as soon as the form shows so the keyboard stays up across the + // transition. A folded form waits until it's opened. + LaunchedEffect(state.showPasswordForm, foldsPasswordForm) { + if (state.showPasswordForm && !foldsPasswordForm) runCatching { usernameFocus.requestFocus() } + } + + // Leaving mid-sign-in would let the finished sign-in pull the app back + // from server setup, so going back waits until it settles. + MarqueeStage( + scrim = MarqueeScrimStyle.BottomDeep, + frostStart = 0.40f, + onBack = onChangeServer, + blockBack = state.signInBusy, + topBar = { + MarqueeIconButton( + icon = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Change server", + onClick = onChangeServer, + enabled = !state.signInBusy, ) - } - Spacer(Modifier.height(24.dp)) - - Column2Glass { - AuroraTextField( - label = "Username", - value = state.username, - onValueChange = viewModel::onUsernameChanged, - modifier = Modifier.focusRequester(usernameFocus), - placeholder = "yourname", - imeAction = ImeAction.Next, + MarqueeTopBarSpacer() + MarqueeServerChip( + name = server.name, + hostLabel = server.hostLabel, + markUrl = server.branding?.markUrl, + actionLabel = "Use a different server", + onAction = onChangeServer, + enabled = !state.signInBusy, ) - AuroraTextField( - label = "Password", - value = state.password, - onValueChange = viewModel::onPasswordChanged, - placeholder = "••••••", - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Go, - onImeAction = viewModel::onLoginClick, - visualTransformation = if (showPassword) { - VisualTransformation.None + }, + ) { + MarqueeHeadline( + title = "Sign in", + lead = server.branding?.loginSubtitle ?: "Use your ${server.name} account.", + ) + + Column(Modifier.fillMaxWidth().padding(top = 24.dp).animateContentSize()) { + if (state.options == null) { + LoadingSkeleton() + return@Column + } + + state.networkProvider?.let { provider -> + MarqueeButton( + onClick = viewModel::onNetworkSignIn, + isLoading = state.networkSignInBusy, + enabled = !state.signInBusy, + modifier = Modifier.semantics { + contentDescription = if (state.networkSignInBusy) "Signing in…" else continueAsLabel(provider) + }, + ) { + if (state.networkSignInBusy) { + Text("Signing in…") + } else { + NetworkSignInLabel(provider) + } + } + if (state.providers.isNotEmpty() || state.showPasswordForm) { + MarqueeLabeledDivider("or", Modifier.padding(vertical = 18.dp)) + } + } + + state.providers.forEachIndexed { index, provider -> + val busy = state.providerBusy == provider.id + // The first provider leads unless a network sign-in already does. + val primary = index == 0 && state.networkProvider == null + MarqueeButton( + onClick = { viewModel.onProviderClick(provider) }, + kind = if (primary) MarqueeButtonKind.Primary else MarqueeButtonKind.Glass, + isLoading = busy, + enabled = !state.signInBusy, + modifier = Modifier.padding(top = if (index > 0) 10.dp else 0.dp), + ) { + if (busy) { + Text("Waiting for sign-in…") + } else { + MarqueeProviderMark(provider) + Text(signInWithLabel(provider), maxLines = 2, textAlign = TextAlign.Center) + } + } + } + if (state.offersAccountChoice) { + MarqueeButton( + text = differentAccountLabel(state.providers), + onClick = viewModel::onUseDifferentAccount, + kind = MarqueeButtonKind.Plain, + enabled = !state.signInBusy, + modifier = Modifier.padding(top = 6.dp), + ) + } + + if (state.options != null && !state.showPasswordForm && state.providers.isEmpty() && state.networkProvider == null) { + MarqueeErrorText( + "This server doesn't offer a sign-in this app can use. Sign in on the web, or ask the server's admin.", + ) + } + + // Discovery failed: the password form still shows, and this row + // says the providers may be missing and retries. + if (state.optionsUnavailable) { + Row( + Modifier.fillMaxWidth().padding(bottom = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MarqueeErrorText("Couldn't load sign-in options.", Modifier.weight(1f).then(announced)) + TextButton(onClick = viewModel::loadOptions, enabled = !state.signInBusy) { + Text("Retry", color = MarqueeColors.Ink, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) + } + } + } + + if (state.showPasswordForm) { + if (state.providers.isNotEmpty()) { + MarqueeLabeledDivider( + if (foldsPasswordForm) "or" else "or use your password", + Modifier.padding(vertical = 18.dp), + ) + } + if (foldsPasswordForm) { + MarqueeButton( + text = "Sign in with a password", + onClick = { expandsPasswordForm = true }, + kind = MarqueeButtonKind.Plain, + icon = Icons.Outlined.Key, + enabled = !state.signInBusy, + ) + // A provider's failure has no form to show it in. + state.error?.let { MarqueeErrorText(it, Modifier.padding(top = 14.dp).then(announced)) } } else { - PasswordVisualTransformation() - }, - trailing = { - IconButton(onClick = { showPassword = !showPassword }) { - Icon( - imageVector = if (showPassword) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, - contentDescription = if (showPassword) "Hide password" else "Show password", - tint = Color.White.copy(alpha = 0.62f), + MarqueeFieldGroup(isError = state.error != null, modifier = Modifier.marqueeShake(passwordFailures)) { + MarqueeTextField( + value = state.username, + onValueChange = viewModel::onUsernameChanged, + icon = Icons.Outlined.Person, + placeholder = "Username", + imeAction = ImeAction.Next, + focusRequester = usernameFocus, + enabled = !state.signInBusy, + ) + MarqueeSeparator() + MarqueeTextField( + value = state.password, + onValueChange = viewModel::onPasswordChanged, + icon = Icons.Outlined.Lock, + placeholder = "Password", + isSecure = true, + isError = state.error != null, + enabled = !state.signInBusy, + imeAction = ImeAction.Go, + onImeAction = { + focusManager.clearFocus() + viewModel.onLoginClick() + }, ) } - }, - ) + state.error?.let { MarqueeErrorText(it, Modifier.padding(top = 10.dp, start = 4.dp).then(announced)) } + MarqueeButton( + text = if (state.isLoading) "Signing in…" else "Sign in", + onClick = { + focusManager.clearFocus() + viewModel.onLoginClick() + }, + icon = Icons.AutoMirrored.Filled.Login, + isLoading = state.isLoading, + // Any sign-in under way: the view model takes no password until it ends. + enabled = !state.signInBusy, + modifier = Modifier.padding(top = 14.dp), + ) + } + } else { + state.error?.let { MarqueeErrorText(it, Modifier.padding(top = 14.dp).then(announced)) } + if (state.isLoading) { + Text( + text = "Finishing sign-in…", + modifier = Modifier.fillMaxWidth().padding(top = 14.dp).then(announced), + color = MarqueeColors.InkTertiary, + textAlign = TextAlign.Center, + ) + } + } - state.error?.let { AuroraErrorLabel(it) } + Footnote(state) - AuroraPrimaryButton( - label = if (state.isLoading) "Signing in…" else "Sign in", - onClick = viewModel::onLoginClick, - icon = Icons.AutoMirrored.Filled.Login, - isLoading = state.isLoading, - modifier = Modifier.fillMaxWidth(), - ) + if (state.signupEnabled) { + MarqueeButton( + text = "Create account", + onClick = onNavigateToSignup, + kind = MarqueeButtonKind.Plain, + enabled = !state.signInBusy, + modifier = Modifier.padding(top = 4.dp), + ) + } + } + } +} - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(14.dp, androidx.compose.ui.Alignment.CenterHorizontally), - ) { - if (state.signupEnabled) { - AuroraGhostButton(label = "Create account", onClick = onNavigateToSignup) - } - AuroraGhostButton(label = "Change server", onClick = onChangeServer) +@Composable +private fun NetworkSignInLabel(provider: SignInProvider) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + MarqueeProviderMark(provider) + Column(verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text(continueAsLabel(provider), maxLines = 1) + continueViaLabel(provider)?.let { via -> + Text(via, fontSize = 12.sp, fontWeight = FontWeight.Medium, modifier = Modifier.alpha(0.7f), maxLines = 1) } } } } @Composable -private fun Column2Glass(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) { - androidx.compose.foundation.layout.Column( - modifier = Modifier +private fun Footnote(state: LoginUiState) { + val options = state.options ?: return + val text = when { + options.oauthProviders.isEmpty() && options.showPasswordForm -> "Forgot your password? Ask the server admin." + options.oauthProviders.isNotEmpty() && !options.showPasswordForm -> "Password sign-in is turned off on this server." + else -> return + } + Text( + text, + color = MarqueeColors.InkTertiary, + fontSize = 13.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(top = 16.dp), + ) +} + +/** Placeholders with a soft shimmer while the server's sign-in options load. */ +@Composable +private fun LoadingSkeleton() { + val reduceMotion = rememberReduceMotion() + val shimmer = if (reduceMotion) { + 0.08f + } else { + val transition = rememberInfiniteTransition(label = "skeleton") + transition.animateFloat( + initialValue = 0.06f, + targetValue = 0.12f, + animationSpec = infiniteRepeatable(tween(800), RepeatMode.Reverse), + label = "skeletonAlpha", + ).value + } + Column( + Modifier .fillMaxWidth() - .auroraGlass(cornerRadius = 24.dp, emphasized = true) - .padding(22.dp), - verticalArrangement = Arrangement.spacedBy(18.dp), - content = content, + .semantics(mergeDescendants = true) { contentDescription = "Loading sign-in options" }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Box(Modifier.fillMaxWidth().height(MarqueeMetrics.ButtonHeight).clip(CircleShape).background(Color.White.copy(alpha = shimmer))) + Box(Modifier.width(140.dp).height(10.dp).clip(CircleShape).background(Color.White.copy(alpha = shimmer))) + Box( + Modifier + .fillMaxWidth() + .height(MarqueeMetrics.FieldHeight * 2) + .clip(RoundedCornerShape(MarqueeMetrics.FieldCorner)) + .background(Color.White.copy(alpha = shimmer)), + ) + } +} + +/** "Use a different account" with several providers: which one to sign in with. */ +@Composable +private fun AccountProviderChooser( + providers: List, + onChoose: (SignInProvider) -> Unit, + onDismiss: () -> Unit, +) { + PrairieDialog( + title = "Use a different account", + onDismissRequest = onDismiss, + actions = providers.map { provider -> + PrairieDialogAction(label = signInWithLabel(provider), onClick = { onChoose(provider) }) + } + PrairieDialogAction(label = "Cancel", onClick = onDismiss), ) } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/LoginViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/LoginViewModel.kt index 0c196492bb..98505bf0fe 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/LoginViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/LoginViewModel.kt @@ -2,12 +2,27 @@ package org.prairieserver.prairie.android.ui.screens.auth import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import org.prairieserver.prairie.android.auth.NativeSignInCoordinator +import org.prairieserver.prairie.android.auth.NativeSignInMessages +import org.prairieserver.prairie.android.auth.NativeSignInProtocol +import org.prairieserver.prairie.android.auth.NativeSignInPurpose +import org.prairieserver.prairie.android.auth.NativeSignInResult +import org.prairieserver.prairie.android.auth.NativeSignInStart +import org.prairieserver.prairie.model.auth.NetworkSignInFailure +import org.prairieserver.prairie.model.auth.PasswordLoginFailure +import org.prairieserver.prairie.model.auth.SignInOptions +import org.prairieserver.prairie.model.auth.SignInProvider import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.ServerRegistry +import org.prairieserver.prairie.network.TokenManager import org.prairieserver.prairie.repository.AuthRepository +import org.prairieserver.prairie.repository.ExternalSignInRepository +import org.prairieserver.prairie.repository.ServerIdentityRepository import java.net.URI import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -19,22 +34,96 @@ data class LoginUiState( val error: String? = null, val signupEnabled: Boolean = false, val loginSuccess: Boolean = false, -) + /** Null while the server's sign-in options load. */ + val options: SignInOptions? = null, + /** + * The options couldn't be read (network): the password form shows as a + * fallback, with a retry for the server's other ways to sign in. + */ + val optionsUnavailable: Boolean = false, + /** The provider whose browser sign-in is starting or finishing. */ + val providerBusy: String? = null, + /** + * "Continue as …" is waiting for the server: the password form and the + * provider buttons wait too. Its own flag, so reloading the options + * can't free them while the request is still out. + */ + val networkSignInBusy: Boolean = false, + /** A start URL for the screen to open in a Custom Tab, once. */ + val browserLaunch: String? = null, + /** "Use a different account" with several providers: which one to sign in with. */ + val choosingAccountProvider: Boolean = false, +) { + val showPasswordForm: Boolean get() = options?.showPasswordForm == true + val providers: List get() = options?.oauthProviders.orEmpty() + + /** "Continue as ": the network provider the server listed for this device, above everything else. */ + val networkProvider: SignInProvider? get() = options?.networkProvider + + /** "Use a different account" under the provider buttons, when the server takes `prompt=select_account`. */ + val offersAccountChoice: Boolean get() = options?.selectAccount == true && providers.isNotEmpty() + + /** A sign-in on this screen is under way, so no other may start. */ + val signInBusy: Boolean get() = isLoading || providerBusy != null || networkSignInBusy +} class LoginViewModel( private val authRepository: AuthRepository, + private val externalSignIn: ExternalSignInRepository, + private val serverRegistry: ServerRegistry, + private val serverIdentities: ServerIdentityRepository, + private val nativeSignIn: NativeSignInCoordinator, + private val tokenManager: TokenManager, ) : ViewModel() { private val _uiState = MutableStateFlow(LoginUiState()) val uiState: StateFlow = _uiState.asStateFlow() + /** The saved server this screen signs in to; results of flows started for another saved server are ignored. */ + private val screenServerId: String? = serverRegistry.activeServerId.value + + /** + * This screen took the auto-start "Not you? Switch account" asked for and + * the browser hasn't opened yet; handed back if the screen goes away first. + */ + private var autoStartInFlight = false + init { + loadOptions() viewModelScope.launch { - val hostLabel = loginServerHostLabel(authRepository.getServerUrl()) - _uiState.update { it.copy(serverHostLabel = hostLabel) } + nativeSignIn.result.filterNotNull().collect { onNativeResult(it) } } } + /** Reads the server's sign-in options; also the retry after they couldn't be read. */ + fun loadOptions() { + _uiState.update { it.copy(options = null, optionsUnavailable = false) } + viewModelScope.launch { + val serverUrl = authRepository.getServerUrl() + _uiState.update { it.copy(serverHostLabel = loginServerHostLabel(serverUrl)) } + val options = if (serverUrl.isBlank()) SignInOptions.PasswordOnly else externalSignIn.signInOptions(serverUrl) + _uiState.update { + it.copy(options = options ?: SignInOptions.PasswordOnly, optionsUnavailable = options == null) + } + if (options != null) startRequestedSignIn(options) + } + } + + /** + * "Not you? Switch account" sent the person here to pick another account: + * start the provider sign-in by itself when the server lists exactly one + * provider and passes `select_account` on (prairie-apple + * `LoginViewModel.startRequestedSignIn`). + */ + private fun startRequestedSignIn(options: SignInOptions) { + val serverId = screenServerId ?: return + if (!nativeSignIn.consumeAutoStart(serverId)) return + val provider = options.oauthProviders.singleOrNull() + if (provider?.nativeStartPath == null || !options.selectAccount) return + autoStartInFlight = true + onProviderClick(provider, chooseAccount = true) + } + /** Called by the navigation layer to pass data from the previous screen. */ fun setSignupEnabled(enabled: Boolean) { _uiState.update { it.copy(signupEnabled = enabled) } @@ -49,7 +138,7 @@ class LoginViewModel( } fun onLoginClick() { - if (_uiState.value.isLoading) return + if (_uiState.value.signInBusy) return val current = _uiState.value if (current.username.isBlank()) { _uiState.update { it.copy(error = "Username is required") } @@ -65,15 +154,17 @@ class LoginViewModel( when (val result = authRepository.login(current.username, current.password)) { is ApiResult.Success -> { + onSignedIn() _uiState.update { it.copy(isLoading = false, loginSuccess = true) } } is ApiResult.Error -> { - val message = when (result.code) { - 401 -> "Invalid username or password" - 403 -> "Account is disabled" - else -> result.message.ifBlank { "Login failed" } - } + val options = _uiState.value.options + val message = passwordLoginMessage( + result, + providerName = options?.oauthProviders?.firstOrNull()?.displayName, + directoryName = options?.directoryProvider?.displayName, + ) _uiState.update { it.copy(isLoading = false, error = message) } } @@ -89,10 +180,224 @@ class LoginViewModel( } } + /** A sign-in on this screen (password or network identity) saved its session. */ + private suspend fun onSignedIn() { + screenServerId?.let(nativeSignIn::clearAccountChoice) + // A provider flow left open in the browser no longer speaks for this session. + nativeSignIn.discardPending() + } + + /** + * "Continue as ": sign in as whoever the server's network provider + * (such as Tailscale) says owns this device. No browser and no password: + * one POST to the listed path on the saved server base, and the session + * lands exactly like a password sign-in's. + */ + fun onNetworkSignIn() { + val state = _uiState.value + val provider = state.networkProvider ?: return + val path = provider.networkSignInPath ?: return + if (state.signInBusy) return + _uiState.update { it.copy(networkSignInBusy = true, error = null) } + viewModelScope.launch { + val result = authRepository.signInWith { serverUrl -> externalSignIn.signInWithNetworkIdentity(serverUrl, path) } + when (result) { + is ApiResult.Success -> { + onSignedIn() + _uiState.update { it.copy(networkSignInBusy = false, loginSuccess = true) } + } + is ApiResult.Error -> _uiState.update { + it.copy(networkSignInBusy = false, error = networkSignInMessage(result, provider.displayName)) + } + is ApiResult.NetworkError -> _uiState.update { + it.copy(networkSignInBusy = false, error = "Network error. Please check your connection.") + } + } + } + } + + /** + * "Sign in with ": record a native flow for the active server + * and hand its start URL to the screen. The redirect must name this + * server's verified identity, so it is read (and re-verified) first. + * [chooseAccount] ("Use a different account") and the first provider + * sign-in after an explicit sign-out ask the provider to offer another + * account, when the server passes that on. + */ + fun onProviderClick(provider: SignInProvider, chooseAccount: Boolean = false) { + val startPath = provider.nativeStartPath ?: return + if (_uiState.value.signInBusy) return + _uiState.update { it.copy(providerBusy = provider.id, error = null, choosingAccountProvider = false) } + viewModelScope.launch { + val entry = serverRegistry.activeEntry.value + val serverId = entry?.let { serverIdentities.identityOf(it, refresh = true) } + if (entry == null || serverId == null) { + autoStartInFlight = false + _uiState.update { + it.copy( + providerBusy = null, + error = "Couldn't confirm this server's identity. Check your connection and try again.", + ) + } + return@launch + } + val askForAccount = _uiState.value.options?.selectAccount == true && + (chooseAccount || nativeSignIn.accountChoiceRequested(entry.id)) + val start = nativeSignIn.begin( + purpose = NativeSignInPurpose.SignIn, + serverEntryId = entry.id, + serverUrl = entry.url, + verifiedServerId = serverId, + providerName = provider.displayName, + nativeStartPath = startPath, + loginSessionId = tokenManager.loginSessionId(entry.id), + prompt = NativeSignInProtocol.PROMPT_SELECT_ACCOUNT.takeIf { askForAccount }, + ) + when (start) { + is NativeSignInStart.Open -> _uiState.update { it.copy(browserLaunch = start.url) } + is NativeSignInStart.Refused -> { + autoStartInFlight = false + _uiState.update { it.copy(providerBusy = null, error = start.message) } + } + } + } + } + + /** + * "Use a different account": the provider is asked to let the person pick + * another provider account instead of the one the browser is signed in + * with. With several providers the screen asks which one first. + */ + fun onUseDifferentAccount() { + val state = _uiState.value + if (!state.offersAccountChoice || state.signInBusy) return + val only = state.providers.singleOrNull() + if (only != null) onProviderClick(only, chooseAccount = true) else _uiState.update { it.copy(choosingAccountProvider = true) } + } + + fun onAccountProviderChosen(provider: SignInProvider) = onProviderClick(provider, chooseAccount = true) + + fun onAccountChoiceDismissed() { + _uiState.update { it.copy(choosingAccountProvider = false) } + } + + /** + * The screen tried to open [browserLaunch]. Signing in continues in the + * browser, so the button is free again; the redirect brings the result. + */ + fun onBrowserLaunchHandled(opened: Boolean) { + autoStartInFlight = false + _uiState.update { state -> + val providerName = state.providers.firstOrNull { it.id == state.providerBusy }?.displayName + state.copy( + browserLaunch = null, + providerBusy = null, + error = if (opened) state.error else NativeSignInMessages.forReason(NativeSignInMessages.NO_BROWSER, providerName), + ) + } + } + + private suspend fun onNativeResult(result: NativeSignInResult) { + if (result.purpose != NativeSignInPurpose.SignIn) return + if (screenServerId != null && result.serverEntryId != screenServerId) return + when (result) { + is NativeSignInResult.Finishing -> + _uiState.update { it.copy(isLoading = true, error = null) } + is NativeSignInResult.SignedIn -> { + nativeSignIn.consume(result) + // Only while that session is still the one installed, on this + // screen's server: a result left over from before a sign-out + // must not route anywhere. + val signedIn = tokenManager.getAccessToken()?.isNotBlank() == true && + (screenServerId == null || serverRegistry.activeServerId.value == screenServerId) + _uiState.update { it.copy(isLoading = false, loginSuccess = signedIn) } + } + is NativeSignInResult.Failed -> { + nativeSignIn.consume(result) + _uiState.update { it.copy(isLoading = false, error = result.message) } + } + is NativeSignInResult.Linked -> Unit + } + } + /** Resets the success flag after the UI has consumed it. */ fun onLoginSuccessConsumed() { _uiState.update { it.copy(loginSuccess = false) } } + + override fun onCleared() { + if (autoStartInFlight) screenServerId?.let(nativeSignIn::restoreAutoStart) + } +} + +/** + * Text for a refused password sign-in. [providerName] names the server's + * browser sign-in provider, when it has one, for the local-password-off case; + * [directoryName] the directory (LDAP) provider whose first sign-in can meet + * an email or identity already in use. + */ +internal fun passwordLoginMessage( + error: ApiResult.Error, + providerName: String? = null, + directoryName: String? = null, +): String = + when (PasswordLoginFailure.of(error.code, error.error)) { + PasswordLoginFailure.InvalidCredentials -> "Invalid username or password" + PasswordLoginFailure.LocalLoginDisabled -> if (providerName != null) { + "Password sign-in is turned off on this server. Sign in with $providerName instead." + } else { + "Password sign-in is turned off on this server." + } + PasswordLoginFailure.NotPermitted -> "Your account isn't allowed to use this server." + PasswordLoginFailure.PasswordExpired -> "Your password has expired. Change it with your directory, then sign in again." + PasswordLoginFailure.AccountDisabled -> "Account is disabled" + PasswordLoginFailure.ProviderUnavailable -> "The sign-in service can't be reached right now. Try again later." + PasswordLoginFailure.EmailInUse -> NativeSignInMessages.forReason("email_in_use", directoryName) + PasswordLoginFailure.IdentityLinkedElsewhere -> NativeSignInMessages.forReason("identity_linked_elsewhere", directoryName) + PasswordLoginFailure.RateLimited -> NativeSignInMessages.forReason(NativeSignInMessages.RATE_LIMITED) + PasswordLoginFailure.AccountRequired -> NativeSignInMessages.forReason(NativeSignInMessages.ACCOUNT_REQUIRED) + PasswordLoginFailure.Other -> error.message.ifBlank { "Login failed" } + } + +/** + * Text for a refused network identity sign-in ("Continue as …") through + * [providerName], such as Tailscale. Refusals other operations share read as + * they do there. + */ +internal fun networkSignInMessage(error: ApiResult.Error, providerName: String): String = + when (NetworkSignInFailure.of(error.code, error.error)) { + NetworkSignInFailure.NetworkIdentityRequired -> ProviderRefusalText.addressRequired(providerName, "sign in this way") + NetworkSignInFailure.NotPermitted -> ProviderRefusalText.notPermitted(providerName) + NetworkSignInFailure.EmailInUse -> + "An account with your email already exists. Sign in with your password, then connect $providerName in Settings → Sign-in." + NetworkSignInFailure.AccountRequired -> NativeSignInMessages.forReason(NativeSignInMessages.ACCOUNT_REQUIRED) + NetworkSignInFailure.AccountDisabled -> NativeSignInMessages.forReason("account_disabled") + NetworkSignInFailure.IdentityLinkedElsewhere -> NativeSignInMessages.forReason("identity_linked_elsewhere", providerName) + NetworkSignInFailure.NotFound -> ProviderRefusalText.providerGone(providerName) + NetworkSignInFailure.ProviderUnavailable -> NativeSignInMessages.forReason("provider_unavailable", providerName) + NetworkSignInFailure.RateLimited -> NativeSignInMessages.forReason(NativeSignInMessages.RATE_LIMITED) + NetworkSignInFailure.Other -> when { + // AuthRepository's code for a sign-in whose account or server changed underneath it. + error.error == "identity_changed" -> NativeSignInMessages.forReason(NativeSignInMessages.ACCOUNT_CHANGED) + error.code == 0 && error.error == NativeSignInMessages.UPDATE_REQUIRED -> + NativeSignInMessages.forReason(NativeSignInMessages.UPDATE_REQUIRED) + else -> NativeSignInMessages.forReason("login_failed", providerName) + } + } + +/** + * The phone's copy for provider refusals that sign-in ("Continue as …") and + * "Connect " in Settings share. + */ +internal object ProviderRefusalText { + /** `network_identity_required`: the app reached the server some other way; [toDo] is what needs that address. */ + fun addressRequired(providerName: String, toDo: String) = "Open this server at its $providerName address to $toDo." + + /** `not_permitted`: a tagged device, or one the provider's policy leaves out. */ + fun notPermitted(providerName: String) = "$providerName doesn't allow this device on this server." + + /** The provider is no longer enabled on the server (404). */ + fun providerGone(providerName: String) = "$providerName is no longer available on this server." } internal fun loginServerHostLabel(serverUrl: String): String? { diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/ServerSetupScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/ServerSetupScreen.kt index aa051ff859..30121c4227 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/ServerSetupScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/ServerSetupScreen.kt @@ -1,22 +1,33 @@ package org.prairieserver.prairie.android.ui.screens.auth +import org.prairieserver.prairie.android.ui.components.PrairieConfirmDialog import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.outlined.Language +import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.Icon -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -28,48 +39,74 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource -import org.prairieserver.prairie.android.R -import org.prairieserver.prairie.android.ui.components.aurora.AuroraErrorLabel -import org.prairieserver.prairie.android.ui.components.aurora.AuroraEyebrow -import org.prairieserver.prairie.android.ui.components.aurora.AuroraFieldLabel -import org.prairieserver.prairie.android.ui.components.aurora.AuroraGhostButton -import org.prairieserver.prairie.android.ui.components.aurora.AuroraInk -import org.prairieserver.prairie.android.ui.components.aurora.AuroraPrimaryButton -import org.prairieserver.prairie.android.ui.components.aurora.AuroraScreen -import org.prairieserver.prairie.android.ui.components.aurora.AuroraSegment -import org.prairieserver.prairie.android.ui.components.aurora.AuroraTextField -import org.prairieserver.prairie.android.ui.components.aurora.AuroraVariant -import org.prairieserver.prairie.android.ui.components.aurora.auroraGlass +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeButton +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeErrorHaptic +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeErrorText +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeHeadline +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeMetrics +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeSeparator +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeStage +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeTextField +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeWordmark +import org.prairieserver.prairie.android.ui.components.marquee.marqueePressHaptic +import org.prairieserver.prairie.common.ui.marquee.MarqueeColors +import org.prairieserver.prairie.common.ui.marquee.MarqueeScene +import org.prairieserver.prairie.common.ui.marquee.MarqueeScrimStyle +import org.prairieserver.prairie.common.ui.marquee.MarqueeServerMark +import org.prairieserver.prairie.common.ui.marquee.ServerBranding +import org.prairieserver.prairie.common.ui.marquee.ServerBrandingCache +import org.prairieserver.prairie.model.server.ServerEntry +import org.prairieserver.prairie.network.ServerRegistry /** - * First screen the user sees. Allows entering the Prairie server URL. - * - * Rebuilt to match prairie-apple's `ServerSetupView` (Aurora): a single centered - * glass form over the plum backdrop. Protocol + port stay tucked under - * "Advanced options". + * First screen when no server is configured: the address field over the + * brand light, recent servers below it, and protocol and port under + * "Advanced options" (prairie-apple `ServerSetupView`). * * After validating the server: * - If the server needs setup, navigates to [onNavigateToSetup]. - * - Otherwise navigates to [onNavigateToLogin], indicating whether signup is - * enabled. + * - A saved server this device is still signed in to goes to [onNavigateToProfiles]. + * - Otherwise navigates to [onNavigateToLogin], indicating whether signup is enabled. */ @Composable fun ServerSetupScreen( onNavigateToSetup: () -> Unit, onNavigateToLogin: (signupEnabled: Boolean) -> Unit, + onNavigateToProfiles: () -> Unit, + /** An address to start from, such as the server an app link named. */ + prefillUrl: String? = null, viewModel: ServerSetupViewModel = koinViewModel(), + serverRegistry: ServerRegistry = koinInject(), + brandingCache: ServerBrandingCache = koinInject(), ) { val state by viewModel.uiState.collectAsState() + val entries by serverRegistry.entries.collectAsState() + val recents = remember(entries) { entries.sortedByDescending { it.lastUsedAtEpochMs }.take(3) } + var fieldFocused by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { MarqueeScene.showGeneric() } + LaunchedEffect(prefillUrl) { + prefillUrl?.takeIf { it.isNotBlank() }?.let(viewModel::prefill) + } // React to navigation events produced by the ViewModel. LaunchedEffect(state.navigateTo) { @@ -84,161 +121,283 @@ fun ServerSetupScreen( onNavigateToLogin(dest.signupEnabled) } + ServerSetupDestination.Profiles -> { + viewModel.onNavigationConsumed() + onNavigateToProfiles() + } + null -> Unit } } + MarqueeErrorHaptic(state.error) var showAdvanced by rememberSaveable { mutableStateOf(false) } state.pendingCleartextUrl?.let { origin -> - AlertDialog( - onDismissRequest = viewModel::cancelCleartextConnection, - title = { Text("Use unencrypted HTTP?") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text(origin, fontWeight = FontWeight.SemiBold) - Text( - "This connection is not encrypted. Anyone on the network may see or change " + - "traffic, including your sign-in. Continue only on a network you trust.", - ) - } - }, - confirmButton = { - AuroraPrimaryButton( - label = "Use HTTP", - onClick = viewModel::confirmCleartextConnection, - modifier = Modifier.width(140.dp), - isLoading = state.isLoading, - ) - }, - dismissButton = { - AuroraGhostButton( - label = "Cancel", - onClick = viewModel::cancelCleartextConnection, - ) - }, + PrairieConfirmDialog( + title = "Connect without encryption?", + body = "Your password and what you watch will be sent unencrypted to " + + "${ServerBranding.hostLabel(origin)}. Only do this on a network you trust.", + confirmLabel = "Connect", + destructive = false, + confirmEnabled = !state.isLoading, + onConfirm = viewModel::confirmCleartextConnection, + onDismiss = viewModel::cancelCleartextConnection, ) } - AuroraScreen(variant = AuroraVariant.Server) { - // Prairie wordmark (iOS width 132). - Image( - painter = painterResource(id = R.drawable.prairie_wordmark), - contentDescription = "Prairie", + MarqueeStage( + scrim = MarqueeScrimStyle.Bottom, + frostStart = 0.30f, + topBar = { MarqueeWordmark() }, + ) { + MarqueeHeadline(title = "Connect to\nyour server", lead = "Type the address you use for Prairie.") + + MarqueeTextField( + value = state.serverUrl, + onValueChange = viewModel::onServerUrlChanged, + icon = Icons.Outlined.Language, + placeholder = "media.example.com", + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Go, + onImeAction = viewModel::onConnectClick, + showsClearButton = true, + isError = state.error != null, modifier = Modifier - .width(132.dp) - .height(40.dp), - contentScale = ContentScale.Fit, + .padding(top = 20.dp) + .onFocusChanged { fieldFocused = it.hasFocus }, ) - Spacer(Modifier.height(30.dp)) + state.error?.let { error -> + MarqueeErrorText(error, Modifier.padding(top = 10.dp, start = 4.dp)) + } + if (state.usesCleartext && state.error == null) { + Text( + "This address uses unencrypted HTTP. Fine on a trusted home network; avoid it on public Wi-Fi.", + color = MarqueeColors.Warning, + fontSize = 12.sp, + modifier = Modifier.padding(top = 10.dp, start = 4.dp), + ) + } - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - AuroraEyebrow(text = "Step 01 — Connect", centered = true) + AdvancedOptions( + expanded = showAdvanced, + onToggle = { showAdvanced = !showAdvanced }, + enabled = !state.isLoading, + scheme = state.selectedScheme, + onSchemeSelected = viewModel::onSchemeSelected, + port = state.port, + onPortChanged = viewModel::onPortChanged, + modifier = Modifier.padding(top = 12.dp), + ) + + // Recents are for picking before typing; while the address field has + // the keyboard, the field and Continue need the room. + if (recents.isNotEmpty() && !fieldFocused) { Text( - text = "Add your server", - fontSize = 28.sp, + "Recent", + color = MarqueeColors.InkSecondary, + fontSize = 15.sp, fontWeight = FontWeight.SemiBold, - color = AuroraInk, - textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 22.dp, start = 2.dp, bottom = 10.dp), + ) + RecentServers( + servers = recents, + brandingCache = brandingCache, + enabled = !state.isLoading, + onPick = { server -> + viewModel.useRecent(server.url) + viewModel.onConnectClick() + }, ) } - Spacer(Modifier.height(24.dp)) + MarqueeButton( + text = if (state.isLoading) "Connecting…" else "Continue", + onClick = viewModel::onConnectClick, + icon = Icons.Default.Link, + isLoading = state.isLoading, + enabled = !state.isLoading, + modifier = Modifier.padding(top = 22.dp), + ) + } +} - // Glass card. - Column( +@Composable +private fun AdvancedOptions( + expanded: Boolean, + onToggle: () -> Unit, + enabled: Boolean, + scheme: ServerSetupScheme, + onSchemeSelected: (ServerSetupScheme) -> Unit, + port: String, + onPortChanged: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier.fillMaxWidth().animateContentSize()) { + val chevron by animateFloatAsState(if (expanded) 180f else 0f, label = "advancedChevron") + val interaction = remember { MutableInteractionSource() } + Row( modifier = Modifier - .fillMaxWidth() - .auroraGlass(cornerRadius = 24.dp, emphasized = true) - .padding(22.dp), - verticalArrangement = Arrangement.spacedBy(18.dp), + .heightIn(min = 32.dp) + .clip(RoundedCornerShape(8.dp)) + .clickable(enabled = enabled, role = Role.Button, interactionSource = interaction, indication = null, onClick = onToggle) + .marqueePressHaptic(interaction) + .semantics { stateDescription = if (expanded) "Expanded" else "Collapsed" } + .padding(start = 4.dp, end = 8.dp), + horizontalArrangement = Arrangement.spacedBy(7.dp), + verticalAlignment = Alignment.CenterVertically, ) { - AuroraTextField( - label = "Server address", - value = state.serverUrl, - onValueChange = viewModel::onServerUrlChanged, - placeholder = "media.example.com", - keyboardType = KeyboardType.Uri, - imeAction = ImeAction.Go, - onImeAction = viewModel::onConnectClick, - ) - - // Advanced options disclosure. - val chevronRotation by animateFloatAsState( - targetValue = if (showAdvanced) 180f else 0f, - label = "advancedChevron", - ) - AuroraGhostButton( - label = "Advanced options", - onClick = { showAdvanced = !showAdvanced }, - fillMaxWidth = true, - trailing = { - Icon( - imageVector = Icons.Filled.KeyboardArrowDown, - contentDescription = null, - tint = AuroraInk.copy(alpha = 0.62f), - modifier = Modifier - .size(16.dp) - .rotate(chevronRotation), - ) - }, + Icon(Icons.Outlined.Settings, contentDescription = null, tint = MarqueeColors.InkSecondary, modifier = Modifier.size(16.dp)) + Text("Advanced options", color = MarqueeColors.InkSecondary, fontSize = 14.sp) + Icon( + Icons.Filled.KeyboardArrowDown, + contentDescription = null, + tint = MarqueeColors.InkSecondary, + modifier = Modifier.size(16.dp).rotate(chevron), ) - - AnimatedVisibility(visible = showAdvanced) { - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - AuroraFieldLabel("Protocol") - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - listOf( - ServerSetupScheme.Auto, - ServerSetupScheme.Https, - ServerSetupScheme.Http, - ).forEach { scheme -> - AuroraSegment( - title = scheme.label, - isSelected = state.selectedScheme == scheme, - onClick = { viewModel.onSchemeSelected(scheme) }, - modifier = Modifier.weight(1f), - ) + } + AnimatedVisibility(visible = expanded) { + Column(Modifier.padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + val shape = RoundedCornerShape(MarqueeMetrics.FieldCorner) + Column( + Modifier + .fillMaxWidth() + .clip(shape) + .background(Color.White.copy(alpha = 0.07f)) + .border(1.dp, Color.White.copy(alpha = 0.12f), shape), + ) { + Row( + Modifier.fillMaxWidth().height(MarqueeMetrics.FieldHeight).padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Protocol", color = MarqueeColors.Ink, fontSize = MarqueeMetrics.FieldFont, modifier = Modifier.weight(1f)) + SegmentedPicker( + options = ServerSetupScheme.entries, + selected = scheme, + label = { it.label }, + onSelect = onSchemeSelected, + ) + } + MarqueeSeparator() + Row( + Modifier.fillMaxWidth().height(MarqueeMetrics.FieldHeight).padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Port", color = MarqueeColors.Ink, fontSize = MarqueeMetrics.FieldFont, modifier = Modifier.weight(1f)) + Box(Modifier.width(120.dp), contentAlignment = Alignment.CenterEnd) { + if (port.isEmpty()) { + Text("Auto", color = MarqueeColors.InkTertiary, fontSize = MarqueeMetrics.FieldFont) } + BasicTextField( + value = port, + onValueChange = onPortChanged, + singleLine = true, + textStyle = TextStyle(color = MarqueeColors.Ink, fontSize = MarqueeMetrics.FieldFont, textAlign = TextAlign.End), + cursorBrush = SolidColor(Color(0xFF0A84FF)), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), + modifier = Modifier.fillMaxWidth().semantics { contentDescription = "Port" }, + ) } } - AuroraTextField( - label = "Port", - value = state.port, - onValueChange = viewModel::onPortChanged, - placeholder = "8090", - keyboardType = KeyboardType.Number, - ) } + Text( + "Auto tries HTTPS first, then HTTP, then Prairie's port 8090.", + color = MarqueeColors.InkTertiary, + fontSize = 12.sp, + modifier = Modifier.padding(start = 4.dp), + ) } + } + } +} - state.error?.let { error -> - AuroraErrorLabel(error) +/** A small iOS-style segmented control. */ +@Composable +private fun SegmentedPicker(options: List, selected: T, label: (T) -> String, onSelect: (T) -> Unit) { + Row( + Modifier + .width(190.dp) + .height(32.dp) + .clip(RoundedCornerShape(9.dp)) + .background(Color.White.copy(alpha = 0.10f)) + .padding(2.dp), + ) { + options.forEach { option -> + val isSelected = option == selected + Box( + Modifier + .weight(1f) + .height(28.dp) + .clip(RoundedCornerShape(7.dp)) + .background(if (isSelected) Color.White.copy(alpha = 0.28f) else Color.Transparent) + .clickable(role = Role.RadioButton) { onSelect(option) }, + contentAlignment = Alignment.Center, + ) { + Text( + label(option), + color = MarqueeColors.Ink, + fontSize = 13.sp, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, + maxLines = 1, + ) } + } + } +} - if (state.usesCleartext && state.error == null) { - androidx.compose.material3.Text( - modifier = androidx.compose.ui.Modifier.fillMaxWidth(), - text = "This server uses unencrypted HTTP. Fine on a trusted home network; " + - "avoid it on public Wi-Fi.", - fontSize = 12.sp, - color = org.prairieserver.prairie.android.ui.theme.PrairieWarning, +@Composable +private fun RecentServers( + servers: List, + brandingCache: ServerBrandingCache, + enabled: Boolean, + onPick: (ServerEntry) -> Unit, +) { + val shape = RoundedCornerShape(16.dp) + Column( + Modifier + .fillMaxWidth() + .clip(shape) + .background(Color.White.copy(alpha = 0.07f)) + .border(1.dp, Color.White.copy(alpha = 0.12f), shape), + ) { + servers.forEachIndexed { index, server -> + if (index > 0) MarqueeSeparator() + val host = ServerBranding.hostLabel(server.url) + val interaction = remember { MutableInteractionSource() } + Row( + Modifier + .fillMaxWidth() + .clickable(enabled = enabled, role = Role.Button, interactionSource = interaction, indication = null) { onPick(server) } + .marqueePressHaptic(interaction) + .semantics { contentDescription = "${server.displayName}, $host" } + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MarqueeServerMark( + name = server.fetchedName, + imageUrl = brandingCache.branding(server.url)?.markUrl, + size = 40.dp, + ) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + server.displayName, + color = MarqueeColors.Ink, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text(host, color = MarqueeColors.InkTertiary, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MarqueeColors.InkTertiary, + modifier = Modifier.size(18.dp), ) } - - AuroraPrimaryButton( - label = if (state.isLoading) "Connecting…" else "Connect", - onClick = viewModel::onConnectClick, - icon = Icons.Default.Link, - isLoading = state.isLoading, - modifier = Modifier.padding(top = 4.dp), - ) } } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/ServerSetupViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/ServerSetupViewModel.kt index 64e60673dc..e46c4a8be0 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/ServerSetupViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/ServerSetupViewModel.kt @@ -49,6 +49,9 @@ sealed class ServerSetupDestination { /** Server is ready -- go to login. Signup may or may not be available. */ data class Login(val signupEnabled: Boolean) : ServerSetupDestination() + + /** A saved server this device is still signed in to: straight to its profiles. */ + data object Profiles : ServerSetupDestination() } class ServerSetupViewModel( @@ -67,22 +70,34 @@ class ServerSetupViewModel( private val candidateUrls: (ServerSetupUiState) -> List = { buildServerSetupCandidateUrls(it.serverUrl, it.selectedScheme, it.port) }, + /** Whether the now-active server already has a signed-in session, as when a saved server is picked from Recent. */ + private val hasSession: suspend () -> Boolean = { false }, ) : ViewModel() { private val _uiState = MutableStateFlow(ServerSetupUiState()) val uiState: StateFlow = _uiState.asStateFlow() private var pendingCleartextConnection: PendingCleartextConnection? = null + /** Set by [prefill]: the saved URL must not replace an address the caller supplied. */ + private var prefilled = false + init { // Pre-populate with previously saved server URL, if any. viewModelScope.launch { val saved = authRepository.getServerUrl() - if (saved.isNotBlank()) { + if (saved.isNotBlank() && !prefilled) { _uiState.update { it.copy(serverUrl = saved) } } } } + /** Start from [url] (an app link's server) instead of the saved address. */ + fun prefill(url: String) { + if (prefilled) return + prefilled = true + _uiState.update { it.copy(serverUrl = url, error = null) } + } + fun onServerUrlChanged(url: String) { _uiState.update { it.copy(serverUrl = url, error = null) } } @@ -95,6 +110,15 @@ class ServerSetupViewModel( _uiState.update { it.copy(port = port, error = null) } } + /** + * Fills in a saved server's address. Its URL already names the scheme and + * port, so a protocol or port chosen for an earlier attempt must not + * override them. + */ + fun useRecent(url: String) { + _uiState.update { it.copy(serverUrl = url, selectedScheme = ServerSetupScheme.Auto, port = "", error = null) } + } + /** * Validates the entered server URL: * 1. Build candidate URLs from the host, protocol, and optional port. @@ -247,11 +271,18 @@ class ServerSetupViewModel( contract: ServerContract?, ) { authRepository.setServerUrl(serverUrl, contract) + // A saved server that is still signed in goes straight to its + // profiles; only a server without a session needs sign-in. + val next = if (destination is ServerSetupDestination.Login && hasSession()) { + ServerSetupDestination.Profiles + } else { + destination + } _uiState.update { it.copy( isLoading = false, pendingCleartextUrl = null, - navigateTo = destination, + navigateTo = next, ) } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/SetupScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/SetupScreen.kt index ba01e93d73..ed7c062a64 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/SetupScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/SetupScreen.kt @@ -1,49 +1,36 @@ package org.prairieserver.prairie.android.ui.screens.auth -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PersonAdd -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Text +import androidx.compose.material.icons.outlined.Email +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.Person import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import org.prairieserver.prairie.android.ui.components.aurora.AuroraEyebrow -import org.prairieserver.prairie.android.ui.components.aurora.AuroraErrorLabel -import org.prairieserver.prairie.android.ui.components.aurora.AuroraPrimaryButton -import org.prairieserver.prairie.android.ui.components.aurora.AuroraScreen -import org.prairieserver.prairie.android.ui.components.aurora.AuroraScrim -import org.prairieserver.prairie.android.ui.components.aurora.AuroraTextField -import org.prairieserver.prairie.android.ui.components.aurora.AuroraVariant -import org.prairieserver.prairie.android.ui.components.aurora.auroraGlass import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeButton +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeErrorHaptic +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeErrorText +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeFieldGroup +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeHeadline +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeSeparator +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeStage +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeTextField +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeWordmark +import org.prairieserver.prairie.common.ui.marquee.MarqueeScrimStyle /** - * First-run admin account creation — an Android capability (iOS points to the - * browser for this); kept and reskinned in the Aurora grammar to match the rest - * of onboarding. Wordmark, "Setup needed" eyebrow + "Welcome to Prairie", glass - * card with Username / Email / Password + cream "Create account". + * First-run admin account creation on a server that has none yet. An Android + * capability (the Apple apps send the person to the browser for this), laid + * out like the other first-run screens: Username, Email and Password as one + * grouped block and "Create account". */ @Composable fun SetupScreen( @@ -51,7 +38,6 @@ fun SetupScreen( viewModel: SetupViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() - var showPassword by remember { mutableStateOf(false) } LaunchedEffect(state.setupSuccess) { if (state.setupSuccess) { @@ -59,82 +45,51 @@ fun SetupScreen( onNavigateToProfiles() } } + MarqueeErrorHaptic(state.error) - AuroraScreen(variant = AuroraVariant.Server, scrim = AuroraScrim.Soft) { - PrairieLogo() - - Spacer(Modifier.height(30.dp)) - AuroraEyebrow(text = "Setup needed", centered = true) - Spacer(Modifier.height(12.dp)) - Text( - text = "Welcome to Prairie", - fontSize = 30.sp, - fontWeight = FontWeight.SemiBold, - color = Color(0xFFF3EFE9), - ) - Spacer(Modifier.height(6.dp)) - Text( - text = "Create your first admin profile.", - fontSize = 15.sp, - color = Color(0xFFF3EFE9).copy(alpha = 0.7f), + MarqueeStage( + scrim = MarqueeScrimStyle.BottomDeep, + frostStart = 0.40f, + topBar = { MarqueeWordmark() }, + ) { + MarqueeHeadline( + title = "Set up\nyour server", + lead = "This server has no accounts yet. Create the first one; it becomes the admin.", ) - Spacer(Modifier.height(24.dp)) - - Column( - modifier = Modifier - .fillMaxWidth() - .auroraGlass(cornerRadius = 24.dp, emphasized = true) - .padding(22.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - AuroraTextField( - label = "Username", + MarqueeFieldGroup(isError = state.error != null, modifier = Modifier.padding(top = 24.dp)) { + MarqueeTextField( value = state.username, onValueChange = viewModel::onUsernameChanged, - placeholder = "yourname", - imeAction = ImeAction.Next, + icon = Icons.Outlined.Person, + placeholder = "Username", ) - AuroraTextField( - label = "Email", + MarqueeSeparator() + MarqueeTextField( value = state.email, onValueChange = viewModel::onEmailChanged, - placeholder = "you@example.com", + icon = Icons.Outlined.Email, + placeholder = "Email", keyboardType = KeyboardType.Email, - imeAction = ImeAction.Next, ) - AuroraTextField( - label = "Password", + MarqueeSeparator() + MarqueeTextField( value = state.password, onValueChange = viewModel::onPasswordChanged, - placeholder = "••••••", - keyboardType = KeyboardType.Password, + icon = Icons.Outlined.Lock, + placeholder = "Password", + isSecure = true, imeAction = ImeAction.Go, onImeAction = viewModel::onCreateAccountClick, - visualTransformation = if (showPassword) { - VisualTransformation.None - } else { - PasswordVisualTransformation() - }, - trailing = { - IconButton(onClick = { showPassword = !showPassword }) { - Icon( - imageVector = if (showPassword) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, - contentDescription = if (showPassword) "Hide password" else "Show password", - tint = Color.White.copy(alpha = 0.62f), - ) - } - }, - ) - - state.error?.let { AuroraErrorLabel(it) } - - AuroraPrimaryButton( - label = if (state.isLoading) "Creating…" else "Create account", - onClick = viewModel::onCreateAccountClick, - icon = Icons.Default.PersonAdd, - isLoading = state.isLoading, - modifier = Modifier.fillMaxWidth(), ) } + state.error?.let { MarqueeErrorText(it, Modifier.padding(top = 10.dp)) } + MarqueeButton( + text = if (state.isLoading) "Creating…" else "Create account", + onClick = viewModel::onCreateAccountClick, + icon = Icons.Default.PersonAdd, + isLoading = state.isLoading, + enabled = !state.isLoading, + modifier = Modifier.padding(top = 14.dp), + ) } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/SignInLabels.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/SignInLabels.kt new file mode 100644 index 0000000000..b93a592180 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/SignInLabels.kt @@ -0,0 +1,25 @@ +package org.prairieserver.prairie.android.ui.screens.auth + +import org.prairieserver.prairie.model.auth.SignInProvider + +/** "Sign in with ". */ +fun signInWithLabel(provider: SignInProvider): String = "Sign in with ${provider.displayName}" + +/** + * A network provider's button: "Continue as " when the provider named + * who owns this device, "Continue with " when it named nobody. + */ +fun continueAsLabel(provider: SignInProvider): String = + provider.networkIdentity?.label?.let { "Continue as $it" } ?: "Continue with ${provider.displayName}" + +/** The line under "Continue as " naming the provider; none when the label already names it. */ +fun continueViaLabel(provider: SignInProvider): String? = + provider.networkIdentity?.label?.let { "via ${provider.displayName}" } + +/** + * The account-choice link under the provider buttons. It picks another + * account at the provider, not another way into Prairie, so it names the + * provider when there is one; with several, a chooser follows. + */ +fun differentAccountLabel(providers: List): String = + providers.singleOrNull()?.let { "Use a different ${it.displayName} account" } ?: "Use a different account" diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/SignupScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/SignupScreen.kt index 911debba80..5a449d5c71 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/SignupScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/SignupScreen.kt @@ -1,51 +1,37 @@ package org.prairieserver.prairie.android.ui.screens.auth -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.PersonAdd -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Text +import androidx.compose.material.icons.outlined.ConfirmationNumber +import androidx.compose.material.icons.outlined.Email +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.Person import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import org.prairieserver.prairie.android.ui.components.aurora.AuroraEyebrow -import org.prairieserver.prairie.android.ui.components.aurora.AuroraErrorLabel -import org.prairieserver.prairie.android.ui.components.aurora.AuroraGhostButton -import org.prairieserver.prairie.android.ui.components.aurora.AuroraPrimaryButton -import org.prairieserver.prairie.android.ui.components.aurora.AuroraScreen -import org.prairieserver.prairie.android.ui.components.aurora.AuroraScrim -import org.prairieserver.prairie.android.ui.components.aurora.AuroraTextField -import org.prairieserver.prairie.android.ui.components.aurora.AuroraVariant -import org.prairieserver.prairie.android.ui.components.aurora.auroraGlass import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeButton +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeErrorHaptic +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeErrorText +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeFieldGroup +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeHeadline +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeIconButton +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeSeparator +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeStage +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeTextField +import org.prairieserver.prairie.common.ui.marquee.MarqueeScrimStyle /** - * Account registration. Mirrors prairie-apple iOS phone `SignupView` (Aurora): - * wordmark, "Create account" eyebrow + "Create your account", glass card with - * Username / Email / Password / Invite code, cream "Create account" button, and - * a "Back to sign in" ghost. (Android VM has no confirm-password field, so it - * is omitted rather than adding VM state.) + * Account registration with an invite code, laid out like the other + * first-run screens: Username, Email, Password and Invite code as one grouped + * block and "Create account". Back returns to sign-in. */ @Composable fun SignupScreen( @@ -54,7 +40,6 @@ fun SignupScreen( viewModel: SignupViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() - var showPassword by remember { mutableStateOf(false) } LaunchedEffect(state.signupSuccess) { if (state.signupSuccess) { @@ -62,89 +47,59 @@ fun SignupScreen( onNavigateToProfiles() } } + MarqueeErrorHaptic(state.error) - AuroraScreen(variant = AuroraVariant.SignIn, scrim = AuroraScrim.Soft) { - PrairieLogo() - - Spacer(Modifier.height(30.dp)) - AuroraEyebrow(text = "Create account", centered = true) - Spacer(Modifier.height(12.dp)) - Text( - text = "Create your account", - fontSize = 30.sp, - fontWeight = FontWeight.SemiBold, - color = Color(0xFFF3EFE9), - ) - Spacer(Modifier.height(24.dp)) - - Column( - modifier = Modifier - .fillMaxWidth() - .auroraGlass(cornerRadius = 24.dp, emphasized = true) - .padding(22.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - AuroraTextField( - label = "Username", + MarqueeStage( + scrim = MarqueeScrimStyle.BottomDeep, + frostStart = 0.40f, + onBack = onNavigateToLogin, + blockBack = state.isLoading, + topBar = { + MarqueeIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back to sign in", onNavigateToLogin, enabled = !state.isLoading) + }, + ) { + MarqueeHeadline(title = "Create your\naccount", lead = "You'll need an invite code from the server's admin.") + MarqueeFieldGroup(isError = state.error != null, modifier = Modifier.padding(top = 24.dp)) { + MarqueeTextField( value = state.username, onValueChange = viewModel::onUsernameChanged, - placeholder = "yourname", - imeAction = ImeAction.Next, + icon = Icons.Outlined.Person, + placeholder = "Username", ) - AuroraTextField( - label = "Email", + MarqueeSeparator() + MarqueeTextField( value = state.email, onValueChange = viewModel::onEmailChanged, - placeholder = "you@example.com", + icon = Icons.Outlined.Email, + placeholder = "Email", keyboardType = KeyboardType.Email, - imeAction = ImeAction.Next, ) - AuroraTextField( - label = "Password", + MarqueeSeparator() + MarqueeTextField( value = state.password, onValueChange = viewModel::onPasswordChanged, - placeholder = "••••••", - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Next, - visualTransformation = if (showPassword) { - VisualTransformation.None - } else { - PasswordVisualTransformation() - }, - trailing = { - IconButton(onClick = { showPassword = !showPassword }) { - Icon( - imageVector = if (showPassword) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, - contentDescription = if (showPassword) "Hide password" else "Show password", - tint = Color.White.copy(alpha = 0.62f), - ) - } - }, + icon = Icons.Outlined.Lock, + placeholder = "Password", + isSecure = true, ) - AuroraTextField( - label = "Invite code", + MarqueeSeparator() + MarqueeTextField( value = state.inviteCode, onValueChange = viewModel::onInviteCodeChanged, - placeholder = "ABCD-1234", + icon = Icons.Outlined.ConfirmationNumber, + placeholder = "Invite code", imeAction = ImeAction.Go, onImeAction = viewModel::onSignupClick, ) - - state.error?.let { AuroraErrorLabel(it) } - - AuroraPrimaryButton( - label = if (state.isLoading) "Creating…" else "Create account", - onClick = viewModel::onSignupClick, - icon = Icons.Default.PersonAdd, - isLoading = state.isLoading, - modifier = Modifier.fillMaxWidth(), - ) - - AuroraGhostButton( - label = "Back to sign in", - onClick = onNavigateToLogin, - fillMaxWidth = true, - ) } + state.error?.let { MarqueeErrorText(it, Modifier.padding(top = 10.dp)) } + MarqueeButton( + text = if (state.isLoading) "Creating…" else "Create account", + onClick = viewModel::onSignupClick, + icon = Icons.Default.PersonAdd, + isLoading = state.isLoading, + enabled = !state.isLoading, + modifier = Modifier.padding(top = 14.dp), + ) } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/book/BookDetailContent.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/book/BookDetailContent.kt index 669bd88e4a..7e855f73eb 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/book/BookDetailContent.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/book/BookDetailContent.kt @@ -43,6 +43,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.prairieserver.prairie.android.ui.screens.detail.CircleActionButton +import org.prairieserver.prairie.common.ui.components.DefaultArtworkKind import org.prairieserver.prairie.common.ui.components.ThumbhashImage import org.prairieserver.prairie.model.book.BookFormat import org.prairieserver.prairie.model.catalog.FileVersion @@ -187,6 +188,7 @@ fun BookDetailContent( .width(BookCoverWidthDp.dp) .aspectRatio(2f / 3f) .clip(RoundedCornerShape(12.dp)), + defaultArtwork = DefaultArtworkKind.forItemType(detail.type), ) Text( text = detail.title, diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/browse/BrowsePrefsStore.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/browse/BrowsePrefsStore.kt deleted file mode 100644 index b2a046c0b5..0000000000 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/browse/BrowsePrefsStore.kt +++ /dev/null @@ -1,58 +0,0 @@ -package org.prairieserver.prairie.android.ui.screens.browse - -import android.content.Context -import kotlinx.serialization.json.Json -import org.prairieserver.prairie.catalog.filter.CatalogFilterState -import org.prairieserver.prairie.network.ServerRegistry - -/** - * Persists browse filter + sort state per server, per profile, per library — - * mirrors iOS `BrowsePrefsStore` (`ios.browsePrefs...`, - * here `android.browsePrefs.…`). Nothing is persisted without an active - * profile, so anonymous browsing never leaks into a profile's saved state. - * - * Gated by a user-facing "Preserve sort & filters" toggle (default ON); - * turning it off clears the saved state. - */ -class BrowsePrefsStore( - context: Context, - private val serverRegistry: ServerRegistry, -) { - private val prefs = context.getSharedPreferences("browse_prefs", Context.MODE_PRIVATE) - private val json = Json { ignoreUnknownKeys = true } - - private fun base(libraryId: Int?): String? { - val serverId = serverRegistry.activeServerId.value ?: "default" - val profileId = serverRegistry.activeEntry.value?.profileId - ?.takeIf { it.isNotBlank() } ?: return null - val lib = libraryId?.toString() ?: "all" - return "android.browsePrefs.$serverId.$profileId.$lib" - } - - fun savedState(libraryId: Int?): CatalogFilterState? { - if (!preserveEnabled(libraryId)) return null - val key = base(libraryId) ?: return null - val raw = prefs.getString("$key.state", null) ?: return null - return runCatching { json.decodeFromString(raw) }.getOrNull() - } - - fun saveState(libraryId: Int?, state: CatalogFilterState) { - if (!preserveEnabled(libraryId)) return - val key = base(libraryId) ?: return - prefs.edit().putString("$key.state", json.encodeToString(state)).apply() - } - - /** Default ON when the key is absent (iOS parity). */ - fun preserveEnabled(libraryId: Int?): Boolean { - val key = base(libraryId) ?: return false - return prefs.getBoolean("$key.preserve", true) - } - - fun setPreserveEnabled(libraryId: Int?, enabled: Boolean) { - val key = base(libraryId) ?: return - prefs.edit().apply { - putBoolean("$key.preserve", enabled) - if (!enabled) remove("$key.state") - }.apply() - } -} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/browse/BrowseScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/browse/BrowseScreen.kt index 988b1e9f20..6b915232e9 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/browse/BrowseScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/browse/BrowseScreen.kt @@ -47,6 +47,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.prairieserver.prairie.android.ui.components.PosterGridSkeleton +import org.prairieserver.prairie.android.ui.components.PrairieDropdownMenu +import org.prairieserver.prairie.android.ui.components.PrairieDropdownMenuItem import org.prairieserver.prairie.android.ui.components.rememberShimmerProgress import org.prairieserver.prairie.android.ui.theme.PrairieSurfaceElevated import org.prairieserver.prairie.android.ui.util.formatCardDate @@ -101,13 +103,13 @@ fun BrowseScreen( contentDescription = "Sort", ) } - androidx.compose.material3.DropdownMenu( + PrairieDropdownMenu( expanded = sortMenuOpen, onDismissRequest = { sortMenuOpen = false }, ) { browseSortOptions(state.mediaType).forEach { (field, label) -> val active = state.filterState.sort == field - androidx.compose.material3.DropdownMenuItem( + PrairieDropdownMenuItem( text = { Text( text = if (active) { @@ -235,13 +237,17 @@ fun BrowseScreen( modifier = Modifier.size(64.dp), ) Text( - text = "No items found", + text = if (state.libraryHasItems == false) "This library is empty" else "No items found", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) Text( - text = "Try adjusting your filters", + text = if (state.libraryHasItems == false) { + "There is nothing in this library yet." + } else { + "Try adjusting your filters" + }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), textAlign = TextAlign.Center, diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/browse/BrowseViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/browse/BrowseViewModel.kt index ccffe2a460..a6d49a00be 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/browse/BrowseViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/browse/BrowseViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import org.prairieserver.prairie.catalog.filter.BrowseFacetMediaType import org.prairieserver.prairie.catalog.filter.CatalogFilterQueryBuilder import org.prairieserver.prairie.catalog.filter.CatalogFilterState +import org.prairieserver.prairie.common.settings.BrowsePrefsStore import org.prairieserver.prairie.model.catalog.BrowseItem import org.prairieserver.prairie.model.catalog.CatalogFiltersResponse import org.prairieserver.prairie.model.catalog.MediaItemUserState @@ -31,6 +32,8 @@ data class BrowseUiState( val items: List = emptyList(), val hasMore: Boolean = false, val total: Int = 0, + /** For an empty library browse: false when the library itself is empty, true when filters hid everything, null when unknown (#451). */ + val libraryHasItems: Boolean? = null, val filterState: CatalogFilterState = CatalogFilterState(), val mediaType: BrowseFacetMediaType = BrowseFacetMediaType.Video, val preserveFilters: Boolean = true, @@ -219,6 +222,15 @@ class BrowseViewModel( // Overlay local optimistic watched/favorite so an offline mutation // shows immediately on the cached grid (mirrors Home). val overlaid = overlayLocalState(response.items) + // An empty first page of a library only says this view matched + // nothing (#451): with nothing narrowing it, the page was the + // whole library; otherwise one unfiltered item decides. + val libraryId = currentState.libraryId + val libraryHasItems = when { + !reset || overlaid.isNotEmpty() || libraryId == null -> null + !filters.hasActiveFilters && currentState.selectedNamePrefix == null -> false + else -> catalogRepository.libraryHasItems(libraryId) + } kotlin.coroutines.coroutineContext.ensureActive() continuation = response.continuation // Audiobook libraries surface book-native facets @@ -238,6 +250,7 @@ class BrowseViewModel( items = if (reset) overlaid else it.items + overlaid, hasMore = response.hasMore, total = response.total, + libraryHasItems = if (reset) libraryHasItems else it.libraryHasItems, title = response.title ?: "Browse", mediaType = mediaType ?: it.mediaType, error = null, diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/browse/CatalogGrid.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/browse/CatalogGrid.kt index db5ca0cc78..25d5630c82 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/browse/CatalogGrid.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/browse/CatalogGrid.kt @@ -1,5 +1,8 @@ package org.prairieserver.prairie.android.ui.screens.browse +import org.prairieserver.prairie.common.cards.LocalEpisodeSpoilerPrefs +import org.prairieserver.prairie.model.settings.EpisodeSpoilers + import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -165,8 +168,13 @@ fun CatalogGrid( key = { it.contentId }, contentType = { item -> item.type }, ) { item -> - val (actions, userState) = rememberBrowseItemCardActions(item) + val (actions, userState, positionSeconds) = rememberBrowseItemCardActions(item) + val spoilerPrefs = LocalEpisodeSpoilerPrefs.current + val hideArtwork = EpisodeSpoilers.hidesBrowseArtwork(item, spoilerPrefs, userState.played, positionSeconds = positionSeconds) + val hideDetailArtwork = EpisodeSpoilers.hidesBrowseArtwork(item, spoilerPrefs, userState.played, selectBackdrop = true, positionSeconds = positionSeconds) MediaCard( + hideArtwork = hideArtwork, + hideDetailArtwork = hideDetailArtwork, title = item.title, posterUrl = item.posterUrl, posterThumbhash = item.posterThumbhash, @@ -175,8 +183,8 @@ fun CatalogGrid( type = item.type, userState = userState, onClick = { - heroHandoff?.pendingArtworkUrl = item.backdropUrl ?: item.posterUrl - heroHandoff?.pendingArtworkThumbhash = item.backdropThumbhash ?: item.posterThumbhash + heroHandoff?.pendingArtworkUrl = (item.backdropUrl ?: item.posterUrl).takeUnless { hideDetailArtwork } + heroHandoff?.pendingArtworkThumbhash = if (hideDetailArtwork) null else item.backdropThumbhash ?: item.posterThumbhash onItemClick(item.contentId) }, width = cardWidth, diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/calendar/CalendarScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/calendar/CalendarScreen.kt index 893aa4a1b3..c9f9a2d6af 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/calendar/CalendarScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/calendar/CalendarScreen.kt @@ -83,6 +83,7 @@ import org.prairieserver.prairie.android.ui.navigation.LocalBottomChromeInset import org.prairieserver.prairie.common.calendar.localDisplayAirTime import org.prairieserver.prairie.common.cards.LocalCardPresentation import org.prairieserver.prairie.common.ui.components.DeferImagePresentationWhileScrolling +import org.prairieserver.prairie.common.ui.components.DefaultArtworkKind import org.prairieserver.prairie.common.ui.components.ThumbhashImage import org.prairieserver.prairie.model.calendar.CalendarBadge import org.prairieserver.prairie.model.calendar.CalendarFilter @@ -707,6 +708,7 @@ private fun CalendarEventCard( thumbhash = item.posterThumbhash, contentDescription = item.title, modifier = Modifier.fillMaxSize(), + defaultArtwork = DefaultArtworkKind.forItemType(item.type), ) // Badge pills, top-leading. overlayPadding (phone) = 6. diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/cast/PrairieCastLaunchHaptics.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/cast/PrairieCastLaunchHaptics.kt new file mode 100644 index 0000000000..38800d9a0c --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/cast/PrairieCastLaunchHaptics.kt @@ -0,0 +1,40 @@ +package org.prairieserver.prairie.android.ui.screens.cast + +import android.os.Build +import android.view.HapticFeedbackConstants +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalView +import org.prairieserver.prairie.android.cast.PrairieCastController +import org.prairieserver.prairie.android.cast.PrairieCastLaunchFeedback + +/** + * Haptics for a Play sent to a TV, wherever the user is in the app: a light + * tick when it goes out, success once the TV reports the title, and an error + * if it fails. Mirrors prairie-apple's remote. + */ +@Composable +fun PrairieCastLaunchHaptics(controller: PrairieCastController) { + val view = LocalView.current + LaunchedEffect(controller, view) { + controller.launchFeedback.collect { feedback -> + view.performHapticFeedback( + when (feedback) { + PrairieCastLaunchFeedback.Sent -> HapticFeedbackConstants.CLOCK_TICK + PrairieCastLaunchFeedback.Started -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + HapticFeedbackConstants.CONFIRM + } else { + HapticFeedbackConstants.LONG_PRESS + } + PrairieCastLaunchFeedback.Failed -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + HapticFeedbackConstants.REJECT + } else { + HapticFeedbackConstants.LONG_PRESS + } + }, + ) + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/cast/PrairieCastMiniBar.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/cast/PrairieCastMiniBar.kt index 9827e94c08..0b026cad58 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/cast/PrairieCastMiniBar.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/cast/PrairieCastMiniBar.kt @@ -37,11 +37,12 @@ import org.prairieserver.prairie.android.cast.PrairieCastController import org.prairieserver.prairie.common.ui.components.ThumbhashImage /** - * Persistent "Playing on " bar shown above the tab content whenever a TV + * Persistent Remote Control bar shown above the tab content whenever a TV * control session is active and the full remote is dismissed. Mirrors - * prairie-apple's `PrairieControlMiniBar`: poster thumb, title, target line, and a - * trailing play/pause button (spinner while reconnecting). Tapping reopens - * the remote; disconnect lives in the remote's menu, not here. + * prairie-apple's `PrairieControlMiniBar`: poster thumb, title, target line + * ("Playing on", "Starting on", or "Connected / to" while the TV is idle), + * and a trailing play/pause button (spinner while reconnecting or starting). + * Tapping reopens the remote; disconnect lives in the remote's menu, not here. * * Inset handling is the caller's: on tab screens the bar sits in the * Scaffold's bottomBar slot directly above the nav menu (which owns the @@ -55,6 +56,8 @@ fun PrairieCastMiniBar( ) { val state by controller.state.collectAsState() val playback = state.playbackState + val launch = state.launch + val isIdle = playback?.contentId.isNullOrEmpty() val targetName = state.connectedTarget?.name ?: "Prairie TV" // Keep the bar up through a reconnect (with a spinner) instead of having // it vanish and pop back; hide it while an auto-resume probe is still @@ -67,7 +70,7 @@ fun PrairieCastMiniBar( exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), modifier = modifier, ) { - val artwork = rememberPrairieCastArtwork(playback?.contentId) + val artwork = rememberPrairieCastArtwork(launch?.contentId ?: playback?.contentId) Surface( onClick = onOpenRemote, modifier = Modifier @@ -87,13 +90,23 @@ fun PrairieCastMiniBar( Column(modifier = Modifier.weight(1f)) { Text( - if (state.isReconnecting) "Reconnecting…" else (playback?.title ?: "Connected"), + when { + state.isReconnecting -> "Reconnecting…" + launch != null -> artwork.title ?: "Starting playback" + isIdle -> "Connected" + else -> playback?.title ?: "Connected" + }, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleSmall, ) Text( - if (state.isReconnecting) "to $targetName" else "Playing on $targetName", + when { + state.isReconnecting -> "to $targetName" + launch != null -> "Starting on $targetName…" + isIdle -> "to $targetName" + else -> "Playing on $targetName" + }, maxLines = 1, overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -101,7 +114,7 @@ fun PrairieCastMiniBar( ) } - if (state.isReconnecting) { + if (state.isReconnecting || launch != null) { CircularProgressIndicator( modifier = Modifier.size(22.dp), strokeWidth = 2.dp, diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/cast/PrairieCastRemoteScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/cast/PrairieCastRemoteScreen.kt index ef753face8..d260190ad6 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/cast/PrairieCastRemoteScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/cast/PrairieCastRemoteScreen.kt @@ -1,5 +1,8 @@ package org.prairieserver.prairie.android.ui.screens.cast +import org.prairieserver.prairie.android.ui.components.PrairieDropdownMenuItem +import org.prairieserver.prairie.android.ui.components.PrairieDropdownMenu +import org.prairieserver.prairie.android.ui.components.PrairieConfirmDialog import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -43,10 +46,7 @@ import androidx.compose.material.icons.outlined.Speed import androidx.compose.material.icons.outlined.Tune import androidx.compose.material.icons.outlined.Tv import androidx.compose.material.icons.outlined.TvOff -import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -56,7 +56,6 @@ import androidx.compose.material3.Slider import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -119,7 +118,9 @@ fun PrairieCastRemoteScreen( ) { val state by controller.state.collectAsState() val playback = state.playbackState - val artwork = rememberPrairieCastArtwork(playback?.contentId) + val launch = state.launch + // A title on its way to the TV shows its own artwork, not the outgoing one. + val artwork = rememberPrairieCastArtwork(launch?.contentId ?: playback?.contentId) val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current var showTargetPicker by remember { mutableStateOf(false) } @@ -166,7 +167,6 @@ fun PrairieCastRemoteScreen( onChooseTv = { showTargetPicker = true }, onStopPlayback = { controller.stopPlayback() }, onSetVideoGravity = controller::setVideoGravity, - onSetHdrEnabled = controller::setHdrEnabled, onDisconnect = { controller.disconnect() onBack() @@ -183,6 +183,14 @@ fun PrairieCastRemoteScreen( ) { when { state.isReconnecting -> RemoteStatus(title = "Reconnecting…", showSpinner = true) + // From the tap until the TV reports the title, including + // over an idle TV or the title being replaced. + launch != null -> RemoteLaunching( + title = artwork.title, + targetName = state.connectedTarget?.name, + posterUrl = artwork.posterUrl ?: artwork.backdropUrl, + posterThumbhash = artwork.posterThumbhash ?: artwork.backdropThumbhash, + ) playback == null -> RemoteConnecting( targetName = state.connectedTarget?.name, // A fully torn-down session (TV disconnected, reconnect @@ -191,10 +199,6 @@ fun PrairieCastRemoteScreen( ?: "Not connected to a TV.".takeIf { state.connectedTarget == null && !state.isConnecting }, onChooseTv = { showTargetPicker = true }, ) - playback.contentId == null && state.isLaunching -> RemoteStatus( - title = "Starting playback on ${state.connectedTarget?.name ?: "Prairie TV"}…", - showSpinner = true, - ) playback.contentId == null -> RemoteIdleConnected( targetName = state.connectedTarget?.name, ) @@ -219,34 +223,21 @@ fun PrairieCastRemoteScreen( } if (showBatteryPrompt) { - AlertDialog( - onDismissRequest = { - RemoteControlBatteryOptimization.markPromptShown(context) - showBatteryPrompt = false - }, - title = { Text(stringResource(R.string.remote_battery_title)) }, - text = { Text(stringResource(R.string.remote_battery_message)) }, - confirmButton = { - TextButton( - onClick = { - RemoteControlBatteryOptimization.markPromptShown(context) - showBatteryPrompt = false - RemoteControlBatteryOptimization.openSettings(context) - }, - ) { - Text(stringResource(R.string.remote_battery_settings)) - } - }, - dismissButton = { - TextButton( - onClick = { - RemoteControlBatteryOptimization.markPromptShown(context) - showBatteryPrompt = false - }, - ) { - Text(stringResource(R.string.remote_battery_not_now)) - } + val dismissBatteryPrompt = { + RemoteControlBatteryOptimization.markPromptShown(context) + showBatteryPrompt = false + } + PrairieConfirmDialog( + title = stringResource(R.string.remote_battery_title), + body = stringResource(R.string.remote_battery_message), + confirmLabel = stringResource(R.string.remote_battery_settings), + dismissLabel = stringResource(R.string.remote_battery_not_now), + destructive = false, + onConfirm = { + dismissBatteryPrompt() + RemoteControlBatteryOptimization.openSettings(context) }, + onDismiss = dismissBatteryPrompt, ) } } @@ -280,7 +271,6 @@ private fun RemoteTopBar( onChooseTv: () -> Unit, onStopPlayback: () -> Unit, onSetVideoGravity: (String) -> Unit, - onSetHdrEnabled: (Boolean) -> Unit, onDisconnect: () -> Unit, showBatterySettings: Boolean, onBatterySettings: () -> Unit, @@ -310,8 +300,8 @@ private fun RemoteTopBar( tint = RemoteOnSurface, ) } - DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) { - DropdownMenuItem( + PrairieDropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) { + PrairieDropdownMenuItem( text = { Text("Choose a Different TV") }, leadingIcon = { Icon(Icons.Outlined.Tv, contentDescription = null) }, onClick = { @@ -319,7 +309,7 @@ private fun RemoteTopBar( onChooseTv() }, ) - DropdownMenuItem( + PrairieDropdownMenuItem( text = { Text("Stop Playback") }, leadingIcon = { Icon(Icons.Filled.Stop, contentDescription = null) }, onClick = { @@ -328,7 +318,7 @@ private fun RemoteTopBar( }, ) if (playback?.supportsVideoGravity == true) { - DropdownMenuItem( + PrairieDropdownMenuItem( text = { Text("Aspect Ratio") }, leadingIcon = { Icon(Icons.Outlined.AspectRatio, contentDescription = null) @@ -342,25 +332,8 @@ private fun RemoteTopBar( }, ) } - if (playback?.supportsHDRToggle == true) { - DropdownMenuItem( - text = { Text("HDR") }, - leadingIcon = { - Icon(Icons.Outlined.AspectRatio, contentDescription = null) - }, - trailingIcon = { - if (playback.hdrEnabled) { - Icon(Icons.Filled.Check, contentDescription = "Enabled") - } - }, - onClick = { - menuExpanded = false - onSetHdrEnabled(!playback.hdrEnabled) - }, - ) - } if (showBatterySettings) { - DropdownMenuItem( + PrairieDropdownMenuItem( text = { Text(stringResource(R.string.remote_battery_settings)) }, leadingIcon = { Icon(Icons.Outlined.SettingsRemote, contentDescription = null) @@ -372,7 +345,7 @@ private fun RemoteTopBar( ) } HorizontalDivider() - DropdownMenuItem( + PrairieDropdownMenuItem( text = { Text("Disconnect", color = MaterialTheme.colorScheme.error) }, leadingIcon = { Icon( @@ -387,7 +360,7 @@ private fun RemoteTopBar( }, ) } - DropdownMenu( + PrairieDropdownMenu( expanded = aspectMenuExpanded && playback?.supportsVideoGravity == true, onDismissRequest = { aspectMenuExpanded = false }, ) { @@ -401,7 +374,7 @@ private fun RemoteTopBar( .forEach { (id, label) -> val selected = playback?.videoGravity == id || (id == "fill" && playback?.videoGravity in listOf("zoom", "crop")) - DropdownMenuItem( + PrairieDropdownMenuItem( text = { Text(label) }, leadingIcon = { if (selected) { @@ -437,6 +410,49 @@ private fun RemoteStatus(title: String, showSpinner: Boolean) { } } +/** A Play on its way to the TV: the title's poster and "Starting on …". */ +@Composable +private fun RemoteLaunching( + title: String?, + targetName: String?, + posterUrl: String?, + posterThumbhash: String?, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(18.dp), + modifier = Modifier.padding(24.dp), + ) { + RemotePoster(posterUrl = posterUrl, posterThumbhash = posterThumbhash) + if (!title.isNullOrEmpty()) { + Text( + title, + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), + color = RemoteOnSurface, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + CircularProgressIndicator( + color = RemoteOnSurface, + strokeWidth = 2.dp, + modifier = Modifier.size(18.dp), + ) + Text( + "Starting on ${targetName ?: "Prairie TV"}…", + style = MaterialTheme.typography.titleMedium, + color = RemoteSecondary, + textAlign = TextAlign.Center, + ) + } + } +} + /** Connected with nothing playing — mirrors Apple's idle remote. */ @Composable private fun RemoteIdleConnected(targetName: String?) { @@ -980,12 +996,12 @@ private fun subtitleMenuEntries( } if (playback.supportsSubtitlePosition == true) { add(MenuEntry(label = "Position", selected = false, isSectionHeader = true)) - listOf("standard" to "Bottom", "lower-third" to "Lower Third", "top" to "Top").forEach { (id, label) -> + // tvOS SubtitlePositionPreset raw values; it rejects anything else. + listOf("bottom" to "Bottom", "lower-third" to "Lower Third", "top" to "Top").forEach { (id, label) -> add( MenuEntry( label = label, - selected = playback.subtitlePosition == id || - (id == "standard" && playback.subtitlePosition == "bottom"), + selected = playback.subtitlePosition == id, onClick = { controller.setSubtitlePosition(id) }, ), ) @@ -1024,7 +1040,7 @@ private fun RemoteChipMenu( color = if (enabled) RemoteOnSurface.copy(alpha = 0.9f) else RemoteSecondary.copy(alpha = 0.5f), ) } - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + PrairieDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { entries.forEach { entry -> if (entry.isSectionHeader) { Text( @@ -1034,7 +1050,7 @@ private fun RemoteChipMenu( modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), ) } else { - DropdownMenuItem( + PrairieDropdownMenuItem( text = { Text(entry.label) }, enabled = entry.enabled, leadingIcon = { diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/collections/CollectionDetailScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/collections/CollectionDetailScreen.kt index ebbefc0b2c..b2924443dc 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/collections/CollectionDetailScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/collections/CollectionDetailScreen.kt @@ -13,6 +13,9 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Shuffle +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material.icons.outlined.CollectionsBookmark import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -42,10 +45,14 @@ fun CollectionDetailScreen( collectionId: String, onBackClick: () -> Unit, onItemClick: (String) -> Unit, + // Plays the first pick of a shuffle started from the top bar. + onShuffleStarted: (org.prairieserver.prairie.model.shuffle.Shuffle) -> Unit = {}, viewModel: CollectionDetailViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() val gridState = rememberLazyGridState() + val shuffleLauncher = org.prairieserver.prairie.android.ui.screens.shuffle.rememberShuffleLauncher(onShuffleStarted) + val shuffleKind = viewModel.shuffleScopeKind LaunchedEffect(collectionId) { viewModel.initialize(collectionId) @@ -70,6 +77,16 @@ fun CollectionDetailScreen( PrairieTopBar( title = state.title, onBackClick = onBackClick, + actions = { + if (shuffleKind != null && state.items.isNotEmpty() && shuffleLauncher.supports(shuffleKind)) { + IconButton( + onClick = { shuffleLauncher.start(shuffleKind, collectionId) }, + enabled = !shuffleLauncher.isStarting, + ) { + Icon(Icons.Filled.Shuffle, contentDescription = "Shuffle") + } + } + }, ) }, containerColor = MaterialTheme.colorScheme.background, diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/collections/CollectionDetailViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/collections/CollectionDetailViewModel.kt index ee2905ed88..d6e6058ed3 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/collections/CollectionDetailViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/collections/CollectionDetailViewModel.kt @@ -51,8 +51,24 @@ class CollectionDetailViewModel( private var collectionId: String = "" private val libraryId: Int? = savedStateHandle.get("libraryId")?.toIntOrNull() private val collectionSource: String? = savedStateHandle.get("source") + private val mediaScope = savedStateHandle.get("mediaScope")?.takeIf { it in setOf("movie", "series") } private val isLibraryUserCollection: Boolean get() = libraryId != null && collectionSource == "user_collection" + + /** + * The shuffle scope this collection is: a library's server collection or a + * user collection. Null in a Movies- or Series-scoped view: a shuffle + * request carries no media type or library, so it would draw from both. + */ + val shuffleScopeKind: org.prairieserver.prairie.model.shuffle.ShuffleScopeKind? + get() = if (mediaScope != null) { + null + } else if (libraryId != null && !isLibraryUserCollection) { + org.prairieserver.prairie.model.shuffle.ShuffleScopeKind.LIBRARY_COLLECTION + } else { + org.prairieserver.prairie.model.shuffle.ShuffleScopeKind.USER_COLLECTION + } + private val pageSize = 40 fun initialize(id: String) { @@ -139,7 +155,7 @@ class CollectionDetailViewModel( } } - val itemsResult = if (isLibraryUserCollection) { + val itemsResult = if (isLibraryUserCollection && mediaScope == null) { collectionRepository.getItems( collectionId, limit = pageSize, @@ -152,6 +168,9 @@ class CollectionDetailViewModel( sectionRepository.getLibraryCollectionItems( collectionId, limit = pageSize, + mediaType = mediaScope, + libraryId = libraryId, + source = if (isLibraryUserCollection) "user_collection" else "library_collection", ).map { libraryContinuation = it.continuation it @@ -197,11 +216,14 @@ class CollectionDetailViewModel( pagingJob = viewModelScope.launch { _uiState.update { it.copy(isLoadingMore = true) } // Library paging is migrated separately; personal collections use opaque cursors. - val result = if (libraryId != null && !isLibraryUserCollection) { + val result = if (libraryId != null && (!isLibraryUserCollection || mediaScope != null)) { sectionRepository.getLibraryCollectionItems( collectionId, continuation = libraryContinuation, limit = pageSize, + mediaType = mediaScope, + libraryId = libraryId, + source = if (isLibraryUserCollection) "user_collection" else "library_collection", ).map { libraryContinuation = it.continuation; it } } else { collectionRepository.getItems( diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/collections/CollectionsScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/collections/CollectionsScreen.kt index e19a9a5386..92d28add3d 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/collections/CollectionsScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/collections/CollectionsScreen.kt @@ -31,7 +31,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.common.ui.OnViewerAccessChanged +import org.prairieserver.prairie.network.AccessChangeSignals import org.prairieserver.prairie.android.ui.components.EmptyStateView import org.prairieserver.prairie.android.ui.components.ErrorView import org.prairieserver.prairie.android.ui.components.LoadingIndicator @@ -48,6 +51,7 @@ fun CollectionsScreen( viewModel: CollectionsViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() + OnViewerAccessChanged(koinInject(), viewModel.accessChanges) { viewModel.refresh() } Scaffold( topBar = { diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/collections/LibraryCollectionsScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/collections/LibraryCollectionsScreen.kt index a38bcba237..488051376b 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/collections/LibraryCollectionsScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/collections/LibraryCollectionsScreen.kt @@ -56,7 +56,10 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.common.ui.OnViewerAccessChanged +import org.prairieserver.prairie.network.AccessChangeSignals /** * One named or anonymous section as it should appear on screen, in the @@ -81,10 +84,17 @@ class LibraryCollectionsViewModel( private val sectionRepository: SectionRepository, savedStateHandle: SavedStateHandle, ) : ViewModel() { + /** Access changes this ViewModel has applied, kept while its screen is away. */ + val accessChanges = org.prairieserver.prairie.network.AccessChangeCursor() + private val libraryId: Int? = savedStateHandle.get("libraryId")?.toIntOrNull() private val _uiState = MutableStateFlow(LibraryCollectionsUiState()) val uiState: StateFlow = _uiState.asStateFlow() + // A newer load or refresh supersedes an unfinished one, so an answer from + // before an access change can't land after the refresh it triggered. + private var loadJob: kotlinx.coroutines.Job? = null + init { loadCollections() } @@ -101,7 +111,8 @@ class LibraryCollectionsViewModel( return } - viewModelScope.launch { + loadJob?.cancel() + loadJob = viewModelScope.launch { _uiState.update { it.copy( isLoading = true, @@ -115,7 +126,8 @@ class LibraryCollectionsViewModel( fun refresh() { val currentLibraryId = libraryId ?: return - viewModelScope.launch { + loadJob?.cancel() + loadJob = viewModelScope.launch { _uiState.update { it.copy(isRefreshing = true, error = null) } applyResult(sectionRepository.getLibraryCollectionsGrouped(currentLibraryId)) } @@ -199,6 +211,7 @@ fun LibraryCollectionsScreen( viewModel: LibraryCollectionsViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() + OnViewerAccessChanged(koinInject(), viewModel.accessChanges) { viewModel.refresh() } androidx.compose.material3.Scaffold( topBar = { diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DescriptionTranslationSection.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DescriptionTranslationSection.kt index dd9a81ff1f..2ea73d49c3 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DescriptionTranslationSection.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DescriptionTranslationSection.kt @@ -16,7 +16,9 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import org.prairieserver.prairie.common.ui.components.MachineTranslatedLabel import org.prairieserver.prairie.metadata.DescriptionTranslationPhase /** @@ -67,3 +69,21 @@ fun DescriptionTranslationSection( } } } + +/** + * "Translated by AI" under an overview the server reports as machine-translated. + * [compact] is the icon-only form for dense rows such as episode cards. + */ +@Composable +fun DetailMachineTranslatedLabel( + modifier: Modifier = Modifier, + compact: Boolean = false, + iconSize: Dp = 12.dp, +) { + MachineTranslatedLabel( + color = DetailTertiaryText, + compact = compact, + iconSize = iconSize, + modifier = modifier, + ) +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DetailSharedComponents.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DetailSharedComponents.kt index 6f191b9a6e..b983336a28 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DetailSharedComponents.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DetailSharedComponents.kt @@ -1,8 +1,15 @@ package org.prairieserver.prairie.android.ui.screens.detail +import org.prairieserver.prairie.android.ui.components.PrairieDropdownMenuItem +import org.prairieserver.prairie.android.ui.components.PrairieDropdownMenu +import org.prairieserver.prairie.android.ui.components.PrairieDialogActionStyle +import org.prairieserver.prairie.android.ui.components.PrairieDialogAction +import org.prairieserver.prairie.android.ui.components.PrairieDialog import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -36,12 +43,10 @@ import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.outlined.KeyboardArrowDown import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.DropdownMenu import androidx.compose.material3.Icon +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Surface -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -51,6 +56,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Brush import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.CompositingStrategy @@ -60,6 +66,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -76,10 +83,20 @@ import org.prairieserver.prairie.android.ui.theme.PrairieOpaqueControlBorder import org.prairieserver.prairie.android.ui.theme.PrairieSecondaryText import org.prairieserver.prairie.android.ui.theme.PrairieSurfaceElevated import org.prairieserver.prairie.android.ui.theme.PillShape +import org.prairieserver.prairie.common.cards.LocalEpisodeSpoilerPrefs +import org.prairieserver.prairie.common.ui.components.SpoilerImage +import org.prairieserver.prairie.common.settings.titleLogoUrl +import org.prairieserver.prairie.common.ui.RatingEntry +import org.prairieserver.prairie.common.ui.WholeTokenRow +import org.prairieserver.prairie.common.ui.components.DefaultArtworkKind import org.prairieserver.prairie.common.ui.components.ThumbhashImage +import org.prairieserver.prairie.model.catalog.DisplayRating +import org.prairieserver.prairie.model.catalog.ExternalRatings import org.prairieserver.prairie.model.catalog.ItemDetail +import org.prairieserver.prairie.model.catalog.titleRatings import org.prairieserver.prairie.model.catalog.Season import org.prairieserver.prairie.model.catalog.isSpecialsForDisplay +import org.prairieserver.prairie.model.settings.EpisodeSpoilers // ── Tokens ──────────────────────────────────────────────────── @@ -102,6 +119,9 @@ data class DetailPortraitArtwork( val url: String?, val thumbhash: String?, val reserveSpace: Boolean = false, + /** False while a fallback poster may still arrive, so a blank [url] keeps + * the box empty rather than showing the default artwork. */ + val isResolved: Boolean = true, ) /** @@ -218,6 +238,7 @@ private fun ExpandedDetailHero( ExpandedDetailHeroBackdrop( artworkUrl = detail.backdropUrl, artworkThumbhash = detail.backdropThumbhash, + hidden = detail.hidesEpisodeArtwork(detail.backdropIsEpisodeStill), pageSurface = pageSurface, ) { Row( @@ -241,13 +262,16 @@ private fun ExpandedDetailHero( shape = RoundedCornerShape(12.dp), ), ) { - if (!portraitArtwork.url.isNullOrBlank()) { + // A blank URL can mean the series poster is still loading, + // so the default artwork waits until the poster is resolved. + if (!portraitArtwork.url.isNullOrBlank() || portraitArtwork.isResolved) { ThumbhashImage( url = portraitArtwork.url, thumbhash = portraitArtwork.thumbhash, contentDescription = detail.title, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), + defaultArtwork = DefaultArtworkKind.forItemType(detail.type), ) } } @@ -259,15 +283,27 @@ private fun ExpandedDetailHero( .then(if (hasPortrait) Modifier.height(posterHeight) else Modifier), horizontalAlignment = Alignment.CenterHorizontally, ) { + // Beside a portrait, the text block takes only the height + // the actions leave, so Play stays level with the poster. + // Only the title gives up height: it is weighted, so the + // facts and ratings rows are measured first and never clip, + // and a logo scales down to what is left. Column( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .then(if (hasPortrait) Modifier.weight(1f).clipToBounds() else Modifier), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp), ) { if (!eyebrow.isNullOrBlank()) { EyebrowChip(text = eyebrow) } - ExpandedHeroTitle(detail = detail) + Box( + modifier = if (hasPortrait) Modifier.weight(1f, fill = false).clipToBounds() else Modifier, + contentAlignment = Alignment.Center, + ) { + ExpandedHeroTitle(detail = detail) + } val metadataTokens = (factsLine + sourceTokens).distinct() if (metadataTokens.isNotEmpty() || detail.contentRating != null) { SourceRow( @@ -276,12 +312,9 @@ private fun ExpandedDetailHero( horizontalAlignment = Alignment.CenterHorizontally, ) } + DetailRatingsRow(ratings = detail.titleRatings()) } - if (hasPortrait) { - Spacer(modifier = Modifier.weight(1f)) - } else { - Spacer(modifier = Modifier.height(20.dp)) - } + Spacer(modifier = Modifier.height(if (hasPortrait) 12.dp else 20.dp)) // Expanded/tablet only: Play and its bottom action row end // no lower than the portrait. The compact phone branch is // intentionally unchanged. @@ -330,12 +363,14 @@ internal fun ExpandedDetailHeroBackdrop( artworkUrl: String?, artworkThumbhash: String?, pageSurface: Color, + hidden: Boolean = false, content: @Composable () -> Unit, ) { Box(modifier = Modifier.fillMaxWidth()) { - ThumbhashImage( + SpoilerImage( url = artworkUrl, thumbhash = artworkThumbhash, + hidden = hidden, contentDescription = null, contentScale = ContentScale.Crop, crossfadeMillis = DetailArtworkCrossfadeMs, @@ -419,7 +454,7 @@ private fun ExpandedHeroTitle(detail: ItemDetail) { return } - val logoUrl = detail.logoUrl + val logoUrl = titleLogoUrl(detail.logoUrl) if (!logoUrl.isNullOrBlank()) { ThumbhashImage( url = logoUrl, @@ -500,6 +535,9 @@ fun DetailHero( DetailHeroArtwork( artworkUrl = detail.backdropUrl ?: detail.posterUrl, artworkThumbhash = detail.backdropThumbhash ?: detail.posterThumbhash, + hidden = detail.hidesEpisodeArtwork( + if (detail.backdropUrl != null) detail.backdropIsEpisodeStill else detail.posterIsEpisodeStill, + ), contentDescription = detail.title, ) { HeroTitle(detail = detail) @@ -514,8 +552,17 @@ fun DetailHero( verticalArrangement = Arrangement.spacedBy(16.dp), ) { val metadataTokens = (factsLine + sourceTokens).distinct() - if (metadataTokens.isNotEmpty() || detail.contentRating != null) { - SourceRow(tokens = metadataTokens, ratingChip = detail.contentRating) + val ratings = detail.titleRatings() + if (metadataTokens.isNotEmpty() || detail.contentRating != null || ratings.isNotEmpty()) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (metadataTokens.isNotEmpty() || detail.contentRating != null) { + SourceRow(tokens = metadataTokens, ratingChip = detail.contentRating) + } + DetailRatingsRow(ratings = ExternalRatings.forPhone(ratings)) + } } actions() if (reserveOverviewSpace || !overviewText.isNullOrBlank()) { @@ -542,6 +589,7 @@ internal fun DetailHeroArtwork( artworkUrl: String?, artworkThumbhash: String?, contentDescription: String? = null, + hidden: Boolean = false, title: @Composable () -> Unit, ) { BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { @@ -588,9 +636,10 @@ internal fun DetailHeroArtwork( ) }, ) { - ThumbhashImage( + SpoilerImage( url = artworkUrl, thumbhash = artworkThumbhash, + hidden = hidden, contentDescription = contentDescription, contentScale = ContentScale.Crop, crossfadeMillis = DetailArtworkCrossfadeMs, @@ -743,6 +792,12 @@ private fun Backdrop( } } +/** Whether spoiler protection hides artwork of this episode with the given provenance. */ +@Composable +private fun ItemDetail.hidesEpisodeArtwork(artworkIsEpisodeStill: Boolean?): Boolean = + type.equals("episode", ignoreCase = true) && + LocalEpisodeSpoilerPrefs.current.hidesImage(EpisodeSpoilers.isUnwatched(userData), artworkIsEpisodeStill) + @Composable private fun HeroTitle(detail: ItemDetail) { val isEpisode = detail.type == "episode" @@ -794,7 +849,7 @@ private fun HeroTitle(detail: ItemDetail) { return } - val logoUrl = detail.logoUrl + val logoUrl = titleLogoUrl(detail.logoUrl) if (!logoUrl.isNullOrBlank()) { // iOS hero logo height — 160pt on compact phones, full width. ThumbhashImage( @@ -917,6 +972,28 @@ private fun SourceRow( } } +/** + * External ratings centered on one line, in the order given. When they do not + * all fit, whole entries drop from the end; the row never wraps. The server + * sends at most three; the phone layout also caps at + * [ExternalRatings.PHONE_LIMIT]. + */ +@Composable +private fun DetailRatingsRow(ratings: List) { + if (ratings.isEmpty()) return + val style = detailRatingStyle() + WholeTokenRow(spacing = DetailRatingSpacing) { + ratings.forEach { rating -> RatingEntry(rating = rating, style = style) } + } +} + +@Composable +private fun detailRatingStyle(): TextStyle = LocalTextStyle.current.merge( + TextStyle(color = DetailPrimaryText, fontSize = 15.sp, lineHeight = 20.sp), +) + +private val DetailRatingSpacing = 20.dp + @Composable private fun ContentRatingChip(text: String) { Box( @@ -1088,7 +1165,7 @@ fun CircleOverflowButton( } Text("More", fontSize = 10.sp, fontWeight = FontWeight.Medium, color = Color.White.copy(alpha = 0.60f)) } - DropdownMenu( + PrairieDropdownMenu( expanded = expanded, onDismissRequest = { expanded = false }, ) { @@ -1257,26 +1334,27 @@ fun HeroActionStack( if (showResumeDialog && onPlayFromBeginning != null) { val stoppedAt = resumeStoppedAtLabel - AlertDialog( + PrairieDialog( + title = "Continue Watching?", + message = stoppedAt?.let { "You stopped at $it." }, onDismissRequest = { showResumeDialog = false }, - title = { Text("Continue Watching?") }, - text = if (stoppedAt != null) { - { Text("You stopped at $stoppedAt.") } - } else { - null - }, - confirmButton = { - TextButton(onClick = { - showResumeDialog = false - onPlay() - }) { Text("Resume") } - }, - dismissButton = { - TextButton(onClick = { - showResumeDialog = false - onPlayFromBeginning() - }) { Text("Play from Beginning") } - }, + actions = listOf( + PrairieDialogAction( + label = "Resume", + style = PrairieDialogActionStyle.Primary, + onClick = { + showResumeDialog = false + onPlay() + }, + ), + PrairieDialogAction( + label = "Play from Beginning", + onClick = { + showResumeDialog = false + onPlayFromBeginning() + }, + ), + ), ) } } @@ -1338,12 +1416,15 @@ fun SectionHeader( // ── Season chips ────────────────────────────────────────────── +@OptIn(ExperimentalFoundationApi::class) @Composable fun SeasonChips( seasons: List, selectedSeasonNumber: Int, onSeasonSelected: (Int) -> Unit, modifier: Modifier = Modifier, + /** Long-press menu on a chip; null hides it. */ + onSeasonWatchedChange: ((Season, Boolean) -> Unit)? = null, ) { // The series overview deliberately keeps its single selected chip. iOS // renders "Season 1" even when there is no alternative season because it @@ -1389,27 +1470,50 @@ fun SeasonChips( ) { season -> val isSelected = season.seasonNumber == selectedSeasonNumber val label = phoneSeasonLabel(season) + val isWatched = season.userData?.played == true + var menuExpanded by remember { mutableStateOf(false) } // iOS PhoneSeasonChips: 14pt (semibold selected / medium // unselected), hpad 16, height 36, unselected fill white-0.06. - Surface( - shape = PillShape, - color = if (isSelected) Color.White else Color.White.copy(alpha = 0.06f), - border = if (isSelected) { - null - } else { - androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.25f)) - }, - modifier = Modifier - .height(36.dp) - .clickable { onSeasonSelected(season.seasonNumber) }, - ) { - Box(contentAlignment = Alignment.Center) { - Text( - text = label, - fontSize = 14.sp, - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, - color = if (isSelected) Color.Black else Color.White, - modifier = Modifier.padding(horizontal = 16.dp), + Box { + Surface( + shape = PillShape, + color = if (isSelected) Color.White else Color.White.copy(alpha = 0.06f), + border = if (isSelected) { + null + } else { + androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.25f)) + }, + modifier = Modifier + .height(36.dp) + .combinedClickable( + onClick = { onSeasonSelected(season.seasonNumber) }, + onLongClick = if (onSeasonWatchedChange != null && season.episodeCount > 0) { + { menuExpanded = true } + } else { + null + }, + ), + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = label, + fontSize = 14.sp, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, + color = if (isSelected) Color.Black else Color.White, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + } + PrairieDropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + ) { + PrairieDropdownMenuItem( + text = { Text(if (isWatched) "Mark Season Unwatched" else "Mark Season Watched") }, + onClick = { + menuExpanded = false + onSeasonWatchedChange?.invoke(season, !isWatched) + }, ) } } @@ -1447,7 +1551,6 @@ object HeroMetadata { ): List = buildList { if (detail.year > 0) add(detail.year.toString()) if (runtimeMinutes > 0) add(formatRuntime(runtimeMinutes)) - detail.ratingImdb?.let { add("IMDb %.1f".format(it)) } } fun seriesFactsLine(detail: ItemDetail): List = buildList { @@ -1455,7 +1558,6 @@ object HeroMetadata { detail.seasonCount?.takeIf { it > 0 }?.let { add("$it Season${if (it > 1) "s" else ""}") } - detail.ratingImdb?.let { add("IMDb %.1f".format(it)) } } private fun formatRuntime(minutes: Int): String { diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DetailTopChrome.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DetailTopChrome.kt new file mode 100644 index 0000000000..bd331712cd --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DetailTopChrome.kt @@ -0,0 +1,138 @@ +package org.prairieserver.prairie.android.ui.screens.detail + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.prairieserver.prairie.android.ui.theme.PrairieNavPillBorder +import org.prairieserver.prairie.android.ui.theme.PrairieOverlayPillSurface +import org.prairieserver.prairie.android.ui.theme.PrairiePageBackground + +/** + * The controls floating over a detail page's artwork: a back button, an + * optional trailing control, and a pinned strip and title that fade in as the + * hero scrolls away. Shared by library titles and request details, so both + * pages carry the same chrome. + */ +@Composable +internal fun DetailTopChrome( + title: String, + scroll: DetailScrollState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, + trailing: (@Composable () -> Unit)? = null, +) { + Box(modifier = modifier.fillMaxSize()) { + // The strip fades in first so the controls gain a backing as the + // artwork leaves, then the title arrives once the hero is mostly gone — + // the two ranges and the smoothstep are iOS's. + val barAlpha = detailHeaderProgress(scroll.offsetDp, HeaderBarFadeFromDp, HeaderBarFadeToDp) + val titleAlpha = detailHeaderProgress(scroll.offsetDp, HeaderTitleFadeFromDp, HeaderTitleFadeToDp) + if (barAlpha > 0f) { + // Runs from the very top of the window, not from below the status + // bar: the page is edge to edge, so insetting the strip left the + // status-bar band uncovered above it. + val statusBarHeight = WindowInsets.statusBars + .asPaddingValues() + .calculateTopPadding() + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .height(statusBarHeight + DetailHeaderBarHeight) + .graphicsLayer { alpha = barAlpha } + .background(PrairiePageBackground) + .drawBehind { + drawRect( + color = Color.White.copy(alpha = 0.10f), + topLeft = Offset(0f, size.height - 1f), + size = Size(size.width, 1f), + ) + }, + ) + } + if (titleAlpha > 0f && title.isNotBlank()) { + Text( + text = title, + color = Color.White, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier + .align(Alignment.TopCenter) + .statusBarsPadding() + .height(DetailHeaderBarHeight) + .fillMaxWidth() + // Clear of the controls on either side, and centred on + // them: both sit in the same strip. + .padding(horizontal = 72.dp) + .wrapContentHeight(Alignment.CenterVertically) + .graphicsLayer { alpha = titleAlpha }, + ) + } + + // These glyphs sit on hero artwork that can be any colour, so they keep + // a disc — the bottom-nav pill, made translucent. A dark disc holds a + // white glyph over a pale poster and still lets the artwork through. + IconButton( + onClick = onBackClick, + modifier = Modifier + .align(Alignment.TopStart) + .statusBarsPadding() + // Same geometry as the Home header's actions: a 40dp target + // 16dp from the edge, sitting directly below the status bar. + .padding(horizontal = 16.dp) + .size(40.dp) + .clip(CircleShape) + .background(PrairieOverlayPillSurface) + .border(1.dp, PrairieNavPillBorder, CircleShape), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = Color.White, + ) + } + if (trailing != null) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .statusBarsPadding() + .padding(horizontal = 16.dp), + ) { + trailing() + } + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/EpisodeList.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/EpisodeList.kt index 40f784a403..fe25f025d9 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/EpisodeList.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/EpisodeList.kt @@ -1,5 +1,7 @@ package org.prairieserver.prairie.android.ui.screens.detail +import org.prairieserver.prairie.android.ui.components.PrairieDropdownMenuItem +import org.prairieserver.prairie.android.ui.components.PrairieDropdownMenu import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -28,8 +30,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.Icon -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -53,8 +53,13 @@ import androidx.compose.ui.unit.sp import kotlinx.coroutines.flow.distinctUntilChanged import org.prairieserver.prairie.android.ui.util.formatCardDate import org.prairieserver.prairie.android.ui.util.playbackResumePosition +import org.prairieserver.prairie.common.cards.LocalEpisodeSpoilerPrefs +import org.prairieserver.prairie.common.ui.components.SpoilerImage +import org.prairieserver.prairie.common.ui.components.DefaultArtworkKind import org.prairieserver.prairie.common.ui.components.ThumbhashImage import org.prairieserver.prairie.model.catalog.EpisodeListItem +import org.prairieserver.prairie.model.catalog.hasMachineTranslatedOverview +import org.prairieserver.prairie.model.settings.EpisodeSpoilers import kotlin.math.abs /** @@ -172,6 +177,10 @@ private fun EpisodeRailCard( var menuExpanded by remember { mutableStateOf(false) } val isWatched = episode.userData?.played == true val stillShape = RoundedCornerShape(8.dp) + val spoilers = LocalEpisodeSpoilerPrefs.current + val unwatched = EpisodeSpoilers.isUnwatched(episode.userData) + val visibleOverview = episode.overview + ?.takeIf { it.isNotBlank() && !spoilers.hidesOverview(unwatched) } val progress = episodeProgressFraction( positionSeconds = episode.userData?.positionSeconds, durationSeconds = episode.userData?.durationSeconds, @@ -197,11 +206,14 @@ private fun EpisodeRailCard( shape = stillShape, ), ) { - ThumbhashImage( + SpoilerImage( url = episode.stillUrl, thumbhash = episode.stillThumbhash, + hidden = spoilers.hidesImage(unwatched, episode.stillIsEpisodeStill), contentDescription = episode.title, modifier = Modifier.fillMaxSize(), + // The play button covers the centre, so no mark under it. + defaultArtwork = if (onPlayClick != null) DefaultArtworkKind.GlowOnly else DefaultArtworkKind.Tv, ) if (episode.userData?.played == true) { @@ -275,6 +287,11 @@ private fun EpisodeRailCard( letterSpacing = 1.sp, color = Color.White.copy(alpha = 0.55f), ) + if (showsEpisodeDetails && visibleOverview != null && + hasMachineTranslatedOverview(episode.machineTranslatedFields) + ) { + DetailMachineTranslatedLabel(compact = true, iconSize = 11.dp) + } if (isCurrent) { Text( text = "NOW VIEWING", @@ -311,9 +328,11 @@ private fun EpisodeRailCard( overflow = TextOverflow.Ellipsis, ) } - episode.overview?.takeIf { it.isNotBlank() }?.let { overview -> + // Spoiler protection omits the text; the fixed-height column + // keeps the card the same size. + if (visibleOverview != null) { Text( - text = overview, + text = visibleOverview, style = MaterialTheme.typography.bodySmall, color = DetailSecondaryText, maxLines = 3, @@ -322,11 +341,11 @@ private fun EpisodeRailCard( } } } - DropdownMenu( + PrairieDropdownMenu( expanded = menuExpanded, onDismissRequest = { menuExpanded = false }, ) { - DropdownMenuItem( + PrairieDropdownMenuItem( text = { Text(if (isWatched) "Mark as Unwatched" else "Mark as Watched") }, onClick = { menuExpanded = false diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailScreen.kt index 7cbee2008f..def5f275bb 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailScreen.kt @@ -1,5 +1,8 @@ package org.prairieserver.prairie.android.ui.screens.detail +import org.prairieserver.prairie.android.ui.components.PrairieDropdownMenuItem +import org.prairieserver.prairie.android.ui.components.PrairieDropdownMenu +import org.prairieserver.prairie.android.ui.components.PrairieConfirmDialog import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -34,15 +37,11 @@ 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.outlined.SettingsRemote -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -66,7 +65,13 @@ import org.prairieserver.prairie.android.ui.theme.PrairieNavPillBorder import org.prairieserver.prairie.android.cast.PrairieCastController import org.prairieserver.prairie.android.ui.screens.cast.PrairieCastTargetPickerSheet import org.prairieserver.prairie.android.ui.screens.downloads.openDownloadTargetInExternalApp -import org.prairieserver.prairie.android.ui.screens.watchtogether.SuggestToRoomViewModel +import org.prairieserver.prairie.android.ui.screens.watchparty.WatchPartySoloGuardDialog +import org.prairieserver.prairie.android.ui.screens.watchparty.rememberWatchPartyDetailActions +import org.prairieserver.prairie.android.ui.screens.watchparty.rememberWatchPartySoloGuard +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.viewmodel.WatchPartyItem +import org.prairieserver.prairie.model.settings.EpisodeSpoilers +import org.prairieserver.prairie.viewmodel.WatchPartySpoilers import org.prairieserver.prairie.android.ui.util.playbackResumePosition import org.prairieserver.prairie.common.downloads.DownloadEnqueuer import org.prairieserver.prairie.common.downloads.DownloadOpenTarget @@ -80,15 +85,17 @@ import org.prairieserver.prairie.model.ebook.isInAppReadableEbookVersion import org.prairieserver.prairie.model.ebook.isSupportedEbookVersion import org.prairieserver.prairie.model.download.DownloadQuality import org.prairieserver.prairie.model.download.labelFor -import org.prairieserver.prairie.model.feature.CLIENT_WATCH_TOGETHER_SURFACE_ENABLED import org.prairieserver.prairie.common.settings.PlayerSettingsStore import org.prairieserver.prairie.network.ServerRegistry import org.prairieserver.prairie.playback.selectPlaybackVersion import org.koin.compose.koinInject -import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.common.ui.OnViewerAccessChanged +import org.prairieserver.prairie.network.AccessChangeSignals import org.prairieserver.prairie.metadata.DescriptionTranslationPhase import org.prairieserver.prairie.model.feature.MetadataAiFeatureStore import org.prairieserver.prairie.model.metadata.MetadataAiOnView +import org.prairieserver.prairie.model.catalog.hasMachineTranslatedOverview +import org.prairieserver.prairie.model.catalog.pendingEpisodeTranslationLanguage internal data class SeriesDetailRedirect( val seriesContentId: String, @@ -128,14 +135,22 @@ fun ItemDetailScreen( onSeriesDetailReplace: (String, Int, String?) -> Unit, onAudiobookPlayClick: (contentId: String, fileId: Int?, fromStart: Boolean, startPosition: Double?) -> Unit = { _, _, _, _ -> }, onBookReadClick: (String, Int?) -> Unit = { _, _ -> }, - onWatchTogether: (String, Int?) -> Unit = { _, _ -> }, + /** The route's library, staged with a Watch Party item. */ + libraryId: Int? = null, + /** Host a Watch Party with this item (opens the hub). */ + onWatchParty: (WatchPartyItem) -> Unit = {}, + /** The host switched a playing party to an item from this page. */ + onPartySelected: (RoomSnapshot) -> Unit = {}, // Auto-presents the cast remote after "Play on device" launches, mirroring // Apple's playOnTV: the connect/handoff handshake renders in the remote. onOpenCastRemote: () -> Unit = {}, + // Plays the first pick of a shuffle started from the series overflow menu. + onShuffleStarted: (org.prairieserver.prairie.model.shuffle.Shuffle) -> Unit = {}, viewModel: ItemDetailViewModel, modifier: Modifier = Modifier, ) { val state by viewModel.uiState.collectAsState() + val shuffleLauncher = org.prairieserver.prairie.android.ui.screens.shuffle.rememberShuffleLauncher(onShuffleStarted) val seriesRedirect = remember(state.detail) { state.detail?.let(::seriesDetailRedirect) } var seriesRedirectFailed by rememberSaveable(state.detail?.contentId) { mutableStateOf(false) } LaunchedEffect(seriesRedirect) { @@ -156,18 +171,20 @@ fun ItemDetailScreen( LaunchedEffect(state.selectedEpisodeContentId) { if (state.selectedEpisodeContentId != null) viewModel.ensureSelectedEpisodeDetailLoaded() } - val suggestViewModel: SuggestToRoomViewModel = koinViewModel() - val suggestRoom by suggestViewModel.room.collectAsState() - val suggestState by suggestViewModel.uiState.collectAsState() val context = LocalContext.current - LaunchedEffect(suggestState.notice, suggestState.error) { - val message = suggestState.notice ?: suggestState.error - if (message != null) { - Toast.makeText(context, message, Toast.LENGTH_SHORT).show() - suggestViewModel.consumeNotice() - suggestViewModel.clearError() - } + // D5: while this device is in a Watch Party, detail Play, extras, and + // audiobook play ask to leave the party first (the player is shared). + val soloGuard = rememberWatchPartySoloGuard() + val guardedPlay: (String, Int?, Int?, Int?, Double?) -> Unit = { contentId, fileId, audio, subtitle, resume -> + soloGuard.run { onPlayClick(contentId, fileId, audio, subtitle, resume) } + } + val guardedAudiobookPlay: (String, Int?, Boolean, Double?) -> Unit = { contentId, fileId, fromStart, start -> + soloGuard.run { onAudiobookPlayClick(contentId, fileId, fromStart, start) } } + val (partyActions, partyDialogs) = rememberWatchPartyDetailActions( + onHost = onWatchParty, + onSelectedWhilePlaying = onPartySelected, + ) // Refresh on return (e.g. backing out of the player): the ViewModel loads // once in init, so without this the Play button keeps the resume label @@ -176,6 +193,10 @@ fun ItemDetailScreen( // effect-local "skip the first" flag would reset and swallow exactly the // resume we care about. refreshOnReturn() no-ops while detail is still // null, which covers the initial load. + // The same quiet refresh when the server reports an access change, so + // availability, versions, and quality limits follow the new policy; a + // title the viewer can no longer see shows the unavailable state. + OnViewerAccessChanged(koinInject(), viewModel.accessChanges) { viewModel.refreshAfterAccessChange() } val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current androidx.compose.runtime.DisposableEffect(lifecycleOwner) { val observer = androidx.lifecycle.LifecycleEventObserver { _, event -> @@ -207,6 +228,11 @@ fun ItemDetailScreen( } } } + LaunchedEffect(Unit) { + viewModel.downloadFailureMessages.collect { message -> + Toast.makeText(context, message, Toast.LENGTH_LONG).show() + } + } val downloadStorage: DownloadStorage = koinInject() val serverRegistry: ServerRegistry = koinInject() @@ -387,6 +413,22 @@ fun ItemDetailScreen( viewModel.translateDescription(auto = true) } } + // The series page lists the selected season's episodes inline; + // the season's job fills any episode description still missing. + val pendingEpisodeLanguage = state.episodes + .filter { it.seasonNumber == state.selectedSeasonNumber } + .pendingEpisodeTranslationLanguage() + LaunchedEffect( + detail.contentId, + state.selectedSeasonNumber, + state.seasons.isNotEmpty(), + pendingEpisodeLanguage, + metadataAiStatus.onView, + ) { + if (metadataAiStatus.onView == MetadataAiOnView.Auto && pendingEpisodeLanguage != null) { + viewModel.translateShownSeasonEpisodes() + } + } val translationSlot: (@Composable () -> Unit)? = if (translationEligible && (metadataAiStatus.onView == MetadataAiOnView.Button || translationPhase != DescriptionTranslationPhase.Idle) @@ -397,6 +439,10 @@ fun ItemDetailScreen( onTranslate = { viewModel.translateDescription() }, ) } + } else if (hasMachineTranslatedOverview(detail.machineTranslatedFields) && + !detail.overview.isNullOrBlank() + ) { + { DetailMachineTranslatedLabel() } } else { null } @@ -451,18 +497,18 @@ fun ItemDetailScreen( // Resume/Play: no fileId — the player VM resolves // the part from the stored whole-book position. onPlayClick = { - onAudiobookPlayClick(detail.contentId, null, false, null) + guardedAudiobookPlay(detail.contentId, null, false, null) }, onPlayFromStartClick = { - onAudiobookPlayClick(detail.contentId, null, true, null) + guardedAudiobookPlay(detail.contentId, null, true, null) }, // Parts play from a whole-book (global) offset. onPlayFromPositionClick = { startPosition -> - onAudiobookPlayClick(detail.contentId, null, false, startPosition) + guardedAudiobookPlay(detail.contentId, null, false, startPosition) }, // Chapters jump to their global start offset. onChapterClick = { chapter -> - onAudiobookPlayClick(detail.contentId, null, false, chapter.startSeconds) + guardedAudiobookPlay(detail.contentId, null, false, chapter.startSeconds) }, onFavoriteClick = { viewModel.toggleFavorite() }, onWatchlistClick = { viewModel.toggleWatchlist() }, @@ -685,7 +731,7 @@ fun ItemDetailScreen( }, onPlayClick = { selectedEpisode?.let { - onPlayClick( + guardedPlay( it.contentId, selectedEpisodeFileId, state.selectedAudioIndex.takeIf { state.hasExplicitAudioSelection }, @@ -693,8 +739,8 @@ fun ItemDetailScreen( selectedEpisodeResume, ) } ?: nextEpisode?.let { - onPlayClick(it.contentId, null, null, null, playbackResumePosition(it)) - } ?: onPlayClick( + guardedPlay(it.contentId, null, null, null, playbackResumePosition(it)) + } ?: guardedPlay( detail.contentId, null, null, @@ -705,7 +751,7 @@ fun ItemDetailScreen( onPlayFromBeginning = activeSeriesResume?.let { { selectedEpisode?.let { ep -> - onPlayClick( + guardedPlay( ep.contentId, selectedEpisodeFileId, state.selectedAudioIndex.takeIf { state.hasExplicitAudioSelection }, @@ -713,26 +759,53 @@ fun ItemDetailScreen( 0.0, ) } ?: nextEpisode?.let { ep -> - onPlayClick(ep.contentId, null, null, null, 0.0) - } ?: onPlayClick(detail.contentId, null, null, null, 0.0) + guardedPlay(ep.contentId, null, null, null, 0.0) + } ?: guardedPlay(detail.contentId, null, null, null, 0.0) } }, resumeStoppedAtLabel = activeSeriesResume?.let { formatResumeStoppedAt(it) }, onEpisodePlayClick = { contentId, resumePositionSeconds -> - onPlayClick(contentId, null, null, null, resumePositionSeconds) + guardedPlay(contentId, null, null, null, resumePositionSeconds) }, onEpisodeDetailClick = { viewModel.selectSeriesEpisode(it) }, onEpisodeWatchedChange = { episodeContentId, watched -> viewModel.setEpisodeWatched(episodeContentId, watched) }, onSeasonSelected = { viewModel.selectSeason(it) }, + onSeasonWatchedChange = { season, watched -> + viewModel.setSeasonWatched(season, watched) + }, + onShuffleSeries = if ( + shuffleLauncher.supports(org.prairieserver.prairie.model.shuffle.ShuffleScopeKind.SERIES) + ) { + { + shuffleLauncher.start( + org.prairieserver.prairie.model.shuffle.ShuffleScopeKind.SERIES, + detail.contentId, + ) + } + } else { + null + }, + onShuffleSeason = if ( + shuffleLauncher.supports(org.prairieserver.prairie.model.shuffle.ShuffleScopeKind.SEASON) + ) { + { season -> + shuffleLauncher.start( + org.prairieserver.prairie.model.shuffle.ShuffleScopeKind.SEASON, + season.contentId, + ) + } + } else { + null + }, onFavoriteClick = { viewModel.toggleFavorite() }, onWatchlistClick = { viewModel.toggleWatchlist() }, onToggleWatched = { viewModel.toggleWatched() }, onPersonClick = onPersonClick, onItemDetailClick = onItemDetailClick, onPlayExtra = { extra -> - onPlayClick(extra.contentId, extra.fileId, null, null, 0.0) + guardedPlay(extra.contentId, extra.fileId, null, null, 0.0) }, onSeriesDownloadClick = { // Series/season batches are Original-only server-side @@ -776,28 +849,29 @@ fun ItemDetailScreen( } } }, - onSuggestToRoom = if ( - CLIENT_WATCH_TOGETHER_SURFACE_ENABLED && - suggestRoom != null && - nextEpisode != null - ) { - { - suggestViewModel.suggest( - contentId = nextEpisode.contentId, + // A series enters a party through the episode Play + // would start: the selected one, else next up. + partyAction = partyActions.actionFor( + (selectedEpisode ?: nextEpisode)?.let { episode -> + WatchPartyItem( + contentId = episode.contentId, contentType = "episode", - title = nextEpisode.title ?: detail.title, - subtitle = detail.title, - posterUrl = nextEpisode.stillUrl ?: detail.posterUrl, + title = episode.title?.takeIf { it.isNotBlank() } + ?: "Episode ${episode.episodeNumber}", + subtitle = "${detail.title} · S${episode.seasonNumber}·E${episode.episodeNumber}", + posterUrl = detail.posterUrl, + fileId = selectedEpisodeVersion?.fileId + ?.takeIf { episode.contentId == selectedEpisode?.contentId }, + libraryId = libraryId, + // The series poster stands in for the episode. + spoilers = WatchPartySpoilers( + unwatchedEpisode = EpisodeSpoilers.isUnwatched(episode.userData), + posterIsEpisodeStill = false, + backdropIsEpisodeStill = false, + ), ) - } - } else { - null - }, - onWatchTogether = if (CLIENT_WATCH_TOGETHER_SURFACE_ENABLED) { - { onWatchTogether(nextEpisode?.contentId ?: detail.contentId, null) } - } else { - null - }, + }, + ), ) } @@ -819,6 +893,7 @@ fun ItemDetailScreen( state.episodeSeriesPosterThumbhash }, reserveSpace = true, + isResolved = seasonPosterUrl != null || state.episodeSeriesPosterResolved, ) } else { DetailPortraitArtwork( @@ -867,7 +942,7 @@ fun ItemDetailScreen( selectedAudioIndex = explicitAudioIndex, selectedSubtitleIndex = explicitSubtitleIndex, onPlayClick = { - onPlayClick( + guardedPlay( detail.contentId, playbackFileId, explicitAudioIndex, @@ -877,7 +952,7 @@ fun ItemDetailScreen( }, onPlayFromBeginning = movieResume?.let { { - onPlayClick( + guardedPlay( detail.contentId, playbackFileId, explicitAudioIndex, @@ -902,7 +977,7 @@ fun ItemDetailScreen( onPersonClick = onPersonClick, onItemDetailClick = onItemDetailClick, onPlayExtra = { extra -> - onPlayClick(extra.contentId, extra.fileId, null, null, 0.0) + guardedPlay(extra.contentId, extra.fileId, null, null, 0.0) }, onSeriesClick = seriesId?.let { resolvedSeriesId -> { onSeriesClick(resolvedSeriesId) } @@ -943,26 +1018,33 @@ fun ItemDetailScreen( ) } }, - onSuggestToRoom = if ( - CLIENT_WATCH_TOGETHER_SURFACE_ENABLED && suggestRoom != null - ) { - { - suggestViewModel.suggest( + // Movies and episodes stage the displayed edition. + partyAction = partyActions.actionFor( + detail.takeIf { it.type == "movie" || it.type == "episode" }?.let { + WatchPartyItem( contentId = detail.contentId, contentType = detail.type, title = detail.title, - subtitle = detail.seriesTitle?.takeIf { value -> value.isNotBlank() }, - posterUrl = detail.posterUrl, + subtitle = if (detail.type == "episode") { + listOfNotNull( + detail.seriesTitle?.takeIf { value -> value.isNotBlank() }, + detail.seasonNumber?.let { season -> + detail.episodeNumber?.let { episode -> "S$season·E$episode" } + }, + ).joinToString(" · ").ifBlank { null } + } else { + detail.year.takeIf { year -> year > 0 }?.toString() + }, + posterUrl = portraitArtwork.url ?: detail.posterUrl, + fileId = selectedVersion?.fileId, + libraryId = libraryId, + spoilers = WatchPartySpoilers.of(detail).let { spoilers -> + // A season or series poster stands in for an episode's still. + if (portraitArtwork.url != null) spoilers.copy(posterIsEpisodeStill = false) else spoilers + }, ) - } - } else { - null - }, - onWatchTogether = if (CLIENT_WATCH_TOGETHER_SURFACE_ENABLED) { - { onWatchTogether(detail.contentId, explicitFileId) } - } else { - null - }, + }, + ), ) } } @@ -994,6 +1076,9 @@ fun ItemDetailScreen( ) } + WatchPartySoloGuardDialog(soloGuard) + partyDialogs() + if (showRemoteTargetPicker) { PrairieCastTargetPickerSheet( onDismiss = { showRemoteTargetPicker = false }, @@ -1002,177 +1087,85 @@ fun ItemDetailScreen( } pendingCancelDownloadAction?.let { confirmAction -> - AlertDialog( - onDismissRequest = { pendingCancelDownloadAction = null }, - title = { Text("Cancel download?") }, - text = { Text("The partially downloaded data will be discarded.") }, - confirmButton = { - TextButton( - onClick = { - pendingCancelDownloadAction = null - confirmAction() - }, - ) { - Text("Discard Download") - } - }, - dismissButton = { - TextButton(onClick = { pendingCancelDownloadAction = null }) { - Text("Keep Download") - } + PrairieConfirmDialog( + title = "Cancel download?", + body = "The partially downloaded data will be discarded.", + confirmLabel = "Discard Download", + dismissLabel = "Keep Download", + onConfirm = { + pendingCancelDownloadAction = null + confirmAction() }, + onDismiss = { pendingCancelDownloadAction = null }, ) } } } - // Pinned header. The strip fades in first so the controls gain a - // backing as the artwork leaves, then the title arrives once the hero - // is mostly gone — the two ranges and the smoothstep are iOS's. - val headerTitle = state.detail?.title.orEmpty() - val barAlpha = detailHeaderProgress( - detailScroll.offsetDp, - HeaderBarFadeFromDp, - HeaderBarFadeToDp, - ) - val titleAlpha = detailHeaderProgress( - detailScroll.offsetDp, - HeaderTitleFadeFromDp, - HeaderTitleFadeToDp, - ) - if (barAlpha > 0f) { - // Runs from the very top of the window, not from below the status - // bar: the page is edge to edge, so insetting the strip left the - // status-bar band uncovered above it. - val statusBarHeight = WindowInsets.statusBars - .asPaddingValues() - .calculateTopPadding() - Box( - modifier = Modifier - .align(Alignment.TopCenter) - .fillMaxWidth() - .height(statusBarHeight + DetailHeaderBarHeight) - .graphicsLayer { alpha = barAlpha } - .background(PrairiePageBackground) - .drawBehind { - drawRect( - color = Color.White.copy(alpha = 0.10f), - topLeft = Offset(0f, size.height - 1f), - size = Size(size.width, 1f), - ) - }, - ) - } - if (titleAlpha > 0f && headerTitle.isNotBlank()) { - Text( - text = headerTitle, - color = Color.White, - fontSize = 17.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - modifier = Modifier - .align(Alignment.TopCenter) - .statusBarsPadding() - .height(DetailHeaderBarHeight) - .fillMaxWidth() - // Clear of the back and remote controls on either side, - // and centred on them: both sit in the same strip. - .padding(horizontal = 72.dp) - .wrapContentHeight(Alignment.CenterVertically) - .graphicsLayer { alpha = titleAlpha }, - ) - } - - // These two glyphs sit on hero artwork that can be any colour, so they - // keep a disc — the bottom-nav pill, made translucent. A dark disc - // holds a white glyph over a pale poster and still lets the artwork - // through, which the previous white-tinted wash could not. - IconButton( - onClick = onBackClick, - modifier = Modifier - .align(Alignment.TopStart) - .statusBarsPadding() - // Same geometry as the Home header's actions: a 40dp target - // 16dp from the edge, sitting directly below the status bar, - // so the controls do not jump when moving between the two. - .padding(horizontal = 16.dp) - .size(40.dp) - .clip(CircleShape) - .background(PrairieOverlayPillSurface) - .border(1.dp, PrairieNavPillBorder, CircleShape), - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - tint = Color.White, - ) - } - Box( - modifier = Modifier - .align(Alignment.TopEnd) - .statusBarsPadding() - .padding(horizontal = 16.dp), - ) { - IconButton( - onClick = { - if (siloCastState.hasActiveSession) { - remoteMenuExpanded = true - } else { - showRemoteTargetPicker = true - } - }, - // Solid pill while a cast session is live, translucent at - // rest, so the fill still reports state. - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background( + DetailTopChrome( + title = state.detail?.title.orEmpty(), + scroll = detailScroll, + onBackClick = onBackClick, + trailing = { + IconButton( + onClick = { if (siloCastState.hasActiveSession) { - PrairieNavPillSurface + remoteMenuExpanded = true } else { - PrairieOverlayPillSurface + showRemoteTargetPicker = true + } + }, + // Solid pill while a cast session is live, translucent at + // rest, so the fill still reports state. + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background( + if (siloCastState.hasActiveSession) { + PrairieNavPillSurface + } else { + PrairieOverlayPillSurface + }, + ) + .border(1.dp, PrairieNavPillBorder, CircleShape), + ) { + Icon( + imageVector = Icons.Outlined.SettingsRemote, + contentDescription = "Remote Control", + tint = Color.White, + ) + } + PrairieDropdownMenu( + expanded = remoteMenuExpanded, + onDismissRequest = { remoteMenuExpanded = false }, + ) { + PrairieDropdownMenuItem( + text = { Text("Remote Control") }, + onClick = { + remoteMenuExpanded = false + onOpenCastRemote() }, ) - .border(1.dp, PrairieNavPillBorder, CircleShape), - ) { - Icon( - imageVector = Icons.Outlined.SettingsRemote, - contentDescription = "Remote Control", - tint = Color.White, - ) - } - DropdownMenu( - expanded = remoteMenuExpanded, - onDismissRequest = { remoteMenuExpanded = false }, - ) { - DropdownMenuItem( - text = { Text("Remote Control") }, - onClick = { - remoteMenuExpanded = false - onOpenCastRemote() - }, - ) - DropdownMenuItem( - text = { Text("Choose TV") }, - onClick = { - remoteMenuExpanded = false - showRemoteTargetPicker = true - }, - ) - HorizontalDivider() - DropdownMenuItem( - text = { - Text("Turn Off Control Mode", color = MaterialTheme.colorScheme.error) - }, - onClick = { - remoteMenuExpanded = false - siloCastController.disconnect() - }, - ) - } - } + PrairieDropdownMenuItem( + text = { Text("Choose TV") }, + onClick = { + remoteMenuExpanded = false + showRemoteTargetPicker = true + }, + ) + HorizontalDivider() + PrairieDropdownMenuItem( + text = { + Text("Turn Off Control Mode", color = MaterialTheme.colorScheme.error) + }, + onClick = { + remoteMenuExpanded = false + siloCastController.disconnect() + }, + ) + } + }, + ) } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailViewModel.kt index d8b7b87e0f..63545b6c68 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailViewModel.kt @@ -6,20 +6,27 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import org.prairieserver.prairie.common.downloads.DownloadEnqueuer +import org.prairieserver.prairie.model.catalog.withEpisodeDescription +import org.prairieserver.prairie.model.catalog.withTranslatedDescription import org.prairieserver.prairie.model.catalog.EpisodeListItem import org.prairieserver.prairie.model.catalog.FileVersion import org.prairieserver.prairie.model.catalog.ItemDetail import org.prairieserver.prairie.model.catalog.LeafItemUserData import org.prairieserver.prairie.model.catalog.Season +import org.prairieserver.prairie.model.catalog.SeasonUserData import org.prairieserver.prairie.model.catalog.initialSeasonDisplayPlan import org.prairieserver.prairie.model.catalog.sortedForDisplay import org.prairieserver.prairie.model.download.DownloadCapability import org.prairieserver.prairie.model.download.DownloadRecord +import org.prairieserver.prairie.model.download.DownloadStatus import org.prairieserver.prairie.model.download.statusEnum import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.errorMessage +import org.prairieserver.prairie.network.isAccessRefusal import org.prairieserver.prairie.model.catalog.isBookLikeItemType import org.prairieserver.prairie.metadata.DescriptionTranslationController import org.prairieserver.prairie.metadata.DescriptionTranslationPhase +import org.prairieserver.prairie.model.catalog.pendingEpisodeTranslationLanguage import org.prairieserver.prairie.repository.CatalogRepository import org.prairieserver.prairie.repository.MetadataAiRepository import org.prairieserver.prairie.repository.DownloadsRepository @@ -45,6 +52,7 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.ensureActive /** * UI state for the item detail screen. @@ -65,6 +73,8 @@ data class ItemDetailUiState( /** Parent series title on a standalone episode page, for download grouping. */ val episodeSeriesTitle: String? = null, val episodeSeriesPosterThumbhash: String? = null, + /** The parent-series poster lookup has finished, with or without a poster. */ + val episodeSeriesPosterResolved: Boolean = false, /** * Route-scoped episode lists keyed by season. Unlike the repository's * durable network-fallback cache, this map is UI-first: once a season has @@ -139,9 +149,15 @@ class ItemDetailViewModel( private val userItemState: org.prairieserver.prairie.repository.port.UserItemStatePort = org.prairieserver.prairie.repository.port.NoOpUserItemStatePort, ) : ViewModel() { + /** Access changes this ViewModel has applied, kept while its screen is away. */ + val accessChanges = org.prairieserver.prairie.network.AccessChangeCursor() private var similarGeneration = 0L private var similarJob: kotlinx.coroutines.Job? = null + private var detailLoadJob: Job? = null + private var quietDetailJob: Job? = null + /** Whether [quietDetailJob] clears the page on a refusal (an access-change refresh). */ + private var quietDetailShowsRefusal = false private val libraryId: Int? = savedStateHandle.get("libraryId")?.toIntOrNull() private val contentId: String = savedStateHandle.get("contentId") ?: "" private val initialSeasonNumber: Int? = @@ -188,6 +204,11 @@ class ItemDetailViewModel( private var watchedMutationGeneration = 0 private val episodeWatchedMutationGenerations = mutableMapOf() + private val seasonWatchedMutationGenerations = mutableMapOf() + private var seasonsRefreshGeneration = 0 + private val failedEpisodeWatchedGenerations = mutableMapOf() + private val succeededEpisodeWatchedGenerations = mutableMapOf() + private var failedSeriesWatchedGeneration = -1 private val descriptionTranslation = DescriptionTranslationController( repository = metadataAiRepository, @@ -195,6 +216,16 @@ class ItemDetailViewModel( ) val translationPhase: StateFlow = descriptionTranslation.phase + // A season's translation job also fills its episodes' descriptions. Phone + // has no season page — the series page lists the selected season's + // episodes inline — so this page starts that job when an episode row + // reports a missing description. Its own controller keeps the series + // overview's single-flight latch independent. + private val episodeTranslation = DescriptionTranslationController( + repository = metadataAiRepository, + delayMs = { kotlinx.coroutines.delay(it) }, + ) + init { viewModelScope.launch { var previous = personalDataRepository.memberships.generation.value @@ -273,6 +304,20 @@ class ItemDetailViewModel( private val _downloadStartEvents = kotlinx.coroutines.flow.MutableSharedFlow(extraBufferCapacity = 4) val downloadStartEvents: kotlinx.coroutines.flow.SharedFlow = _downloadStartEvents + /** Why a download couldn't start, such as the server's "concurrent + * download limit reached". The screen shows it; without it a rejected + * request, and above all a rejected series batch, looked like nothing + * happened. */ + private val _downloadFailureMessages = kotlinx.coroutines.flow.MutableSharedFlow(extraBufferCapacity = 4) + val downloadFailureMessages: kotlinx.coroutines.flow.SharedFlow = _downloadFailureMessages + + private suspend fun reportDownloadStart(result: ApiResult<*>) { + _downloadStartEvents.emit(result is ApiResult.Success) + if (result !is ApiResult.Success) { + _downloadFailureMessages.emit(result.errorMessage("Couldn't start the download.")) + } + } + private suspend fun startDownload( version: FileVersion, displayTitle: String, @@ -317,6 +362,8 @@ class ItemDetailViewModel( episodeNumber = item.episodeNumber, episodeTitle = item.title, posterUrl = knownPoster ?: parent?.posterUrl ?: pageDetail?.posterUrl, + posterIsEpisodeStill = if (knownPoster != null || parent?.posterUrl != null) false + else pageDetail?.posterIsEpisodeStill, downloadQualityOverride = downloadQuality, ) } @@ -329,7 +376,7 @@ class ItemDetailViewModel( downloadQualityOverride = downloadQuality, ) } - _downloadStartEvents.emit(result is ApiResult.Success) + reportDownloadStart(result) } /** Series-level "Download series" — uses the server's batch endpoint @@ -337,9 +384,11 @@ class ItemDetailViewModel( fun onSeriesDownloadTapped(downloadQuality: DownloadQuality? = null) { val detail = _uiState.value.detail ?: return viewModelScope.launch { - downloadEnqueuer.startSeries( - seriesContentId = detail.contentId, - downloadQualityOverride = downloadQuality, + reportDownloadStart( + downloadEnqueuer.startSeries( + seriesContentId = detail.contentId, + downloadQualityOverride = downloadQuality, + ), ) } } @@ -362,17 +411,29 @@ class ItemDetailViewModel( } } - fun loadDetail() { + fun loadDetail() = loadDetail(afterAccessChange = false) + + /** + * [afterAccessChange] skips joining a Home warm-up still in flight, whose + * answer may predate the change. + */ + private fun loadDetail(afterAccessChange: Boolean) { val similarRun = ++similarGeneration similarJob?.cancel() + // A newer load replaces an unfinished one, so a response the server + // gave under an older access policy never lands after it. + detailLoadJob?.cancel() + quietDetailJob?.cancel() _uiState.update { it.copy(similarItems = emptyList()) } - viewModelScope.launch { + detailLoadJob = viewModelScope.launch { val similarOwner = recommendationRepository.captureSimilarAuthority() _uiState.update { it.copy(isLoading = true, error = null) } // Start the live request immediately. The durable cache read can // still paint an instant first frame, but it no longer delays the // network request that supplies fresh movie/series metadata. - val liveDetail = async { catalogRepository.getItemDetail(contentId, libraryId = libraryId) } + val liveDetail = async { + catalogRepository.getItemDetail(contentId, libraryId = libraryId, joinWarmup = !afterAccessChange) + } seedCachedDetail() when (val result = liveDetail.await()) { @@ -402,6 +463,9 @@ class ItemDetailViewModel( val seasonNumber = detail.seasonNumber if (seriesId != null && seasonNumber != null) { loadEpisodeSiblings(seriesId, seasonNumber) + } else { + // No parent to look up, so no series poster is coming. + _uiState.update { it.copy(episodeSeriesPosterResolved = true) } } } // For books, learn whether the server converts Kindle formats to @@ -418,6 +482,10 @@ class ItemDetailViewModel( _uiState.update { it.copy( isLoading = false, + // The durable cache still holds a title the server + // now refuses; drop the copy seedCachedDetail painted + // so the screen shows the error, not stale actions. + detail = if (dropsDetailOn(result)) null else it.detail, error = result.message.ifBlank { "Failed to load details" }, ) } @@ -448,16 +516,67 @@ class ItemDetailViewModel( * Deliberately NOT [loadDetail] — no loading flashes, and the user's * season selection is preserved. */ - fun refreshOnReturn() { + fun refreshOnReturn() = quietRefresh() + + /** + * [refreshOnReturn] after the server reports an access change, except that + * a refusal ([isAccessRefusal]) replaces the detail with the error the + * initial load shows. Transient failures still keep the current detail. + * With no detail on screen (an earlier change refused it, or the first + * load failed), it runs the full load again, as Retry does, so a title the + * viewer regains access to comes back. A load still in flight is replaced + * the same way, because its answer may predate the change. + */ + fun refreshAfterAccessChange() { + val state = _uiState.value + // A load still in flight may have been answered under the old policy: + // replace it rather than letting the change pass unapplied. + if (state.isLoading || state.detail == null) { + loadDetail(afterAccessChange = true) + } else { + quietRefresh(showAccessRefusal = true) + } + } + + /** + * Whether a failed read should clear the page: an access refusal, unless + * the title has a completed download. The page's local play and delete + * actions stay usable for a file the server has since deleted or renamed. + */ + private fun dropsDetailOn(result: ApiResult.Error): Boolean = + result.isAccessRefusal() && downloads.value.none { record -> + record.statusEnum() == DownloadStatus.Completed && + (record.contentId == contentId || record.episodeId == contentId) + } + + /** + * [afterWatchedChange] reads the season list and episodes fresh from the + * server: a coalesced request or cached fallback could still hold the + * state from before the write. + * [showAccessRefusal]: see [refreshAfterAccessChange]. + */ + private fun quietRefresh(afterWatchedChange: Boolean = false, showAccessRefusal: Boolean = false) { val current = _uiState.value.detail ?: return - viewModelScope.launch { + // A newer detail read replaces an unfinished one, so an older answer + // cannot land after it, for example restoring a title the newer read + // found refused. The replacement inherits an access-change refresh's + // refusal handling, so a return refresh cannot drop it. + val showRefusal = showAccessRefusal || (quietDetailShowsRefusal && quietDetailJob?.isActive == true) + quietDetailJob?.cancel() + quietDetailShowsRefusal = showRefusal + quietDetailJob = viewModelScope.launch { // Local overlay first: the player's final position write is already // on disk, so the label corrects before the server round-trip. val overlaid = withLocalProgress(current) if (overlaid != current) { _uiState.update { it.copy(detail = overlaid) } } - when (val result = catalogRepository.getItemDetail(contentId, libraryId = libraryId)) { + when (val result = catalogRepository.getItemDetail( + contentId, + libraryId = libraryId, + // An access-change refresh must not reuse a pre-change warm-up. + joinWarmup = !showRefusal, + )) { is ApiResult.Success -> { val detail = withLocalProgress(result.data) _uiState.update { @@ -467,16 +586,26 @@ class ItemDetailViewModel( ) } } + is ApiResult.Error -> if (showRefusal && dropsDetailOn(result)) { + _uiState.update { + it.copy( + isLoading = false, + detail = null, + error = result.message.ifBlank { "Failed to load details" }, + ) + } + } // Quiet refresh: on failure keep showing what we have. - is ApiResult.Error, is ApiResult.NetworkError -> Unit } } if (current.type == "series") { + refreshSeasonsQuietly(current.contentId, fresh = afterWatchedChange) loadEpisodes( current.contentId, _uiState.value.selectedSeasonNumber, forceRefresh = true, + fresh = afterWatchedChange, ) } else if (current.type == "episode") { current.seriesId?.let { @@ -597,10 +726,11 @@ class ItemDetailViewModel( for (season in accumulator.remainingSeasons(seasons)) { if (!routeActive) return@launch + val descriptionRevision = episodeDescriptionRevisions[season.seasonNumber] ?: 0L when (val r = catalogRepository.getEpisodes(seriesId, season.seasonNumber, libraryId = libraryId)) { is ApiResult.Success -> { val episodes = withLocalProgress(r.data.episodes) - cacheEpisodes(season.seasonNumber, episodes) + cacheEpisodes(season.seasonNumber, episodes, descriptionRevision) accumulator.recordSeason(season.seasonNumber, episodes) _uiState.update { it.copy( @@ -670,10 +800,12 @@ class ItemDetailViewModel( episodeSeriesPosterUrl = result.data.posterUrl, episodeSeriesPosterThumbhash = result.data.posterThumbhash, episodeSeriesTitle = result.data.title, + episodeSeriesPosterResolved = true, ) } } - else -> { /* Series poster fallback is optional. */ } + // Series poster fallback is optional. + else -> _uiState.update { it.copy(episodeSeriesPosterResolved = true) } } } } @@ -741,11 +873,15 @@ class ItemDetailViewModel( catalogRepository.getItemDetail(contentId, libraryId = libraryId) } when (result) { - is ApiResult.Success -> _uiState.update { state -> - if (state.selectedEpisodeContentId != contentId) state else state.copy( - selectedEpisodeDetail = withLocalProgress(result.data), - isLoadingSelectedEpisodeDetail = false, - ) + is ApiResult.Success -> { + val refreshed = withLocalProgress(result.data) + _uiState.update { state -> + if (state.selectedEpisodeContentId != contentId) state else state.copy( + selectedEpisodeDetail = state.episodes.firstOrNull { it.contentId == contentId } + ?.let(refreshed::withEpisodeDescription) ?: refreshed, + isLoadingSelectedEpisodeDetail = false, + ) + } } else -> _uiState.update { state -> if (state.selectedEpisodeContentId != contentId) state else state.copy( @@ -792,7 +928,9 @@ class ItemDetailViewModel( seasonsForDownloadRollup: List? = null, forceRefresh: Boolean = false, preferPrefetched: Boolean = false, + fresh: Boolean = false, ) { + episodeRebuildGenerations[seasonNumber] = (episodeRebuildGenerations[seasonNumber] ?: 0L) + 1 episodeLoadJob?.cancel() val cachedEpisodes = _uiState.value.episodesBySeason[seasonNumber] if (!forceRefresh && cachedEpisodes != null) { @@ -815,6 +953,7 @@ class ItemDetailViewModel( } return } + val descriptionRevision = episodeDescriptionRevisions[seasonNumber] ?: 0L episodeLoadJob = viewModelScope.launch { _uiState.update { it.copy( @@ -829,18 +968,23 @@ class ItemDetailViewModel( val result = if (!forceRefresh && preferPrefetched) { catalogRepository.getEpisodesForPrefetch(seriesId, seasonNumber, libraryId = libraryId) } else { - catalogRepository.getEpisodes(seriesId, seasonNumber, libraryId = libraryId) + catalogRepository.getEpisodes(seriesId, seasonNumber, libraryId = libraryId, fresh = fresh) } when (result) { is ApiResult.Success -> { val episodes = withLocalProgress(result.data.episodes) loadedSeasonNumber = seasonNumber + episodeRebuildGenerations[seasonNumber] = (episodeRebuildGenerations[seasonNumber] ?: 0L) + 1 _uiState.update { - val cache = it.episodesBySeason + (seasonNumber to episodes) + val current = it.episodesBySeason[seasonNumber].orEmpty().associateBy { row -> row.contentId } + val rows = if (descriptionRevision != (episodeDescriptionRevisions[seasonNumber] ?: 0L)) { + episodes.map { row -> current[row.contentId]?.let(row::withTranslatedDescription) ?: row } + } else episodes + val cache = it.episodesBySeason + (seasonNumber to rows) it.copy( isLoadingEpisodes = false, episodesBySeason = cache, - episodes = if (it.selectedSeasonNumber == seasonNumber) episodes else it.episodes, + episodes = if (it.selectedSeasonNumber == seasonNumber) rows else it.episodes, ) } ensureSeriesEpisodeSelection(episodes) @@ -871,15 +1015,39 @@ class ItemDetailViewModel( } } + private val episodeDescriptionRevisions = mutableMapOf() + private val episodeRebuildGenerations = mutableMapOf() + private fun cacheEpisodes( seasonNumber: Int, episodes: List, + descriptionRevision: Long, ) { + episodeRebuildGenerations[seasonNumber] = (episodeRebuildGenerations[seasonNumber] ?: 0L) + 1 _uiState.update { - val cache = it.episodesBySeason + (seasonNumber to episodes) + val current = it.episodesBySeason[seasonNumber].orEmpty().associateBy { row -> row.contentId } + val rows = if (descriptionRevision != (episodeDescriptionRevisions[seasonNumber] ?: 0L)) { + episodes.map { row -> current[row.contentId]?.let(row::withTranslatedDescription) ?: row } + } else episodes + val cache = it.episodesBySeason + (seasonNumber to rows) it.copy( episodesBySeason = cache, - episodes = if (it.selectedSeasonNumber == seasonNumber) episodes else it.episodes, + episodes = if (it.selectedSeasonNumber == seasonNumber) rows else it.episodes, + ) + } + } + + private fun cacheTranslatedEpisodes(seasonNumber: Int, fresh: List) { + episodeDescriptionRevisions[seasonNumber] = (episodeDescriptionRevisions[seasonNumber] ?: 0L) + 1 + _uiState.update { state -> + val current = state.episodesBySeason[seasonNumber].orEmpty().associateBy { it.contentId } + val merged = fresh.map { row -> current[row.contentId]?.withTranslatedDescription(row) ?: row } + val selected = merged.firstOrNull { it.contentId == state.selectedEpisodeContentId } + state.copy( + episodesBySeason = state.episodesBySeason + (seasonNumber to merged), + episodes = if (state.selectedSeasonNumber == seasonNumber) merged else state.episodes, + selectedEpisodeDetail = selected?.let { state.selectedEpisodeDetail?.withEpisodeDescription(it) } + ?: state.selectedEpisodeDetail, ) } } @@ -1129,6 +1297,59 @@ class ItemDetailViewModel( } } + /** + * `auto` on-view mode for the inline episode list: start the shown season's + * translation once per (season, language) when its episode rows report a + * missing description, then re-read the episodes until none does. + */ + fun translateShownSeasonEpisodes() { + val state = _uiState.value + val detail = state.detail ?: return + val seriesId = if (detail.type == "series") detail.contentId else detail.seriesId ?: return + val seasonNumber = state.selectedSeasonNumber + val season = state.seasons.firstOrNull { it.seasonNumber == seasonNumber } ?: return + val target = state.episodes + .filter { it.seasonNumber == seasonNumber } + .pendingEpisodeTranslationLanguage() ?: return + // A season switched to while another season's job polls is retried + // when that job ends (below), rather than latched as already fired. + if (!episodeTranslation.claimAutoFire(season.contentId, target)) return + episodeTranslation.resetFailure() + viewModelScope.launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) { + episodeTranslation.translate( + contentId = season.contentId, + targetLanguage = target, + refetchPendingLanguage = { owner -> + val rebuildGeneration = episodeRebuildGenerations[seasonNumber] ?: 0L + when ( + val result = catalogRepository.getEpisodes( + seriesId, + seasonNumber, + libraryId = libraryId, + fresh = true, + ) + ) { + is ApiResult.Success -> if (metadataAiRepository.isCurrent(owner)) { + val episodes = withLocalProgress(result.data.episodes) + kotlinx.coroutines.currentCoroutineContext().ensureActive() + if (metadataAiRepository.isCurrent(owner) && + rebuildGeneration == (episodeRebuildGenerations[seasonNumber] ?: 0L) + ) { + cacheTranslatedEpisodes(seasonNumber, episodes) + episodes.pendingEpisodeTranslationLanguage() + } else target + } else { + target + } + else -> target // transient refetch failure: keep polling + } + }, + onTranslated = {}, + ) + if (episodeTranslation.takeDeferredAuto()) translateShownSeasonEpisodes() + } + } + /** * Toggles the watchlist state for this item. */ @@ -1139,17 +1360,231 @@ class ItemDetailViewModel( fun toggleWatched() { val currentDetail = _uiState.value.detail ?: return - val current = currentDetail.userData?.played == true - val target = !current + val previous = currentDetail.userData + val target = previous?.played != true val generation = ++watchedMutationGeneration - updatePlayedState(target) + val isSeries = currentDetail.type == "series" + if (isSeries) seasonsRefreshGeneration++ + val episodeGenerationsAtStart = episodeWatchedMutationGenerations.toMap() + val admittedAtMs = System.currentTimeMillis() + updateOwnUserData { it.withPlayed(target) } val writeIntent = personalDataRepository.beginWatched(contentId, target) viewModelScope.launch { val writeResult = personalDataRepository.performPersonalWrite(writeIntent) if (!personalDataRepository.isCurrent(writeIntent)) return@launch when (writeResult) { - is ApiResult.Success -> { /* already updated */ } - else -> if (generation == watchedMutationGeneration) updatePlayedState(current) + // The server applied a series change to every episode; re-read + // the seasons and episodes so their checkmarks follow. + is ApiResult.Success -> if (isSeries) { + // Episodes written on their own since this began keep that state: + // any successful write since, or a newer write that has not failed. + val changedSince = episodeWatchedMutationGenerations + .filter { (id, generation) -> + (succeededEpisodeWatchedGenerations[id] ?: 0) > (episodeGenerationsAtStart[id] ?: 0) || + (episodeGenerationsAtStart[id] != generation && failedEpisodeWatchedGenerations[id] != generation) + } + .keys + updateSeasonPlayedState(seasonNumber = null, played = target, skipEpisodeIds = changedSince) + val loaded = _uiState.value.episodesBySeason + userItemState.clearLocalPlaybackProgressBefore( + loaded.values.flatten().map { it.contentId }, + admittedAtMs, + writeIntent.identityGeneration, + ) + refreshAfterSeasonWatchedChange(currentDetail.contentId, changedSeasonNumber = null) + // Seasons not loaded yet are read after the visible refresh starts. + val unloaded = _uiState.value.seasons.map { it.seasonNumber }.filter { it !in loaded } + userItemState.clearLocalPlaybackProgressBefore( + unloaded.flatMap { seasonEpisodeIds(currentDetail.contentId, it) }, + admittedAtMs, + writeIntent.identityGeneration, + ) + } + else -> if (generation == watchedMutationGeneration) { + if (isSeries) failedSeriesWatchedGeneration = generation + updateOwnUserData { it.withoutPlayed(target, previous) } + } + } + } + } + + /** + * Marks every episode of [season] from its chip or the series overflow + * menu. The server applies the change to the season's episodes. + */ + fun setSeasonWatched(season: Season, watched: Boolean) { + val state = _uiState.value + val detail = state.detail ?: return + if (detail.type != "series") return + val seriesId = detail.contentId + val seasonNumber = season.seasonNumber + val previousSeason = state.seasons.firstOrNull { it.seasonNumber == seasonNumber } ?: return + val previousEpisodes = state.episodesBySeason[seasonNumber] + val episodeGenerationsAtStart = episodeWatchedMutationGenerations.toMap() + val admittedAtMs = System.currentTimeMillis() + + val seriesGenerationAtStart = watchedMutationGeneration + val generation = (seasonWatchedMutationGenerations[seasonNumber] ?: 0) + 1 + seasonWatchedMutationGenerations[seasonNumber] = generation + // A season list read already in flight predates this change. + seasonsRefreshGeneration++ + updateSeasonPlayedState(seasonNumber, watched) + val writeIntent = personalDataRepository.beginWatched(season.contentId, watched) + viewModelScope.launch { + val writeResult = personalDataRepository.performPersonalWrite(writeIntent) + if (!personalDataRepository.isCurrent(writeIntent)) return@launch + if (seasonWatchedMutationGenerations[seasonNumber] != generation) return@launch + when (writeResult) { + is ApiResult.Success -> { + // Both lists in case the cached page changed while the write was pending. + val episodeIds = (_uiState.value.episodesBySeason[seasonNumber].orEmpty() + previousEpisodes.orEmpty()) + .map { it.contentId } + .distinct() + .ifEmpty { seasonEpisodeIds(seriesId, seasonNumber) } + userItemState.clearLocalPlaybackProgressBefore(episodeIds, admittedAtMs, writeIntent.identityGeneration) + refreshAfterSeasonWatchedChange(seriesId, seasonNumber) + } + // A series write since this one began (and not failed) also + // covers this season; the snapshot is stale, so re-read the server. + else -> if (watchedMutationGeneration != seriesGenerationAtStart && + failedSeriesWatchedGeneration != watchedMutationGeneration + ) { + refreshAfterSeasonWatchedChange(seriesId, changedSeasonNumber = null) + } else { + restoreSeasonPlayedState( + previousSeason, + previousEpisodes, + episodeGenerationsAtStart, + ) + } + } + } + } + + /** + * Applies a season ([seasonNumber]) or whole-series (null) watched change to + * the loaded seasons and episodes. Like the server, both marking and + * unmarking clear an episode's resume point, so a failed follow-up read + * cannot leave Resume offering the old position. + */ + private fun updateSeasonPlayedState( + seasonNumber: Int?, + played: Boolean, + skipEpisodeIds: Set = emptySet(), + ) { + fun EpisodeListItem.updated(): EpisodeListItem = if (contentId in skipEpisodeIds) this else copy( + userData = (userData ?: LeafItemUserData()).copy( + played = played, + isInProgress = false, + positionSeconds = null, + ), + ) + fun matches(number: Int) = seasonNumber == null || number == seasonNumber + _uiState.update { state -> + state.copy( + seasons = state.seasons.map { season -> + if (!matches(season.seasonNumber)) season else season.copy( + userData = (season.userData ?: SeasonUserData()).copy(played = played), + ) + }, + episodes = if (matches(state.selectedSeasonNumber)) { + state.episodes.map { it.updated() } + } else { + state.episodes + }, + episodesBySeason = state.episodesBySeason.mapValues { (number, episodes) -> + if (matches(number)) episodes.map { it.updated() } else episodes + }, + ) + } + } + + private fun restoreSeasonPlayedState( + previousSeason: Season, + previousEpisodes: List?, + episodeGenerationsAtStart: Map, + ) { + val seasonNumber = previousSeason.seasonNumber + // An episode marked on its own while the season write was pending keeps + // that newer state; only the season write's own change is undone. + val previousById = previousEpisodes.orEmpty() + .filter { + val latest = episodeWatchedMutationGenerations[it.contentId] + // No episode write admitted since the season write began: restore. + // Otherwise restore only if none of those writes succeeded and the + // latest failed, having rolled back to this season's optimistic state. + val atStart = episodeGenerationsAtStart[it.contentId] ?: 0 + latest == episodeGenerationsAtStart[it.contentId] || + ((succeededEpisodeWatchedGenerations[it.contentId] ?: 0) <= atStart && + failedEpisodeWatchedGenerations[it.contentId] == latest) + } + .associateBy { it.contentId } + fun List.restored() = map { previousById[it.contentId]?.withTranslatedDescription(it) ?: it } + _uiState.update { state -> + val episodesBySeason = if (previousEpisodes != null) { + state.episodesBySeason.mapValues { (number, episodes) -> + if (number == seasonNumber) episodes.restored() else episodes + } + } else { + state.episodesBySeason - seasonNumber + } + state.copy( + seasons = state.seasons.map { if (it.seasonNumber == seasonNumber) previousSeason else it }, + episodes = if (state.selectedSeasonNumber == seasonNumber) state.episodes.restored() else state.episodes, + episodesBySeason = episodesBySeason, + ) + } + } + + /** Episode ids of a season whose page is not loaded; empty if the read fails. */ + private suspend fun seasonEpisodeIds(seriesId: String, seasonNumber: Int): List = + when (val result = catalogRepository.getEpisodes(seriesId, seasonNumber, libraryId = libraryId, fresh = true)) { + is ApiResult.Success -> result.data.episodes.map { it.contentId } + is ApiResult.Error, + is ApiResult.NetworkError -> emptyList() + } + + /** + * Re-reads what a season or series watched change affected: the season + * list (season watched state), the series hero, and the visible season's + * episodes. Other seasons' cached episodes are dropped when they may be + * stale so selecting them later loads fresh checkmarks. + */ + private fun refreshAfterSeasonWatchedChange(seriesId: String, changedSeasonNumber: Int?) { + val state = _uiState.value + val retained = state.episodesBySeason.filterKeys { number -> + // The loaded page is the fallback if the selected season's reload fails. + number == state.selectedSeasonNumber || number == loadedSeasonNumber || + (changedSeasonNumber != null && number != changedSeasonNumber) + } + (state.episodesBySeason.keys - retained.keys).forEach { number -> + episodeRebuildGenerations[number] = (episodeRebuildGenerations[number] ?: 0L) + 1 + } + _uiState.update { it.copy(episodesBySeason = retained) } + // Re-reads the series hero, the season list, and the visible episodes. + quietRefresh(afterWatchedChange = true) + } + + /** + * Replaces the season list (and its watched state) without touching the + * selection. Only the latest request publishes, and a season watched + * change bumps the generation so an older read cannot undo it. + */ + private fun refreshSeasonsQuietly(seriesId: String, fresh: Boolean = false) { + val generation = ++seasonsRefreshGeneration + viewModelScope.launch { + when (val result = catalogRepository.getSeasons(seriesId, libraryId = libraryId, fresh = fresh)) { + is ApiResult.Success -> { + val seasons = result.data.seasons.sortedForDisplay() + _uiState.update { state -> + if (generation != seasonsRefreshGeneration || state.detail?.contentId != seriesId || + seasons.isEmpty() + ) state else state.copy(seasons = seasons) + } + } + // Quiet refresh: on failure keep the season state on screen. + is ApiResult.Error, + is ApiResult.NetworkError -> Unit } } } @@ -1157,47 +1592,48 @@ class ItemDetailViewModel( /** Marks one episode from the in-page rail without navigating away. */ fun setEpisodeWatched(episodeContentId: String, watched: Boolean) { val state = _uiState.value - val previous = state.episodes.firstOrNull { it.contentId == episodeContentId } - ?.userData?.played + val previous = (state.episodes.firstOrNull { it.contentId == episodeContentId } ?: state.episodesBySeason.values.asSequence() .flatten() - .firstOrNull { it.contentId == episodeContentId } - ?.userData?.played - ?: false - if (previous == watched) return + .firstOrNull { it.contentId == episodeContentId }) + ?.userData + if ((previous?.played ?: false) == watched) return val generation = (episodeWatchedMutationGenerations[episodeContentId] ?: 0) + 1 episodeWatchedMutationGenerations[episodeContentId] = generation - updateEpisodePlayedState(episodeContentId, watched) + updateEpisodeUserData(episodeContentId) { it.withPlayed(watched) } val writeIntent = personalDataRepository.beginWatched(episodeContentId, watched) viewModelScope.launch { val writeResult = personalDataRepository.performPersonalWrite(writeIntent) if (!personalDataRepository.isCurrent(writeIntent)) return@launch when (writeResult) { - is ApiResult.Success -> Unit + // The season's watched state may have flipped with this episode. + is ApiResult.Success -> { + succeededEpisodeWatchedGenerations[episodeContentId] = generation + _uiState.value.detail + ?.takeIf { it.type == "series" } + ?.let { refreshSeasonsQuietly(it.contentId) } + } else -> if (episodeWatchedMutationGenerations[episodeContentId] == generation) { - updateEpisodePlayedState(episodeContentId, previous) + failedEpisodeWatchedGenerations[episodeContentId] = generation + updateEpisodeUserData(episodeContentId) { it.withoutPlayed(watched, previous) } } } } } - private fun updateEpisodePlayedState(episodeContentId: String, played: Boolean) { + private fun updateEpisodeUserData(episodeContentId: String, transform: (LeafItemUserData?) -> LeafItemUserData?) { fun EpisodeListItem.updated(): EpisodeListItem = - if (contentId != episodeContentId) this else copy( - userData = (userData ?: LeafItemUserData()).copy(played = played), - ) + if (contentId != episodeContentId) this else copy(userData = transform(userData)) _uiState.update { state -> val selectedDetail = state.selectedEpisodeDetail?.let { episodeDetail -> - if (episodeDetail.contentId != episodeContentId) episodeDetail else episodeDetail.copy( - userData = (episodeDetail.userData ?: LeafItemUserData()).copy(played = played), - ) + if (episodeDetail.contentId != episodeContentId) episodeDetail + else episodeDetail.copy(userData = transform(episodeDetail.userData)) } val ownDetail = state.detail?.let { itemDetail -> - if (itemDetail.contentId != episodeContentId) itemDetail else itemDetail.copy( - userData = (itemDetail.userData ?: LeafItemUserData()).copy(played = played), - ) + if (itemDetail.contentId != episodeContentId) itemDetail + else itemDetail.copy(userData = transform(itemDetail.userData)) } state.copy( detail = ownDetail, @@ -1210,11 +1646,31 @@ class ItemDetailViewModel( } } - private fun updatePlayedState(played: Boolean) { + private fun updateOwnUserData(transform: (LeafItemUserData?) -> LeafItemUserData?) { _uiState.update { state -> val detail = state.detail ?: return@update state - val userData = detail.userData ?: LeafItemUserData() - state.copy(detail = detail.copy(userData = userData.copy(played = played))) + state.copy(detail = detail.copy(userData = transform(detail.userData))) } } } + +/** + * Watched and unwatched both reset resume progress, as the server does, so + * spoiler protection sees the reset episode as not started. + */ +internal fun LeafItemUserData?.withPlayed(played: Boolean): LeafItemUserData = + (this ?: LeafItemUserData()).copy(played = played, isInProgress = false, positionSeconds = null) + +/** + * Undoes a failed [withPlayed] field by field, only where it still shows: a + * reload's watched state, or progress recorded since the toggle, stays. + */ +internal fun LeafItemUserData?.withoutPlayed(played: Boolean, previous: LeafItemUserData?): LeafItemUserData? { + if (this == null || this.played != played) return this + val progressUntouched = isInProgress == false && positionSeconds == null + return copy( + played = previous?.played ?: false, + isInProgress = if (progressUntouched) previous?.isInProgress else isInProgress, + positionSeconds = if (progressUntouched) previous?.positionSeconds else positionSeconds, + ) +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MovieDetailContent.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MovieDetailContent.kt index 88a58f220c..afc449abaa 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MovieDetailContent.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MovieDetailContent.kt @@ -1,5 +1,6 @@ package org.prairieserver.prairie.android.ui.screens.detail +import org.prairieserver.prairie.android.ui.components.PrairieDropdownMenuItem import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -12,14 +13,12 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.outlined.AudioFile import androidx.compose.material.icons.outlined.ClosedCaption import androidx.compose.material.icons.outlined.Groups import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -37,6 +36,7 @@ import org.prairieserver.prairie.android.ui.theme.PrairieBackground import org.prairieserver.prairie.android.ui.theme.PrairieDetailActionControl import org.prairieserver.prairie.android.ui.theme.PrairieDetailActionControlActive import org.prairieserver.prairie.android.ui.util.rememberDominantColor +import org.prairieserver.prairie.common.cards.LocalEpisodeSpoilerPrefs import org.prairieserver.prairie.common.ui.movieDirectorCredit import org.prairieserver.prairie.model.catalog.EpisodeListItem import org.prairieserver.prairie.model.catalog.ItemDetail @@ -44,6 +44,7 @@ import org.prairieserver.prairie.model.catalog.ItemExtra import org.prairieserver.prairie.model.catalog.Season import org.prairieserver.prairie.model.catalog.trailerRailEntries import org.prairieserver.prairie.model.catalog.selectedMediaRuntimeMinutes +import org.prairieserver.prairie.model.settings.EpisodeSpoilers import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.snapshotFlow @@ -98,8 +99,8 @@ fun MovieDetailContent( isDownloaded: Boolean = false, downloadProgress: Float? = null, onDownloadTapped: (() -> Unit)? = null, - onWatchTogether: (() -> Unit)? = null, - onSuggestToRoom: (() -> Unit)? = null, + /** The Watch Party overflow action (host, add, or suggest), when one applies. */ + partyAction: org.prairieserver.prairie.android.ui.screens.watchparty.DetailPartyAction? = null, translation: (@Composable () -> Unit)? = null, modifier: Modifier = Modifier, ) { @@ -116,14 +117,16 @@ fun MovieDetailContent( val audioTracks = selectedVersion?.audioTracks.orEmpty() val subtitleTracks = selectedVersion?.subtitleTracks.orEmpty() val hasTrackSelectors = detail.versions.isNotEmpty() - val hasOverflow = onSeriesClick != null || onWatchTogether != null || - onSuggestToRoom != null + val hasOverflow = onSeriesClick != null || partyAction != null val eyebrow = if (detail.type == "episode") { HeroMetadata.episodeEyebrow(detail) } else { null } + // Spoiler protection: an unwatched episode's page drops its description. + val hidesEpisodeOverview = detail.type == "episode" && + LocalEpisodeSpoilerPrefs.current.hidesOverview(EpisodeSpoilers.isUnwatched(detail.userData)) val sourceTokens = HeroMetadata.movieSourceTokens(detail) val selectedRuntimeMinutes = selectedMediaRuntimeMinutes(detail, selectedVersion) val factsLine = HeroMetadata.movieFactsLine(detail, selectedRuntimeMinutes) @@ -168,7 +171,9 @@ fun MovieDetailContent( portraitArtwork = portraitArtwork, dominantColor = dominantColor, directorText = movieDirectorCredit(detail), - translation = translation, + overviewText = if (hidesEpisodeOverview) null else detail.overview, + // No "Translate description" under a hidden description. + translation = if (hidesEpisodeOverview) null else translation, belowOverview = { // PR #212 places the grouped playback card after overview, // credits, and translation—not inside the action stack. @@ -222,7 +227,7 @@ fun MovieDetailContent( overflow = if (hasOverflow) { { dismiss -> if (onSeriesClick != null) { - DropdownMenuItem( + PrairieDropdownMenuItem( text = { Text("Go to Series") }, onClick = { dismiss() @@ -230,27 +235,15 @@ fun MovieDetailContent( }, ) } - if (onSuggestToRoom != null) { - DropdownMenuItem( - text = { Text("Suggest to Watch Together") }, - leadingIcon = { - Icon(Icons.Filled.Add, contentDescription = null) - }, - onClick = { - dismiss() - onSuggestToRoom() - }, - ) - } - if (onWatchTogether != null) { - DropdownMenuItem( - text = { Text("Watch Together") }, + if (partyAction != null) { + PrairieDropdownMenuItem( + text = { Text(partyAction.label) }, leadingIcon = { Icon(Icons.Outlined.Groups, contentDescription = null) }, onClick = { dismiss() - onWatchTogether() + partyAction.onClick() }, ) } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SeriesDetailContent.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SeriesDetailContent.kt index b99faa4ca9..71c57958a6 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SeriesDetailContent.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SeriesDetailContent.kt @@ -1,5 +1,7 @@ package org.prairieserver.prairie.android.ui.screens.detail +import org.prairieserver.prairie.android.ui.components.PrairieDropdownMenuItem +import org.prairieserver.prairie.android.ui.components.PrairieDropdownMenu import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -11,11 +13,12 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Shuffle import androidx.compose.material.icons.outlined.Groups import androidx.compose.material.icons.outlined.AudioFile +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.Circle import androidx.compose.material.icons.outlined.ClosedCaption -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -28,11 +31,14 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.prairieserver.prairie.android.ui.theme.PrairieBackground import org.prairieserver.prairie.android.ui.util.rememberDominantColor +import org.prairieserver.prairie.common.cards.LocalEpisodeSpoilerPrefs import org.prairieserver.prairie.model.catalog.EpisodeListItem +import org.prairieserver.prairie.model.catalog.hasMachineTranslatedOverview import org.prairieserver.prairie.model.catalog.ItemDetail import org.prairieserver.prairie.model.catalog.ItemExtra import org.prairieserver.prairie.model.catalog.Season import org.prairieserver.prairie.model.catalog.trailerRailEntries +import org.prairieserver.prairie.model.settings.EpisodeSpoilers import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.snapshotFlow @@ -72,6 +78,11 @@ fun SeriesDetailContent( onEpisodeDetailClick: (String) -> Unit, onEpisodeWatchedChange: (String, Boolean) -> Unit, onSeasonSelected: (Int) -> Unit, + onSeasonWatchedChange: ((Season, Boolean) -> Unit)? = null, + // Shuffle the whole series, or the season on screen; null where the + // server doesn't offer that scope. + onShuffleSeries: (() -> Unit)? = null, + onShuffleSeason: ((Season) -> Unit)? = null, onFavoriteClick: () -> Unit, onWatchlistClick: () -> Unit, onToggleWatched: () -> Unit, @@ -84,8 +95,8 @@ fun SeriesDetailContent( /** Series-level roll-up across ALL seasons: isDownloaded when every episode * is downloaded, progress = downloaded/total fraction while partial. */ seriesDownloadState: DetailDownloadState = DetailDownloadState(), - onWatchTogether: (() -> Unit)? = null, - onSuggestToRoom: (() -> Unit)? = null, + /** The Watch Party overflow action (host, add, or suggest), when one applies. */ + partyAction: org.prairieserver.prairie.android.ui.screens.watchparty.DetailPartyAction? = null, translation: (@Composable () -> Unit)? = null, modifier: Modifier = Modifier, ) { @@ -105,14 +116,45 @@ fun SeriesDetailContent( val loadedSelectedEpisodeDetail = selectedEpisodeDetail ?.takeIf { it.contentId == selectedEpisodeContentId } val usesEpisodeEditorial = selectedEpisode != null || selectedEpisodeContentId != null - val selectedEpisodeOverview = loadedSelectedEpisodeDetail?.overview - ?.takeIf { it.isNotBlank() } - ?: selectedEpisode?.overview?.takeIf { it.isNotBlank() } + // Spoiler protection: the rail row's watch state is the one the long-press + // "Mark as Watched" updates, so it wins over the loaded episode detail. + val selectedEpisodeUnwatched = EpisodeSpoilers.isUnwatched( + selectedEpisode?.userData ?: loadedSelectedEpisodeDetail?.userData, + ) + val hidesSelectedEpisodeOverview = + LocalEpisodeSpoilerPrefs.current.hidesOverview(selectedEpisodeUnwatched) + val selectedEpisodeOverview = if (hidesSelectedEpisodeOverview) { + null + } else { + loadedSelectedEpisodeDetail?.overview + ?.takeIf { it.isNotBlank() } + ?: selectedEpisode?.overview?.takeIf { it.isNotBlank() } + } + // Mark the episode overview by the same source the text above came from. + val selectedEpisodeOverviewMachineTranslated = when { + !loadedSelectedEpisodeDetail?.overview.isNullOrBlank() -> + hasMachineTranslatedOverview(loadedSelectedEpisodeDetail?.machineTranslatedFields) + selectedEpisodeOverview != null -> hasMachineTranslatedOverview(selectedEpisode?.machineTranslatedFields) + else -> false + } + val episodeTranslationLabel: (@Composable () -> Unit)? = + if (!hidesSelectedEpisodeOverview && selectedEpisodeOverviewMachineTranslated) { + { DetailMachineTranslatedLabel() } + } else { + null + } // iOS keeps the series cast credit stable while the selected episode's // overview and playback options change. Reusing the series credit avoids // replacing it with a skeleton (and repainting different names) on every // horizontal episode selection. val fixedSeriesCredit = remember(detail.contentId, detail.cast) { seriesStarringCredit(detail) } + val markableSelectedSeason = selectedSeason?.takeIf { + onSeasonWatchedChange != null && it.episodeCount > 0 + } + // Hidden on a season with fewer than two playable episodes. + val shuffleableSelectedSeason = selectedSeason?.takeIf { + onShuffleSeason != null && org.prairieserver.prairie.model.shuffle.canShuffleSeason(episodes) + } val episodeCountSubtitle = selectedSeason?.episodeCount?.takeIf { it > 0 }?.let { count -> "$count episode${if (count == 1) "" else "s"}" } @@ -169,6 +211,7 @@ fun SeriesDetailContent( seasons = seasons, selectedSeasonNumber = selectedSeasonNumber, onSeasonSelected = onSeasonSelected, + onSeasonWatchedChange = onSeasonWatchedChange, ) } if (showsEpisodeDetails) { @@ -257,11 +300,14 @@ fun SeriesDetailContent( } else { detail.overview }, - reserveOverviewSpace = !isExpandedDetailLayout && usesEpisodeEditorial, + // A hidden (spoiler) overview collapses instead of leaving + // the reserved block empty between the actions and the credit. + reserveOverviewSpace = !isExpandedDetailLayout && usesEpisodeEditorial && + !hidesSelectedEpisodeOverview, directorText = fixedSeriesCredit, isCreditLoading = false, reserveCreditSpace = !isExpandedDetailLayout && usesEpisodeEditorial, - translation = if (isExpandedDetailLayout || !usesEpisodeEditorial) translation else null, + translation = if (isExpandedDetailLayout || !usesEpisodeEditorial) translation else episodeTranslationLabel, belowOverview = if (isExpandedDetailLayout) null else playbackSelector, expandedBelowOverview = { episodeSection(true) @@ -281,30 +327,64 @@ fun SeriesDetailContent( onToggleWatchlist = onWatchlistClick, onToggleWatched = onToggleWatched, overflow = if ( - onWatchTogether != null || onSuggestToRoom != null + markableSelectedSeason != null || partyAction != null || + onShuffleSeries != null || shuffleableSelectedSeason != null ) { { dismiss -> - if (onSuggestToRoom != null) { - DropdownMenuItem( - text = { Text("Suggest to Watch Together") }, + if (onShuffleSeries != null) { + PrairieDropdownMenuItem( + text = { Text("Shuffle Series") }, + leadingIcon = { Icon(Icons.Filled.Shuffle, contentDescription = null) }, + onClick = { + dismiss() + onShuffleSeries() + }, + ) + } + if (shuffleableSelectedSeason != null && onShuffleSeason != null) { + PrairieDropdownMenuItem( + text = { Text("Shuffle ${phoneSeasonLabel(shuffleableSelectedSeason)}") }, + leadingIcon = { Icon(Icons.Filled.Shuffle, contentDescription = null) }, + onClick = { + dismiss() + onShuffleSeason(shuffleableSelectedSeason) + }, + ) + } + if (markableSelectedSeason != null && onSeasonWatchedChange != null) { + val seasonWatched = markableSelectedSeason.userData?.played == true + PrairieDropdownMenuItem( + text = { + Text( + "Mark ${phoneSeasonLabel(markableSelectedSeason)} " + + if (seasonWatched) "Unwatched" else "Watched", + ) + }, leadingIcon = { - Icon(Icons.Outlined.Groups, contentDescription = null) + Icon( + if (seasonWatched) { + Icons.Outlined.Circle + } else { + Icons.Outlined.CheckCircle + }, + contentDescription = null, + ) }, onClick = { dismiss() - onSuggestToRoom() + onSeasonWatchedChange(markableSelectedSeason, !seasonWatched) }, ) } - if (onWatchTogether != null) { - DropdownMenuItem( - text = { Text("Watch Together") }, + if (partyAction != null) { + PrairieDropdownMenuItem( + text = { Text(partyAction.label) }, leadingIcon = { Icon(Icons.Outlined.Groups, contentDescription = null) }, onClick = { dismiss() - onWatchTogether() + partyAction.onClick() }, ) } @@ -335,12 +415,12 @@ fun SeriesDetailContent( } }, ) - DropdownMenu( + PrairieDropdownMenu( expanded = showDownloadMenu, onDismissRequest = { showDownloadMenu = false }, ) { if (onEpisodeDownloadClick != null) { - DropdownMenuItem( + PrairieDropdownMenuItem( text = { Text(when { episodeDownloadState.isDownloaded -> "Episode downloaded" episodeDownloadState.progress != null -> "Cancel episode download" @@ -354,7 +434,7 @@ fun SeriesDetailContent( ) } if (onSeriesDownloadClick != null) { - DropdownMenuItem( + PrairieDropdownMenuItem( text = { Text(if (seriesDownloadState.isDownloaded) "Series downloaded" else "Download series") }, enabled = !seriesDownloadState.isDownloaded, onClick = { diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/downloads/DownloadEntryRows.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/downloads/DownloadEntryRows.kt index 43bc63d075..be96277cf8 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/downloads/DownloadEntryRows.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/downloads/DownloadEntryRows.kt @@ -40,7 +40,10 @@ 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 org.prairieserver.prairie.common.ui.components.ThumbhashImage +import org.prairieserver.prairie.common.ui.components.SpoilerImage +import org.prairieserver.prairie.common.cards.LocalEpisodeSpoilerPrefs +import org.prairieserver.prairie.model.settings.EpisodeSpoilerPrefs +import org.prairieserver.prairie.model.settings.EpisodeSpoilers import org.prairieserver.prairie.model.download.DownloadMediaType import org.prairieserver.prairie.model.download.DownloadStatus import org.prairieserver.prairie.model.ebook.ebookFormatKey @@ -54,6 +57,28 @@ internal enum class DownloadRowAction { OpenInAppPlayer, } +internal fun downloadItemHidesArtwork(item: DownloadItem, prefs: EpisodeSpoilerPrefs): Boolean = + item.mediaType == DownloadMediaType.TvShow && + prefs.hidesImage(EpisodeSpoilers.isUnwatched(item.episodeUserData), item.posterIsEpisodeStill) + +internal fun downloadEntryHidesArtwork(entry: DownloadEntry, prefs: EpisodeSpoilerPrefs): Boolean { + fun items(node: DownloadEntry): List = when (node) { + is DownloadEntry.Single -> listOf(node.item) + is DownloadEntry.Season -> node.episodes.map { it.item } + is DownloadEntry.Series -> node.seasons.flatMap { it.episodes }.map { it.item } + is DownloadEntry.Author -> node.books.map { it.item } + } + // A season row shows the series-wide poster, which may come from another + // season's episode, so grouped rows name the download it came from. + val declared = when (entry) { + is DownloadEntry.Season -> entry.posterSource + is DownloadEntry.Series -> entry.posterSource + else -> null + } + val source = declared ?: items(entry).firstOrNull { it.posterUrl == entry.posterUrl } ?: return false + return downloadItemHidesArtwork(source, prefs) +} + internal fun downloadRowAction(item: DownloadItem): DownloadRowAction { if (!item.isComplete || item.localUri.isNullOrBlank()) return DownloadRowAction.None if (item.mediaType == DownloadMediaType.Ebook) { @@ -324,9 +349,10 @@ private fun SingleRow( .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { - ThumbhashImage( + SpoilerImage( url = item.posterUrl, thumbhash = item.posterThumbhash, + hidden = downloadItemHidesArtwork(item, LocalEpisodeSpoilerPrefs.current), contentDescription = item.title, modifier = Modifier .width(56.dp) @@ -450,6 +476,7 @@ private fun ExpandableAggregateRow( isComplete = entry.isComplete, posterUrl = entry.posterUrl, posterThumbhash = entry.posterThumbhash, + hidden = downloadEntryHidesArtwork(entry, LocalEpisodeSpoilerPrefs.current), expanded = expanded, onToggleExpand = { expanded = !expanded }, onDelete = { onDeleteEntry(entry) }, @@ -485,6 +512,7 @@ private fun AggregateRow( isComplete: Boolean, posterUrl: String?, posterThumbhash: String?, + hidden: Boolean, expanded: Boolean, onToggleExpand: () -> Unit, onDelete: () -> Unit, @@ -496,9 +524,10 @@ private fun AggregateRow( .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { - ThumbhashImage( + SpoilerImage( url = posterUrl, thumbhash = posterThumbhash, + hidden = hidden, contentDescription = title, modifier = Modifier .width(56.dp) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/downloads/DownloadsScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/downloads/DownloadsScreen.kt index 4b48d7ea40..2f2afbe8a2 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/downloads/DownloadsScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/downloads/DownloadsScreen.kt @@ -1,7 +1,14 @@ package org.prairieserver.prairie.android.ui.screens.downloads +import org.prairieserver.prairie.android.ui.components.PrairieDialogActionStyle +import org.prairieserver.prairie.android.ui.components.PrairieDialogAction +import org.prairieserver.prairie.android.ui.components.PrairieDialog +import org.prairieserver.prairie.android.ui.components.PrairieConfirmDialog import org.prairieserver.prairie.android.ui.util.formatBytes import android.content.Context +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities @@ -25,7 +32,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.CloudOff import androidx.compose.material.icons.outlined.DownloadForOffline -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -94,6 +100,15 @@ fun DownloadsScreen( ) { val state by viewModel.uiState.collectAsState() val snackbarHostState = remember { SnackbarHostState() } + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(viewModel) { viewModel.refreshLocalUserState() } + DisposableEffect(lifecycleOwner, viewModel) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) viewModel.refreshLocalUserState() + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } // Multi-select delete (mirrors iOS's edit-mode selection). Selection unit // is a top-level entry — selecting a series selects all its episodes. @@ -286,85 +301,41 @@ fun DownloadsScreen( } pendingBulkDelete?.let { (ids, _) -> - AlertDialog( - onDismissRequest = { pendingBulkDelete = null }, - title = { Text("Delete downloaded files?") }, - text = { - Text( - "This removes the downloaded cop${if (ids.size == 1) "y" else "ies"} from this device. " + - "Nothing is deleted from the server library.", - ) - }, - confirmButton = { - TextButton( - onClick = { - viewModel.removeDownloadIds(ids) - pendingBulkDelete = null - exitSelectMode() - }, - ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.error, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = if (ids.size == 1) "Delete Download" else "Delete ${ids.size} Downloads", - color = MaterialTheme.colorScheme.error, - ) - } - }, - dismissButton = { - TextButton(onClick = { pendingBulkDelete = null }) { Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Cancel") } + PrairieConfirmDialog( + title = "Delete downloaded files?", + body = "This removes the downloaded cop${if (ids.size == 1) "y" else "ies"} from this device. " + + "Nothing is deleted from the server library.", + confirmLabel = if (ids.size == 1) "Delete Download" else "Delete ${ids.size} Downloads", + onConfirm = { + viewModel.removeDownloadIds(ids) + pendingBulkDelete = null + exitSelectMode() }, + onDismiss = { pendingBulkDelete = null }, ) } state.reclaimPlan?.let { plan -> - AlertDialog( - onDismissRequest = viewModel::clearReclaimPlan, - title = { Text("Reclaim Watched") }, - text = { - Text( - if (plan.count == 0) { - "No watched downloads are ready to reclaim." - } else { - "Delete ${plan.count} watched download${if (plan.count == 1) "" else "s"} and free ${formatBytes(plan.totalBytes)}?" - }, - ) + PrairieDialog( + title = "Reclaim Watched", + message = if (plan.count == 0) { + "No watched downloads are ready to reclaim." + } else { + "Delete ${plan.count} watched download${if (plan.count == 1) "" else "s"} and free ${formatBytes(plan.totalBytes)}?" }, - confirmButton = { - TextButton( - enabled = plan.count > 0 && !state.isReclaiming, - onClick = viewModel::reclaimWatched, - ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Reclaim") - } - }, - dismissButton = { - TextButton(onClick = viewModel::clearReclaimPlan) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(18.dp), + onDismissRequest = viewModel::clearReclaimPlan, + actions = buildList { + if (plan.count > 0) { + add( + PrairieDialogAction( + label = "Reclaim", + onClick = viewModel::reclaimWatched, + style = PrairieDialogActionStyle.Destructive, + enabled = !state.isReclaiming, + ), ) - Spacer(modifier = Modifier.width(8.dp)) - Text(if (plan.count == 0) "Close" else "Cancel") } + add(PrairieDialogAction(if (plan.count == 0) "Close" else "Cancel", viewModel::clearReclaimPlan)) }, ) } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/downloads/DownloadsViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/downloads/DownloadsViewModel.kt index 11b0b4ab4f..b692458fd5 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/downloads/DownloadsViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/downloads/DownloadsViewModel.kt @@ -7,10 +7,13 @@ import org.prairieserver.prairie.common.downloads.DownloadEnqueuer import org.prairieserver.prairie.common.downloads.DownloadReclaimCandidate import org.prairieserver.prairie.common.downloads.DownloadReclaimPlan import org.prairieserver.prairie.common.downloads.DownloadReclaimPlanner +import org.prairieserver.prairie.common.downloads.DownloadSlotLocks import org.prairieserver.prairie.common.downloads.DownloadStorage import org.prairieserver.prairie.common.downloads.DownloadSubscriptionEvaluatorFactory +import org.prairieserver.prairie.common.downloads.tileArtwork import org.prairieserver.prairie.common.settings.PlayerSettingsStore import org.prairieserver.prairie.model.download.DownloadMediaType +import org.prairieserver.prairie.model.catalog.LeafItemUserData import org.prairieserver.prairie.model.download.DownloadRecord import org.prairieserver.prairie.model.download.DownloadSidecar import org.prairieserver.prairie.model.download.DownloadStatus @@ -20,17 +23,32 @@ import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.ServerRegistry import org.prairieserver.prairie.network.errorMessage import org.prairieserver.prairie.repository.DownloadSubscriptionRepository +import org.prairieserver.prairie.repository.CatalogRepository import org.prairieserver.prairie.repository.DownloadsRepository import org.prairieserver.prairie.repository.ProfileRepository import org.prairieserver.prairie.repository.port.UserItemStatePort +import org.prairieserver.prairie.repository.port.LocalContentState +import org.prairieserver.prairie.repository.port.LocalPlaybackProgress import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext /** @@ -47,6 +65,8 @@ data class DownloadItem( val subtitle: String? = null, val posterUrl: String? = null, val posterThumbhash: String? = null, + val posterIsEpisodeStill: Boolean? = null, + val episodeUserData: LeafItemUserData? = null, val fileSizeBytes: Long = 0, val progress: Float = 0f, val isComplete: Boolean = false, @@ -116,6 +136,28 @@ internal fun downloadItemDisplayProgress( return rawProgress.coerceIn(0f, 1f) } +internal fun resolveDownloadEpisodeUserData( + saved: LeafItemUserData?, remote: LeafItemUserData?, localState: LocalContentState?, + progress: LocalPlaybackProgress?, localOverridesRemote: Boolean, +): LeafItemUserData = if (remote != null && !localOverridesRemote) remote + else overlayDownloadEpisodeUserData(remote ?: saved, localState, progress) + +internal fun overlayDownloadEpisodeUserData( + saved: LeafItemUserData?, + localState: LocalContentState?, + progress: LocalPlaybackProgress?, +): LeafItemUserData { + val original = saved ?: LeafItemUserData() + // Watched mutations clear local resume state. Do not resurrect progress + // captured when the download started after the user marks it unwatched. + val resetProgress = localState?.watched != null && progress == null + return original.copy( + played = localState?.watched ?: original.played, + positionSeconds = progress?.positionSeconds ?: if (resetProgress) 0.0 else original.positionSeconds, + isInProgress = progress?.let { it.positionSeconds > 0 } ?: if (resetProgress) false else original.isInProgress, + ) +} + /** * Recursive hierarchy of downloaded content for the Downloads tab. * @@ -162,6 +204,8 @@ sealed class DownloadEntry { val episodes: List, override val posterUrl: String?, override val posterThumbhash: String?, + /** The download whose artwork [posterUrl] is; it may be in another season. */ + val posterSource: DownloadItem? = null, ) : DownloadEntry() { override val id = "season:$seriesKey:$seasonNumber" override val title = "Season $seasonNumber" @@ -181,6 +225,8 @@ sealed class DownloadEntry { val seasons: List, override val posterUrl: String?, override val posterThumbhash: String?, + /** The download whose artwork [posterUrl] is. */ + val posterSource: DownloadItem? = null, ) : DownloadEntry() { override val id = "series:$seriesKey" override val title = seriesTitle @@ -277,6 +323,8 @@ class DownloadsViewModel( private val subscriptionEvaluatorFactory: DownloadSubscriptionEvaluatorFactory, private val userItemStatePort: UserItemStatePort, private val playerSettingsStore: PlayerSettingsStore, + private val catalogRepository: CatalogRepository, + private val episodeSpoilerStore: org.prairieserver.prairie.common.settings.EpisodeSpoilerStore, ) : ViewModel() { private val _uiState = MutableStateFlow(DownloadsUiState(isLoading = true)) @@ -291,11 +339,29 @@ class DownloadsViewModel( * [toItem] doesn't re-walk the filesystem per record per emission. */ @Volatile private var scopeByFileId: Map> = emptyMap() + // All publication tokens are owned by the main-thread ViewModel scope. + private var sectionsGeneration = 0L + // One section build at a time, so a build never starts from a sidecar map + // that an in-flight reload is about to replace and then wins publication. + private val sectionsLock = Mutex() + private val watchRefreshLock = Mutex() + private data class EpisodeWatchSnapshot(val userData: LeafItemUserData, val requestedAtMs: Long) + private data class ServerEpisodeWatchState( + val scope: Pair, val snapshots: Map, + ) + @Volatile private var serverEpisodeWatchState: ServerEpisodeWatchState? = null + init { playerSettingsStore.keepWatchedDownloadsFlow.onEach { keepWatched -> _uiState.update { it.copy(keepWatchedDownloads = keepWatched) } }.launchIn(viewModelScope) + // Episode watch state only feeds image protection; fetch it once the + // profile turns protection on. + episodeSpoilerStore.state.map { it.prefs.hideImages }.distinctUntilChanged().drop(1).onEach { hidden -> + if (hidden) refreshLocalUserState() + }.launchIn(viewModelScope) + viewModelScope.launch { // Bootstrap: finish the one-time legacy sidecar→Room import before the // first metadata read, so pre-cutover downloads aren't briefly missing @@ -304,11 +370,21 @@ class DownloadsViewModel( // Finish any offline delete interrupted by a crash between the durable // tombstone and the on-device cleanup (idempotent byte/metadata delete). finishPendingDeletions() + // The capture saves artwork after the completed status the records + // collector reloads on, so a later artwork write needs its own + // reload. Subscribed before the initial read below, so the dropped + // first emission describes the state that read also sees. + launch { + metadataStore.savedArtworkChanges().drop(1).collect { + publishSections(repository.records.value, forceReload = true) + } + } // Backfill + initial sidecar read. reloadSidecarMetadata() refreshSubscriptionsInternal() val seeded = metadataByRecordId.values.toList() repository.seedFromSidecars(seeded.map { it.record }) + launch { refreshEpisodeWatchState() } val keep = seeded.map { it.record.id }.toSet() val (refreshServerId, refreshProfileId) = activeDownloadScope() @@ -320,44 +396,114 @@ class DownloadsViewModel( ) } - repository.records.collect { records -> - // Room is the source of truth for what's downloaded locally; the - // tab is built from the Room sidecars (which always carry - // title/poster/mediaType/series), NOT from server records joined - // to a map that can be empty (the old path rendered a bare - // contentId + MOVIES + "missing file" whenever the join missed). - // Live byte-progress/status is overlaid from the in-memory server - // records for in-flight items. Reload sidecars when a record the - // map doesn't know about appears (newly enqueued), or when a - // download finished: the worker writes the completed sidecar - // (status + final file URI) before publishing the record, and - // the local completion is what the row's Ready/Play state reads. - val (sections, bytesUsed) = withContext(Dispatchers.IO) { - val unseenCompletions = records.filter { sidecarMissesCompletion(it) }.map { it.id } - if (unseenCompletions.isNotEmpty() || records.any { it.id !in metadataByRecordId }) { - reloadSidecarMetadata() - // Reload once per disagreement: a row the server holds as - // completed while it downloads again locally must not - // re-read Room on every progress tick. - completionReloadIds = (completionReloadIds + unseenCompletions).filterTo(mutableSetOf()) { - metadataByRecordId[it]?.record?.statusEnum() != DownloadStatus.Completed - } - } - val sects = buildSections(records.associateBy { it.id }) - sects to sects.sumOf { it.totalBytesUsed } + repository.records.collect { records -> publishSections(records) } + } + } + + /** + * Rebuilds the tab from [records] and the Room sidecars and publishes it. + * [forceReload] re-reads the sidecars even when no record is new or newly + * completed (a later sidecar write, such as saved artwork). + */ + private suspend fun publishSections(records: List, forceReload: Boolean = false) = sectionsLock.withLock { + val generation = ++sectionsGeneration + // Room is the source of truth for what's downloaded locally; the + // tab is built from the Room sidecars (which always carry + // title/poster/mediaType/series), NOT from server records joined + // to a map that can be empty (the old path rendered a bare + // contentId + MOVIES + "missing file" whenever the join missed). + // Live byte-progress/status is overlaid from the in-memory server + // records for in-flight items. Reload sidecars when a record the + // map doesn't know about appears (newly enqueued), or when a + // download finished: the worker writes the completed sidecar + // (status + final file URI) before publishing the record, and + // the local completion is what the row's Ready/Play state reads. + val (sections, bytesUsed) = withContext(Dispatchers.IO) { + val unseenCompletions = records.filter { sidecarMissesCompletion(it) }.map { it.id } + if (forceReload || unseenCompletions.isNotEmpty() || records.any { it.id !in metadataByRecordId }) { + reloadSidecarMetadata() + // Reload once per disagreement: a row the server holds as + // completed while it downloads again locally must not + // re-read Room on every progress tick. + completionReloadIds = (completionReloadIds + unseenCompletions).filterTo(mutableSetOf()) { + metadataByRecordId[it]?.record?.statusEnum() != DownloadStatus.Completed } - _uiState.update { - it.copy( - isLoading = false, - // Preserve any delete/refresh failure — refresh() and - // clearError() manage the error lifecycle explicitly; - // a progress tick must not wipe it before it's shown. - error = it.error, - sections = sections, - totalBytesUsed = bytesUsed, - ) + } + val sects = buildSections(records.associateBy { it.id }) + sects to sects.sumOf { it.totalBytesUsed } + } + if (generation != sectionsGeneration) return@withLock + _uiState.update { + it.copy( + isLoading = false, + // Preserve any delete/refresh failure — refresh() and + // clearError() manage the error lifecycle explicitly; + // a progress tick must not wipe it before it's shown. + error = it.error, + sections = sections, + totalBytesUsed = bytesUsed, + ) + } + } + + fun refreshLocalUserState() { + viewModelScope.launch { + // Before the first load the sidecars aren't read yet, and + // publishing would show the empty state: wait for that load. + if (_uiState.value.isLoading) _uiState.first { !it.isLoading } + else sectionsLock.withLock { + val generation = ++sectionsGeneration + val sections = withContext(Dispatchers.IO) { + buildSections(repository.records.value.associateBy { it.id }) + } + if (generation == sectionsGeneration) { + _uiState.update { it.copy(sections = sections, isLoading = false, + totalBytesUsed = sections.sumOf { section -> section.totalBytesUsed }) } + } + } + refreshEpisodeWatchState() + } + } + + /** Episode watch state is read only for image protection. */ + private fun protectsEpisodeArtwork(): Boolean = episodeSpoilerStore.state.value.prefs.hideImages + + private suspend fun refreshEpisodeWatchState() { + // One read per downloaded episode: skip it unless it can change a tile. + if (!protectsEpisodeArtwork()) return + // Entry and ON_RESUME can arrive together; share the in-flight refresh. + if (!watchRefreshLock.tryLock()) return + try { + val scope = activeDownloadScope() + val owner = catalogRepository.captureWatchAuthority() ?: return + val ids = metadataByRecordId.values.filter { it.resolveSidecarMediaType() == DownloadMediaType.TvShow } + .map { it.record.episodeId ?: it.record.contentId }.distinct() + if (ids.isEmpty()) return + val startedAt = System.currentTimeMillis() + val limit = Semaphore(4) + val snapshots = coroutineScope { + ids.map { id -> async { + limit.withPermit { + val response = catalogRepository.getWatchDetail(id, owner) + val data = (response as? ApiResult.Success)?.data?.let { it.userData ?: LeafItemUserData() } + data?.let { id to EpisodeWatchSnapshot(it, startedAt) } + } + } }.awaitAll().filterNotNull().toMap() + } + if (activeDownloadScope() != scope || !catalogRepository.isWatchAuthorityCurrent(owner)) return + serverEpisodeWatchState = ServerEpisodeWatchState(scope, + serverEpisodeWatchState?.takeIf { it.scope == scope }?.snapshots.orEmpty() + snapshots) + sectionsLock.withLock { + val generation = ++sectionsGeneration + val sections = withContext(Dispatchers.IO) { buildSections(repository.records.value.associateBy { it.id }) } + if (activeDownloadScope() == scope && catalogRepository.isWatchAuthorityCurrent(owner) && + generation == sectionsGeneration) { + _uiState.update { it.copy(sections = sections, isLoading = false, + totalBytesUsed = sections.sumOf { section -> section.totalBytesUsed }) } } } + } finally { + watchRefreshLock.unlock() } } @@ -365,6 +511,7 @@ class DownloadsViewModel( viewModelScope.launch { reloadSidecarMetadata() refreshSubscriptionsInternal() + launch { refreshEpisodeWatchState() } val keep = metadataByRecordId.keys val (serverId, profileId) = activeDownloadScope() when (val r = repository.refresh(keepIdsAbsentFromServer = keep, serverId = serverId, profileId = profileId)) { @@ -456,10 +603,12 @@ class DownloadsViewModel( .getOrElse { emptyList() } for (p in pending) { val fileId = p.mediaFileId ?: continue - metadataStore.completePendingDeletion(p.serverId, p.profileId, fileId, p.recordId) { - withContext(Dispatchers.IO) { - storage.delete(p.serverId, p.profileId, fileId) || - !storage.exists(p.serverId, p.profileId, fileId) + DownloadSlotLocks.of(p.serverId, p.profileId, fileId).withLock { + metadataStore.completePendingDeletion(p.serverId, p.profileId, fileId, p.recordId) { + withContext(Dispatchers.IO) { + storage.delete(p.serverId, p.profileId, fileId) || + !storage.exists(p.serverId, p.profileId, fileId) + } } } } @@ -510,6 +659,7 @@ class DownloadsViewModel( private suspend fun removeRecords(ids: List) { if (ids.isEmpty()) return + ++sectionsGeneration val activeScope = activeDownloadScope() var firstError: String? = null @@ -539,10 +689,14 @@ class DownloadsViewModel( try { repository.enqueueDurableDelete(serverId, profileId, id, fileId) if (fileId != null) { - withContext(Dispatchers.IO) { - storage.delete(serverId, profileId, fileId) + // A subtitle refresh rewrites this slot's files and row + // under the same lock, so it cannot write the row back. + DownloadSlotLocks.of(serverId, profileId, fileId).withLock { + withContext(Dispatchers.IO) { + storage.delete(serverId, profileId, fileId) + } + metadataStore.deleteSidecar(serverId, profileId, fileId) } - metadataStore.deleteSidecar(serverId, profileId, fileId) } } finally { // Also covers the fileId == null branch, which never reaches @@ -569,12 +723,13 @@ class DownloadsViewModel( // repository.delete() filters an already-filtered list, so the records // StateFlow conflates it and the collector never re-emits — rebuild the tree // here or the deleted rows stay on screen until the next refresh. + val generation = ++sectionsGeneration val (sections, bytesUsed) = withContext(Dispatchers.IO) { buildSections(repository.records.value.associateBy { it.id }) to storage.totalBytesUsed(serverId, profileId) } _uiState.update { - it.copy( + if (generation != sectionsGeneration) it.copy(error = firstError ?: it.error) else it.copy( error = firstError, sections = sections, totalBytesUsed = bytesUsed, @@ -593,19 +748,22 @@ class DownloadsViewModel( val sidecars = metadataByRecordId.values.toList() val watchedStates = userItemStatePort.localContentStates(sidecars.map { it.record.contentId }) val liveById = repository.records.value.associateBy { it.id } - val rows = sidecars.map { sidecar -> - val live = liveById[sidecar.record.id] - val item = sidecar.toDownloadItem(live) - DownloadReclaimCandidate( - recordId = sidecar.record.id, - contentId = sidecar.record.contentId, - mediaFileId = sidecar.record.mediaFileId, - title = sidecar.title, - status = (live ?: sidecar.record).status, - fileSizeBytes = item.fileSizeBytes, - completed = watchedStates[sidecar.record.contentId]?.watched == true, - updatedAtMs = sidecar.updatedAtMs, - ) + // toDownloadItem stats the media file and saved artwork on disk. + val rows = withContext(Dispatchers.IO) { + sidecars.map { sidecar -> + val live = liveById[sidecar.record.id] + val item = sidecar.toDownloadItem(live) + DownloadReclaimCandidate( + recordId = sidecar.record.id, + contentId = sidecar.record.contentId, + mediaFileId = sidecar.record.mediaFileId, + title = sidecar.title, + status = (live ?: sidecar.record).status, + fileSizeBytes = item.fileSizeBytes, + completed = watchedStates[sidecar.record.contentId]?.watched == true, + updatedAtMs = sidecar.updatedAtMs, + ) + } } // Never plan the file the player is currently using (reachable via // PiP -> Downloads): deleting it yanks the bytes out from under a live @@ -707,13 +865,18 @@ class DownloadsViewModel( // No real bytes on disk: a completed-but-missing row is 0 (don't show a // stale size or inflate the header); an in-flight row uses live bytesSent. ?: if (status == DownloadStatus.Completed) 0L else rec.bytesSent + // Saved artwork is checked on disk here, like the media file above; + // callers build items on Dispatchers.IO. + val artwork = tileArtwork() return DownloadItem( id = record.id, contentId = record.contentId, title = title, subtitle = subtitle, - posterUrl = posterUrl, - posterThumbhash = posterThumbhash, + posterUrl = artwork.url, + posterThumbhash = artwork.thumbhash, + posterIsEpisodeStill = artwork.isEpisodeStill, + episodeUserData = episodeUserData, fileSizeBytes = shownBytes, progress = displayProgress, isComplete = fileState.isComplete, @@ -742,10 +905,36 @@ class DownloadsViewModel( * Top-level: build per-mediaType sections from the Room sidecars (source of * truth), in stable display order. [liveById] overlays in-flight progress. */ - private fun buildSections(liveById: Map): List { + /** Each episode download's current user data: saved, server, and local state merged. */ + private suspend fun currentEpisodeUserData(sidecars: List): Map { + val episodes = sidecars.filter { it.resolveSidecarMediaType() == DownloadMediaType.TvShow } + val ids = episodes.map { it.record.episodeId ?: it.record.contentId } + val states = userItemStatePort.localContentStates(ids) + val positions = userItemStatePort.localPlaybackProgressForContent(ids) + val serverState = serverEpisodeWatchState?.takeIf { it.scope == activeDownloadScope() }?.snapshots.orEmpty() + val localOverrides = serverState.entries.groupBy { it.value.requestedAtMs }.flatMap { (startedAt, snapshots) -> + userItemStatePort.contentIdsWithPendingOrNewerUserState(snapshots.map { it.key }, startedAt) + }.toSet() + return episodes.associate { sidecar -> + val id = sidecar.record.episodeId ?: sidecar.record.contentId + val data = resolveDownloadEpisodeUserData(sidecar.episodeUserData, serverState[id]?.userData, + states[id], positions[id], id in localOverrides) + sidecar.record.id to data + } + } + + private suspend fun buildSections(liveById: Map): List { val sidecars = metadataByRecordId.values.toList() if (sidecars.isEmpty()) return emptyList() - val byType = sidecars.groupBy { it.resolveSidecarMediaType() } + // Watch state only decides image protection: with it off, skip the + // Room reads that would otherwise run on every progress tick. + val episodeUserDataByRecordId = + if (protectsEpisodeArtwork()) currentEpisodeUserData(sidecars) else emptyMap() + val byType = sidecars.map { sidecar -> + if (sidecar.record.id in episodeUserDataByRecordId) { + sidecar.copy(episodeUserData = episodeUserDataByRecordId[sidecar.record.id]) + } else sidecar + }.groupBy { it.resolveSidecarMediaType() } val sectionOrder = listOf( DownloadMediaType.Movie, DownloadMediaType.TvShow, @@ -787,8 +976,14 @@ class DownloadsViewModel( } val seriesEntries = bySeries.map { (seriesKey, group) -> val seriesTitle = group.firstNotNullOfOrNull { it.seriesTitle?.takeIf { t -> t.isNotBlank() } } ?: "Series" - val poster = group.firstNotNullOfOrNull { it.posterUrl } - val thumb = group.firstNotNullOfOrNull { it.posterThumbhash } + val artwork = group.tileArtwork() + val poster = artwork.url + val thumb = artwork.thumbhash + // The download the rows' artwork belongs to; a saved series + // poster is never a still, whichever download saved it. + val posterSource = group.firstOrNull { it.posterUrl != null }?.let { + it.toDownloadItem(liveById[it.record.id]).copy(posterIsEpisodeStill = artwork.isEpisodeStill) + } val bySeason = LinkedHashMap>() for (sc in group) bySeason.getOrPut(sc.seasonNumber ?: -1) { mutableListOf() } += sc val seasons = bySeason.toSortedMap().map { (season, seasonScs) -> @@ -802,6 +997,7 @@ class DownloadsViewModel( episodes = episodes, posterUrl = poster, posterThumbhash = thumb, + posterSource = posterSource, ) } DownloadEntry.Series( @@ -810,6 +1006,7 @@ class DownloadsViewModel( seasons = seasons, posterUrl = poster, posterThumbhash = thumb, + posterSource = posterSource, ) } return seriesEntries + orphans.map { DownloadEntry.Single(it.toDownloadItem(liveById[it.record.id])) } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/HomeScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/HomeScreen.kt index 62571fef0b..161f3f4e1a 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/HomeScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/HomeScreen.kt @@ -1,5 +1,8 @@ package org.prairieserver.prairie.android.ui.screens.home +import org.prairieserver.prairie.domain.settings.CatalogLanguageRevision +import org.prairieserver.prairie.android.ui.components.PrairieDropdownMenuItem +import org.prairieserver.prairie.android.ui.components.PrairieDropdownMenu import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -25,8 +28,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Person import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.SettingsRemote -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -69,8 +70,6 @@ import org.prairieserver.prairie.android.ui.components.ErrorView import org.prairieserver.prairie.android.ui.components.MediaRowSkeleton import org.prairieserver.prairie.android.ui.components.ProfileMenu import org.prairieserver.prairie.android.ui.components.rememberShimmerProgress -import org.prairieserver.prairie.android.ui.screens.pairing.CompanionPairingViewModel -import org.prairieserver.prairie.android.ui.screens.pairing.CompanionPairingBottomOverlay import org.prairieserver.prairie.android.ui.screens.profiles.ProfileAvatar import org.prairieserver.prairie.common.diagnostics.DiagnosticsHomeContentState import org.prairieserver.prairie.common.diagnostics.DiagnosticsHomeLogger @@ -78,8 +77,6 @@ import org.prairieserver.prairie.common.diagnostics.DiagnosticsHomeScrollRegion import org.prairieserver.prairie.common.diagnostics.DiagnosticsListLogger import org.prairieserver.prairie.common.diagnostics.DiagnosticsListSnapshot import org.prairieserver.prairie.common.diagnostics.DiagnosticsListSurface -import org.prairieserver.prairie.common.pairing.CompanionPairingStatus -import org.prairieserver.prairie.common.pairing.CompanionPairingTarget import org.prairieserver.prairie.common.ui.components.DeferImagePresentationWhileScrolling import org.prairieserver.prairie.common.ui.components.avatarRef import org.prairieserver.prairie.model.catalog.isAudiobookItemType @@ -131,7 +128,7 @@ fun HomeScreen( onRemoteDisconnectClick: () -> Unit, isRemoteControlActive: Boolean, onRequestsClick: (() -> Unit)?, - onWatchTogetherClick: (() -> Unit)?, + onWatchPartyClick: (() -> Unit)?, onSettingsClick: () -> Unit, onSwitchProfileClick: () -> Unit, onSwitchServerClick: () -> Unit, @@ -144,13 +141,6 @@ fun HomeScreen( val serverRegistry: ServerRegistry = koinInject() val activeServerId by serverRegistry.activeServerId.collectAsState() val activeEntry by serverRegistry.activeEntry.collectAsState() - val companionPairingViewModel = koinViewModel() - val companionTargets by companionPairingViewModel.targets.collectAsState() - val companionStatus by companionPairingViewModel.status.collectAsState() - val companionApproval by companionPairingViewModel.pendingApproval.collectAsState() - val companionServerChoices by companionPairingViewModel.serverChoices.collectAsState() - var presentedPairingTarget by remember { mutableStateOf(null) } - var dismissedPairingSessions by rememberSaveable { mutableStateOf(emptyList()) } val sections = state.sections val profileScopeId = activeEntry?.profileId ?: activeProfile?.id val populatedServerSections = remember(sections) { sections.filter { it.items.isNotEmpty() } } @@ -166,6 +156,8 @@ fun HomeScreen( } val context = LocalContext.current val catalogRepository: CatalogRepository = koinInject() + val catalogLanguage: CatalogLanguageRevision = koinInject() + val languageRevision by catalogLanguage.revision.collectAsState() val heroHandoff = LocalHeroSourceHandoff.current val continueWatchingWarmTargets = remember(regularSections) { regularSections @@ -193,10 +185,10 @@ fun HomeScreen( .take(4) .toList() } - var warmedSeriesArtwork by remember(activeServerId, profileScopeId) { + var warmedSeriesArtwork by remember(activeServerId, profileScopeId, languageRevision) { mutableStateOf>(emptyMap()) } - LaunchedEffect(activeServerId, profileScopeId, continueWatchingWarmTargets) { + LaunchedEffect(activeServerId, profileScopeId, languageRevision, continueWatchingWarmTargets) { // Warm each Continue Watching destination in row order. Within one // title, its parent detail, exact episode and selector data run in // parallel. CatalogRepository keeps these calls alive/single-flight if @@ -319,23 +311,6 @@ fun HomeScreen( ) } } - LaunchedEffect(companionTargets, companionStatus, dismissedPairingSessions) { - if (companionStatus is CompanionPairingStatus.Idle) { - val presented = presentedPairingTarget - if (presented == null) { - presentedPairingTarget = companionTargets.firstOrNull { - it.dismissalKey !in dismissedPairingSessions - } - } else { - // Android NSD can resolve the same TV again with a new listener port after - // its setup screen restarts. Keep the visible card, but always pair with the - // freshest endpoint instead of the target object originally latched by UI. - presentedPairingTarget = companionTargets - .firstOrNull { it.deviceId == presented.deviceId } - ?: companionTargets.firstOrNull { it.dismissalKey !in dismissedPairingSessions } - } - } - } // Home can show the same item in several rows at once. Each poster placement // now carries a unique hero key (see MediaCard) so duplicates never collide // in the shared-transition layout — no per-screen claim registry needed. @@ -490,36 +465,16 @@ fun HomeScreen( onRemoteDisconnectClick = onRemoteDisconnectClick, isRemoteControlActive = isRemoteControlActive, onRequestsClick = onRequestsClick, - onWatchTogetherClick = onWatchTogetherClick, + onWatchPartyClick = onWatchPartyClick, onSettingsClick = onSettingsClick, onSwitchProfileClick = onSwitchProfileClick, onSwitchServerClick = onSwitchServerClick, onSignOutClick = onSignOutClick, ) - CompanionPairingBottomOverlay( - target = presentedPairingTarget, - status = companionStatus, - approval = companionApproval, - serverChoices = companionServerChoices, - onPair = companionPairingViewModel::pair, - onServersSelected = companionPairingViewModel::continueWithServers, - onApprove = companionPairingViewModel::approveMatchCode, - onDecline = companionPairingViewModel::cancelMatchCode, - onDismiss = { - presentedPairingTarget?.let { target -> - dismissedPairingSessions = dismissedPairingSessions + target.dismissalKey - } - companionPairingViewModel.dismissPairing() - presentedPairingTarget = null - }, - ) } } -private val CompanionPairingTarget.dismissalKey: String - get() = "$deviceId:${sessionId ?: serviceName}" - @Composable private fun HomeLoadingSkeleton() { val shimmer = rememberShimmerProgress() @@ -547,7 +502,7 @@ private fun HomeFloatingChrome( onRemoteDisconnectClick: () -> Unit, isRemoteControlActive: Boolean, onRequestsClick: (() -> Unit)?, - onWatchTogetherClick: (() -> Unit)?, + onWatchPartyClick: (() -> Unit)?, onSettingsClick: () -> Unit, onSwitchProfileClick: () -> Unit, onSwitchServerClick: () -> Unit, @@ -567,7 +522,7 @@ private fun HomeFloatingChrome( activeProfile = activeProfile, onSearchClick = onSearchClick, onRequestsClick = onRequestsClick, - onWatchTogetherClick = onWatchTogetherClick, + onWatchPartyClick = onWatchPartyClick, onSettingsClick = onSettingsClick, onSwitchProfileClick = onSwitchProfileClick, onSwitchServerClick = onSwitchServerClick, @@ -593,18 +548,18 @@ private fun HomeFloatingChrome( contentDescription = "Remote Control", ) } - DropdownMenu( + PrairieDropdownMenu( expanded = remoteMenuExpanded, onDismissRequest = { remoteMenuExpanded = false }, ) { - DropdownMenuItem( + PrairieDropdownMenuItem( text = { Text("Remote Control") }, onClick = { remoteMenuExpanded = false onRemoteControlClick() }, ) - DropdownMenuItem( + PrairieDropdownMenuItem( text = { Text("Choose TV") }, onClick = { remoteMenuExpanded = false @@ -612,7 +567,7 @@ private fun HomeFloatingChrome( }, ) HorizontalDivider() - DropdownMenuItem( + PrairieDropdownMenuItem( text = { Text( "Turn Off Control Mode", diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesScreen.kt index 96d386c2da..512c3b3a7c 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesScreen.kt @@ -38,17 +38,21 @@ import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Headphones import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.LocalMovies +import androidx.compose.material.icons.filled.Shuffle import androidx.compose.material.icons.filled.Tv import androidx.compose.material.icons.filled.VideoLibrary import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf @@ -68,7 +72,16 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.viewModelScope import org.prairieserver.prairie.android.ui.components.TopBarRowTopInset import org.prairieserver.prairie.android.ui.theme.prairiePageBackdrop @@ -91,13 +104,14 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.runtime.mutableIntStateOf import org.prairieserver.prairie.android.ui.components.rememberShimmerProgress import androidx.compose.material.icons.filled.Cancel -import org.prairieserver.prairie.android.ui.screens.browse.BrowsePrefsStore +import org.prairieserver.prairie.common.settings.BrowsePrefsStore import org.prairieserver.prairie.android.ui.screens.browse.CatalogGrid import org.prairieserver.prairie.android.ui.screens.browse.CatalogViewDensity import org.prairieserver.prairie.android.ui.screens.browse.FilterSheet import org.prairieserver.prairie.android.ui.screens.browse.facetValueLabel import org.prairieserver.prairie.android.ui.screens.browse.normalizeCatalogNamePrefix import org.prairieserver.prairie.catalog.filter.BrowseFacetMediaType +import org.prairieserver.prairie.catalog.scopeLibrarySection import org.prairieserver.prairie.catalog.filter.CatalogFacet import org.prairieserver.prairie.catalog.filter.CatalogFilterQueryBuilder import org.prairieserver.prairie.catalog.filter.CatalogFilterState @@ -123,12 +137,15 @@ import org.prairieserver.prairie.network.apiv2.CatalogContinuationV2 import org.prairieserver.prairie.repository.CatalogRepository import org.prairieserver.prairie.repository.PersonalDataRepository import org.prairieserver.prairie.repository.SectionRepository +import org.prairieserver.prairie.repository.port.canServeCache import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.isActive @@ -166,6 +183,7 @@ data class LibrariesUiState( val isLoadingLibraries: Boolean = true, val libraries: List = emptyList(), val selectedLibraryId: Int? = null, + val mediaScope: String? = null, val selectedTab: LibrariesSubtab = LibrariesSubtab.Recommended, val isLoadingSections: Boolean = false, val sections: List = emptyList(), @@ -175,6 +193,8 @@ data class LibrariesUiState( val catalogItems: List = emptyList(), val catalogTotal: Int = 0, val catalogHasMore: Boolean = false, + /** For an empty grid: false when the library itself is empty, true when filters hid everything, null when unknown (#451). */ + val catalogLibraryHasItems: Boolean? = null, // Full filter model shared with the standalone Browse screen: facets // (genre/decade/rating/studio/language/series/...), match-all/any, and the // available-filter vocabulary from the server. Genre is a Categories facet @@ -201,7 +221,11 @@ class LibrariesViewModel( org.prairieserver.prairie.repository.port.NoOpUserItemStatePort, private val playerSettingsStore: org.prairieserver.prairie.common.settings.PlayerSettingsStore? = null, private val browsePrefs: BrowsePrefsStore? = null, + catalogLanguage: org.prairieserver.prairie.domain.settings.CatalogLanguageRevision? = null, ) : ViewModel() { + /** Access changes this ViewModel has applied, kept while its screen is away. */ + val accessChanges = org.prairieserver.prairie.network.AccessChangeCursor() + private val _uiState = MutableStateFlow(LibrariesUiState()) val uiState: StateFlow = _uiState.asStateFlow() // iOS AppNavPreferences.showAudiobooks parity: audiobook libraries are @@ -210,13 +234,29 @@ class LibrariesViewModel( private var recommendedLoadedLibraryId: Int? = null private var browseLoadedLibraryId: Int? = null private var collectionsLoadedLibraryId: Int? = null + private var recommendedJob: Job? = null private var recommendedRequestGeneration = 0L private var catalogContinuation: CatalogContinuationV2? = null private var catalogRequestGeneration = 0L private var catalogQueryGeneration = 0L private var collectionsRequestGeneration = 0L + // Library-list loads run one at a time, in order, so an older response + // can't overwrite a newer one (on screen or in the offline cache). A + // request made mid-load queues one follow-up pass. + private var librariesJob: Job? = null + private var librariesRecheckQueued = false + // The last library list the server confirmed, hidden audiobook + // libraries included. Later loads confirm a shrink against it, so a + // partial list can't hide libraries or reach the offline cache, and a + // transient failure re-shows it instead of the cache. + private var serverLibraries: List = emptyList() + private var pendingContentReload = false private val pageSize = 42 + /** Bumps when the profile hides or shows a library on another device. */ + val hiddenLibrariesRevision: StateFlow = personalDataRepository.hiddenLibrariesRevision + private var seenHiddenLibrariesRevision = hiddenLibrariesRevision.value + init { playerSettingsStore?.showAudiobooksFlow ?.onEach { show -> @@ -226,124 +266,249 @@ class LibrariesViewModel( } } ?.launchIn(viewModelScope) + // Titles and descriptions are localized per metadata language. + catalogLanguage?.revision + ?.drop(1) + ?.onEach { refreshAfterLanguageChange() } + ?.launchIn(viewModelScope) refresh() } + fun onHiddenLibrariesRevision(revision: Int) { + if (revision == seenHiddenLibrariesRevision) return + seenHiddenLibrariesRevision = revision + refreshLibraryList() + } + private fun isHiddenAudiobookLibrary(library: UserLibrary): Boolean = !showAudiobooks && library.type.trim().lowercase() in setOf("audiobook", "audiobooks") - fun refresh() { - viewModelScope.launch { - _uiState.update { - it.copy( - isLoadingLibraries = true, - librariesError = null, - ) - } + fun refresh() = loadLibraries(reloadContent = true) + + /** + * Re-fetch the library list, reloading the open library's content only when + * the selection has to change. The screen calls this on every resume: this + * VM outlives tab switches, so a list that loaded short (a partial server + * response, or the cached fallback after a failed request) used to stay + * short — hiding the switcher and every other library — until the process + * died. + */ + fun refreshLibraryList() = loadLibraries(reloadContent = false) + + private fun loadLibraries(reloadContent: Boolean) { + if (reloadContent) pendingContentReload = true + if (librariesJob?.isActive == true) { + librariesRecheckQueued = true + return + } + librariesJob = viewModelScope.launch { + do { + librariesRecheckQueued = false + // Each pass takes the reload owed so far, so a background + // re-check never inherits a full refresh it didn't run. + val reload = pendingContentReload + pendingContentReload = false + loadLibrariesPass(reload) + } while (librariesRecheckQueued) + } + } - when (val result = personalDataRepository.listUserLibraries()) { - is ApiResult.Success -> { - // Libraries is the unified hub for every library type - // (video / audio / reading). The selector lists them all - // and ItemDetail routes each item to the right player or - // reader by its type. - val libraries = result.data - .filterNot(::isHiddenAudiobookLibrary) - .sortedBy { library -> library.sortOrder } - val previousLibraryId = _uiState.value.selectedLibraryId - val selectedLibraryId = previousLibraryId - ?.takeIf { currentId -> libraries.any { it.id == currentId } } - ?: libraries.firstOrNull()?.id - // When this resolves to a *new* library (first load, or the - // prior one vanished), restore its saved browse filter + - // preserve state — same as selectLibrary — so opening Browse - // on the default library isn't an unfiltered grid for a - // profile with saved filters. An unchanged selection keeps - // whatever filters are already active. - val restoreBrowsePrefs = - selectedLibraryId != null && selectedLibraryId != previousLibraryId - // Null when there is nothing to restore, so the branches - // below keep the live state untouched. - val restoredFilterState = if (restoreBrowsePrefs) { - browsePrefs?.savedState(selectedLibraryId) ?: CatalogFilterState() - } else { - null - } + private suspend fun loadLibrariesPass(reload: Boolean) { + _uiState.update { + it.copy( + isLoadingLibraries = true, + librariesError = null, + ) + } - _uiState.update { - it.copy( - isLoadingLibraries = false, - libraries = libraries, - selectedLibraryId = selectedLibraryId, - librariesError = null, - filterState = restoredFilterState ?: it.filterState, - // The sort lives inside the persisted filter state, - // so derive the chip from what was restored. - browseSort = restoredFilterState - ?.let(LibraryBrowseSort::fromFilterState) - ?: it.browseSort, - preserveFilters = if (restoreBrowsePrefs) - (browsePrefs?.preserveEnabled(selectedLibraryId) ?: true) - else it.preserveFilters, - ) - } + // Once a list is known, every load confirms a shrink before trusting + // it (a short list is the failure this screen recovers from); a real + // removal (revoked access, deleted library) repeats and applies. Only + // the first load may fall back to the offline cache. + val known = serverLibraries + val fetched = if (reload && known.isEmpty()) { + personalDataRepository.listUserLibraries() + } else { + personalDataRepository.recheckUserLibraries(known.mapTo(mutableSetOf()) { it.id }) + } + // A transient failure re-shows the known list, re-filtered so a Show + // Audiobooks change or a newly hidden library still applies. Auth + // failures fall through and clear it. + val result = if (fetched.canServeCache() && known.isNotEmpty()) { + ApiResult.Success(personalDataRepository.withoutHidden(known)) + } else { + fetched + } + when (result) { + is ApiResult.Success -> { + serverLibraries = result.data + // Libraries is the unified hub for every library type + // (video / audio / reading). The selector lists them all + // and ItemDetail routes each item to the right player or + // reader by its type. + val libraries = result.data + .filterNot(::isHiddenAudiobookLibrary) + .sortedBy { library -> library.sortOrder } + // Recovering from an emptied list (an earlier failure + // cleared it, sections included) owes a content reload too. + val recovering = _uiState.value.libraries.isEmpty() + val previousLibraryId = _uiState.value.selectedLibraryId + val selectedLibraryId = previousLibraryId + ?.takeIf { currentId -> libraries.any { it.id == currentId } } + ?: libraries.firstOrNull()?.id + // A *new* selection (first load, or the prior library + // vanished) gets the same reset as selectLibrary. An + // unchanged selection keeps its rows and active filters. + // A mixed library keeps its Movies/Series scope across a list + // refresh; a library that stops being mixed drops it. + val previousScope = _uiState.value.mediaScope + val mediaScope = libraries.firstOrNull { it.id == selectedLibraryId } + ?.let(::defaultMediaScope) + ?.let { default -> if (selectedLibraryId == previousLibraryId) previousScope ?: default else default } + val selectionChanged = selectedLibraryId != previousLibraryId || mediaScope != previousScope + if (selectionChanged) resetForLibrary(selectedLibraryId, mediaScope) + _uiState.update { + it.copy( + isLoadingLibraries = false, + libraries = libraries, + librariesError = null, + ) + } - if (selectedLibraryId != null) { - loadCurrentTab(selectedLibraryId, force = true) - } + if (selectedLibraryId != null && (reload || selectionChanged || recovering)) { + loadCurrentTab(selectedLibraryId, force = true) } - is ApiResult.Error -> { - _uiState.update { - it.copy( - isLoadingLibraries = false, - libraries = emptyList(), - sections = emptyList(), - librariesError = result.message.ifBlank { "Failed to load libraries" }, - ) - } + } + is ApiResult.Error -> { + // Revoked access drops the baseline; a transient failure keeps + // it, since hidden libraries can outlive an empty screen. + if (!result.canServeCache()) serverLibraries = emptyList() + _uiState.update { + it.copy( + isLoadingLibraries = false, + libraries = emptyList(), + sections = emptyList(), + librariesError = result.message.ifBlank { "Failed to load libraries" }, + ) } - is ApiResult.NetworkError -> { - _uiState.update { - it.copy( - isLoadingLibraries = false, - libraries = emptyList(), - sections = emptyList(), - librariesError = "Network error: ${result.exception.message ?: "unknown"}", - ) - } + } + is ApiResult.NetworkError -> { + _uiState.update { + it.copy( + isLoadingLibraries = false, + libraries = emptyList(), + sections = emptyList(), + librariesError = "Network error: ${result.exception.message ?: "unknown"}", + ) } } } } + private fun refreshAfterLanguageChange() { + recommendedJob?.cancel() + recommendedLoadedLibraryId = null + browseLoadedLibraryId = null + collectionsLoadedLibraryId = null + ++recommendedRequestGeneration + ++catalogRequestGeneration + ++catalogQueryGeneration + ++collectionsRequestGeneration + catalogContinuation = null + _uiState.update { + it.copy( + sections = emptyList(), + catalogItems = emptyList(), + collections = emptyList(), + availableFilters = null, + isLoadingSections = false, + isLoadingCatalog = false, + isLoadingMoreCatalog = false, + isLoadingCollections = false, + ) + } + refresh() + } + + /** + * The server reported an access change: the library list, sections, + * titles, facets, and collections may all differ under the new policy. + * Forget which subtabs are loaded and the cached facets, then reload the + * library list and the visible subtab; the others reload when opened. + */ + fun refreshAfterAccessChange() { + recommendedLoadedLibraryId = null + browseLoadedLibraryId = null + collectionsLoadedLibraryId = null + // A facet load still in flight was answered under the old policy; + // without a new query generation it would refill the facets cleared + // here, and Browse would not fetch them again. + ++catalogQueryGeneration + _uiState.update { it.copy(availableFilters = null) } + refresh() + } + fun selectLibrary(libraryId: Int) { if (_uiState.value.selectedLibraryId == libraryId) return + val library = _uiState.value.libraries.firstOrNull { it.id == libraryId } + resetForLibrary(libraryId, library?.let(::defaultMediaScope)) + loadCurrentTab(libraryId, force = true) + } + + fun selectMediaScope(mediaScope: String) { + val state = _uiState.value + if (mediaScope !in setOf("movie", "series") || state.mediaScope == null || state.mediaScope == mediaScope) return + val libraryId = state.selectedLibraryId ?: return + resetForLibrary(libraryId, mediaScope) + loadCurrentTab(libraryId, force = true) + } + + /** A mixed library opens on Movies; other libraries have no media scope. */ + private fun defaultMediaScope(library: UserLibrary): String? = + if (library.type.trim().lowercase() == "mixed") "movie" else null + + /** + * Point the state at [libraryId] and [mediaScope], dropping the previous + * selection's rows, filter vocabulary and letter, and invalidating its + * in-flight requests. Restores this library and scope's persisted + * filter/sort state (iOS parity) so a preserved selection doesn't flash the + * unfiltered grid; defaults to a clean filter — and therefore + * RecentlyAdded — when nothing is saved. + */ + private fun resetForLibrary(libraryId: Int?, mediaScope: String?) { + recommendedJob?.cancel() recommendedLoadedLibraryId = null browseLoadedLibraryId = null collectionsLoadedLibraryId = null - // Restore this library's persisted filter/sort state (iOS parity) so a - // preserved selection doesn't flash the unfiltered grid; default to a - // clean filter — and therefore RecentlyAdded — when nothing is saved. - val restoredFilterState = browsePrefs?.savedState(libraryId) ?: CatalogFilterState() + ++recommendedRequestGeneration + ++catalogRequestGeneration + ++catalogQueryGeneration + ++collectionsRequestGeneration + catalogContinuation = null + val restoredFilterState = libraryId?.let { browsePrefs?.savedState(it, mediaScope) } ?: CatalogFilterState() _uiState.update { it.copy( selectedLibraryId = libraryId, + mediaScope = mediaScope, sections = emptyList(), sectionsError = null, + isLoadingSections = false, catalogItems = emptyList(), catalogTotal = 0, catalogHasMore = false, + isLoadingCatalog = false, + isLoadingMoreCatalog = false, filterState = restoredFilterState, browseSort = LibraryBrowseSort.fromFilterState(restoredFilterState), availableFilters = null, - preserveFilters = browsePrefs?.preserveEnabled(libraryId) ?: true, + preserveFilters = libraryId?.let { id -> browsePrefs?.preserveEnabled(id, mediaScope) } ?: true, selectedNamePrefix = null, catalogError = null, collections = emptyList(), collectionsError = null, + isLoadingCollections = false, ) } - loadCurrentTab(libraryId, force = true) } fun selectTab(tab: LibrariesSubtab) { @@ -370,15 +535,15 @@ class LibrariesViewModel( catalogHasMore = false, ) } - browsePrefs?.saveState(_uiState.value.selectedLibraryId, reconciled) + browsePrefs?.saveState(_uiState.value.selectedLibraryId, reconciled, _uiState.value.mediaScope) _uiState.value.selectedLibraryId?.let { loadCatalog(it, reset = true, force = true) } } fun setPreserveFilters(enabled: Boolean) { val libraryId = _uiState.value.selectedLibraryId - browsePrefs?.setPreserveEnabled(libraryId, enabled) + browsePrefs?.setPreserveEnabled(libraryId, enabled, _uiState.value.mediaScope) _uiState.update { it.copy(preserveFilters = enabled) } - if (enabled) browsePrefs?.saveState(libraryId, _uiState.value.filterState) + if (enabled) browsePrefs?.saveState(libraryId, _uiState.value.filterState, _uiState.value.mediaScope) } fun selectBrowseSort(sort: LibraryBrowseSort) { @@ -398,7 +563,7 @@ class LibrariesViewModel( catalogHasMore = false, ) } - browsePrefs?.saveState(_uiState.value.selectedLibraryId, nextFilterState) + browsePrefs?.saveState(_uiState.value.selectedLibraryId, nextFilterState, _uiState.value.mediaScope) _uiState.value.selectedLibraryId?.let { loadCatalog(it, reset = true, force = true) } } @@ -448,8 +613,10 @@ class LibrariesViewModel( private fun loadRecommended(libraryId: Int, force: Boolean) { if (!force && recommendedLoadedLibraryId == libraryId) return recommendedLoadedLibraryId = libraryId + val mediaScope = _uiState.value.mediaScope val requestGeneration = ++recommendedRequestGeneration - viewModelScope.launch { + recommendedJob?.cancel() + recommendedJob = viewModelScope.launch { if (!isRecommendedRequestCurrent(requestGeneration, libraryId)) return@launch _uiState.update { if (isRecommendedRequestCurrent(requestGeneration, libraryId, it)) { @@ -480,13 +647,30 @@ class LibrariesViewModel( } when (result) { is ApiResult.Success -> { + val permits = Semaphore(3) + val scoped = result.data.sections.map { section -> + async { + permits.withPermit { + scopeLibrarySection(section, mediaScope) { cursor -> + currentCoroutineContext().ensureActive() + sectionRepository.getLibrarySectionCatalogItems(libraryId, section.id, owner, cursor) + } + } + } + }.awaitAll() if (!isRecommendedRequestCurrent(requestGeneration, libraryId)) return@launch + if (!sectionRepository.isLibrarySectionAuthorityCurrent(owner)) { + recommendedLoadedLibraryId = null + _uiState.update { it.copy(isLoadingSections = false, sections = emptyList()) } + return@launch + } _uiState.update { if (isRecommendedRequestCurrent(requestGeneration, libraryId, it)) { it.copy( isLoadingSections = false, - sections = result.data.sections.filter { section -> section.items.isNotEmpty() }, - sectionsError = null, + sections = scoped.map { it.section }.filter { it.items.isNotEmpty() }, + sectionsError = if (scoped.any { it.incomplete }) + "Some shelves could not be fully loaded. Retry or open Library to browse all titles." else null, ) } else { it @@ -533,6 +717,7 @@ class LibrariesViewModel( val requestState = _uiState.value val requestIdentity = CatalogRequestIdentity( libraryId = libraryId, + mediaScope = requestState.mediaScope, browseSort = requestState.browseSort, selectedNamePrefix = requestState.selectedNamePrefix, filterState = requestState.filterState, @@ -571,7 +756,7 @@ class LibrariesViewModel( if (!isCatalogRequestCurrent(requestGeneration, requestIdentity, it)) { it } else if (reset) { - it.copy(isLoadingCatalog = true, isLoadingMoreCatalog = false, catalogError = null) + it.copy(isLoadingCatalog = true, isLoadingMoreCatalog = false, catalogError = null, catalogLibraryHasItems = null) } else { it.copy(isLoadingMoreCatalog = true, catalogError = null) } @@ -580,6 +765,7 @@ class LibrariesViewModel( when ( val result = catalogRepository.browse( libraryId = libraryId, + mediaType = requestState.mediaScope, sort = requestState.browseSort.sortField, order = requestState.browseSort.sortOrder, continuation = if (reset) null else catalogContinuation, @@ -596,6 +782,17 @@ class LibrariesViewModel( // Overlay local optimistic watched/favorite (mirrors Home/Browse). val overlaid = overlayLocalState(result.data.items) if (!isCatalogRequestCurrent(requestGeneration, requestIdentity)) return@launch + // An empty first page only says this view matched nothing (#451). + // With nothing narrowing it, the page was the whole library; + // otherwise one unfiltered item decides. A Movies/Series view + // of a mixed library asks within that type, as Apple does. + // Loading stays up meanwhile, so the wrong message never flashes. + val libraryHasItems = when { + !reset || overlaid.isNotEmpty() -> null + !requestState.filterState.hasActiveFilters && requestState.selectedNamePrefix == null -> false + else -> catalogRepository.libraryHasItems(libraryId, requestState.mediaScope) + } + if (!isCatalogRequestCurrent(requestGeneration, requestIdentity)) return@launch catalogContinuation = result.data.continuation // Audiobook libraries expose book-native facets // (author/narrator/series) — detected from the first item. @@ -614,6 +811,7 @@ class LibrariesViewModel( catalogItems = if (reset) overlaid else it.catalogItems + overlaid, catalogTotal = result.data.total, catalogHasMore = result.data.hasMore, + catalogLibraryHasItems = if (reset) libraryHasItems else it.catalogLibraryHasItems, browseMediaType = detectedMediaType ?: it.browseMediaType, catalogError = null, ) @@ -763,6 +961,7 @@ class LibrariesViewModel( private fun CatalogRequestIdentity.matches(state: LibrariesUiState): Boolean = state.selectedLibraryId == libraryId && + state.mediaScope == mediaScope && state.browseSort == browseSort && state.selectedNamePrefix == selectedNamePrefix && state.filterState == filterState @@ -776,6 +975,7 @@ class LibrariesViewModel( private data class CatalogRequestIdentity( val libraryId: Int, + val mediaScope: String?, val browseSort: LibraryBrowseSort, val selectedNamePrefix: String?, val filterState: CatalogFilterState, @@ -789,25 +989,60 @@ private const val ChromeFadeDistanceDp = 80f @Composable fun LibrariesScreen( onItemClick: (String, Int?) -> Unit, - onCollectionClick: (LibraryCollection, Int) -> Unit, + onCollectionClick: (LibraryCollection, Int, String?) -> Unit, viewModel: LibrariesViewModel, activeProfile: Profile?, onLibrarySelectorClick: () -> Unit, onSearchClick: () -> Unit, onRequestsClick: (() -> Unit)?, - onWatchTogetherClick: (() -> Unit)?, + onWatchPartyClick: (() -> Unit)?, onSettingsClick: () -> Unit, onSwitchProfileClick: () -> Unit, onSwitchServerClick: () -> Unit, onSignOutClick: () -> Unit, + shuffleLauncher: org.prairieserver.prairie.common.ui.ShuffleLauncher? = null, modifier: Modifier = Modifier, ) { val state by viewModel.uiState.collectAsState() val selectedLibrary = state.libraries.firstOrNull { it.id == state.selectedLibraryId } + // Movie, TV, and mixed libraries shuffle from the Browse tab when the + // server offers it. + // A Movies- or Series-scoped mixed library offers no shuffle: a shuffle + // request carries no media type, so it would draw from both. + val onShuffle = selectedLibrary + ?.takeIf { state.selectedTab == LibrariesSubtab.Browse } + ?.takeIf { state.mediaScope == null } + ?.takeIf { org.prairieserver.prairie.model.shuffle.isShuffleLibraryType(it.type) } + ?.takeIf { shuffleLauncher?.supports(org.prairieserver.prairie.model.shuffle.ShuffleScopeKind.LIBRARY) == true } + ?.let { library -> + { + shuffleLauncher?.start(org.prairieserver.prairie.model.shuffle.ShuffleScopeKind.LIBRARY, library.id.toString()) + Unit + } + } + + // Re-check the library list on every resume: entering the tab (the observer + // replays ON_RESUME when added) and returning to the app. The VM loads once + // and outlives tab switches, so a short list otherwise stuck until restart. + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner, viewModel) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) viewModel.refreshLibraryList() + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + // A library hidden or shown on another device lands on foreground, which + // can be after this resume's re-check; re-check again when it does. + val hiddenLibrariesRevision by viewModel.hiddenLibrariesRevision.collectAsState() + LaunchedEffect(viewModel, hiddenLibrariesRevision) { + viewModel.onHiddenLibrariesRevision(hiddenLibrariesRevision) + } // Recommended tab scroll state — drives the chrome scrim opacity so the // header fades in its scrim once the user scrolls the rows underneath it. val recommendedListState = rememberLazyListState() + LaunchedEffect(state.selectedLibraryId, state.mediaScope) { recommendedListState.scrollToItem(0) } val density = LocalDensity.current val chromeFadePx = remember(density) { with(density) { ChromeFadeDistanceDp.dp.toPx() } @@ -905,7 +1140,7 @@ fun LibrariesScreen( topInset = topInset, onCollectionClick = { collection -> state.selectedLibraryId?.let { libraryId -> - onCollectionClick(collection, libraryId) + onCollectionClick(collection, libraryId, state.mediaScope) } }, onRetry = viewModel::retryCurrentTab, @@ -922,15 +1157,19 @@ fun LibrariesScreen( canSwitch = state.libraries.size > 1, activeProfile = activeProfile, selectedTab = state.selectedTab, + mediaScope = state.mediaScope, + onMediaScopeSelected = viewModel::selectMediaScope, onLibrarySelectorClick = onLibrarySelectorClick, onTabSelected = viewModel::selectTab, onSearchClick = onSearchClick, onRequestsClick = onRequestsClick, - onWatchTogetherClick = onWatchTogetherClick, + onWatchPartyClick = onWatchPartyClick, onSettingsClick = onSettingsClick, onSwitchProfileClick = onSwitchProfileClick, onSwitchServerClick = onSwitchServerClick, onSignOutClick = onSignOutClick, + onShuffleClick = onShuffle, + shuffleEnabled = shuffleLauncher?.isStarting != true, modifier = Modifier.onSizeChanged { chromeHeightPx = it.height }, ) } @@ -1004,6 +1243,14 @@ private fun RecommendedTabContent( verticalArrangement = Arrangement.spacedBy(24.dp), ) { + if (state.sectionsError != null) { + item(key = "scope-load-error") { + Column(Modifier.padding(horizontal = 16.dp)) { + Text(state.sectionsError, color = MaterialTheme.colorScheme.onSurfaceVariant) + TextButton(onClick = onRetry) { Text("Retry") } + } + } + } items( items = state.sections, key = { section -> section.id }, @@ -1108,8 +1355,16 @@ private fun BrowseTabContent( Column(modifier = Modifier.fillMaxSize().padding(top = topInset)) { Box(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { controlsHeader() } EmptyStateView( - title = if (isCustomised) "No matches" else "No items found", - subtitle = if (isCustomised) "No titles match the current sort or filters." else "Try switching libraries", + title = when (state.catalogLibraryHasItems) { + false -> "This library is empty" + true -> "No matches" + null -> if (isCustomised) "No matches" else "No items found" + }, + subtitle = when (state.catalogLibraryHasItems) { + false -> "There is nothing in this library yet." + true -> "No titles match the current filters." + null -> if (isCustomised) "No titles match the current sort or filters." else "Try switching libraries" + }, icon = libraryIcon(state.libraries.firstOrNull { it.id == state.selectedLibraryId }?.type.orEmpty()), modifier = Modifier.weight(1f), ) @@ -1248,6 +1503,7 @@ private fun CollectionsTabContent( ) { collection -> InlineLibraryCollectionCard( collection = collection, + showItemCount = state.mediaScope == null, onClick = { onCollectionClick(collection) }, ) } @@ -1260,6 +1516,7 @@ private fun CollectionsTabContent( @Composable private fun InlineLibraryCollectionCard( collection: LibraryCollection, + showItemCount: Boolean, onClick: () -> Unit, ) { // iOS `LibraryCollectionCard`: VStack(spacing: 6) of a 2:3.3 poster @@ -1286,7 +1543,7 @@ private fun InlineLibraryCollectionCard( contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), ) - Text( + if (showItemCount) Text( text = collection.itemCount?.takeIf { it > 0 }?.toString() ?: "Smart", fontSize = 12.sp, color = Color.White, @@ -1309,7 +1566,7 @@ private fun InlineLibraryCollectionCard( } if (caption.showsMetadata) { Text( - text = collection.itemCount?.let { "$it items" } ?: "Collection", + text = if (showItemCount) collection.itemCount?.let { "$it items" } ?: "Collection" else "Collection", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, @@ -1327,15 +1584,19 @@ private fun LibrariesFloatingChrome( canSwitch: Boolean, activeProfile: Profile?, selectedTab: LibrariesSubtab, + mediaScope: String?, + onMediaScopeSelected: (String) -> Unit, onLibrarySelectorClick: () -> Unit, onTabSelected: (LibrariesSubtab) -> Unit, onSearchClick: () -> Unit, onRequestsClick: (() -> Unit)?, - onWatchTogetherClick: (() -> Unit)?, + onWatchPartyClick: (() -> Unit)?, onSettingsClick: () -> Unit, onSwitchProfileClick: () -> Unit, onSwitchServerClick: () -> Unit, onSignOutClick: () -> Unit, + onShuffleClick: (() -> Unit)? = null, + shuffleEnabled: Boolean = true, modifier: Modifier = Modifier, ) { val statusBarPadding = WindowInsets.statusBars.asPaddingValues() @@ -1377,7 +1638,7 @@ private fun LibrariesFloatingChrome( activeProfile = activeProfile, onSearchClick = onSearchClick, onRequestsClick = onRequestsClick, - onWatchTogetherClick = onWatchTogetherClick, + onWatchPartyClick = onWatchPartyClick, onSettingsClick = onSettingsClick, onSwitchProfileClick = onSwitchProfileClick, onSwitchServerClick = onSwitchServerClick, @@ -1388,11 +1649,20 @@ private fun LibrariesFloatingChrome( // iOS: top bar bottom inset = smallPadding (8). Spacer(modifier = Modifier.height(8.dp)) + if (mediaScope != null) { + Row(Modifier.padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip(selected = mediaScope == "movie", onClick = { onMediaScopeSelected("movie") }, label = { Text("Movies") }) + FilterChip(selected = mediaScope == "series", onClick = { onMediaScopeSelected("series") }, label = { Text("Series") }) + } + } + LibrarySubtabRow( selectedTab = selectedTab, onRecommendedClick = { onTabSelected(LibrariesSubtab.Recommended) }, onBrowseClick = { onTabSelected(LibrariesSubtab.Browse) }, onCollectionsClick = { onTabSelected(LibrariesSubtab.Collections) }, + onShuffleClick = onShuffleClick, + shuffleEnabled = shuffleEnabled, modifier = Modifier.padding(horizontal = 16.dp), ) @@ -1502,6 +1772,8 @@ private fun LibrarySubtabRow( onRecommendedClick: () -> Unit, onBrowseClick: () -> Unit, onCollectionsClick: () -> Unit, + onShuffleClick: (() -> Unit)? = null, + shuffleEnabled: Boolean = true, modifier: Modifier = Modifier, ) { Row( @@ -1523,6 +1795,30 @@ private fun LibrarySubtabRow( selected = selectedTab == LibrariesSubtab.Collections, onClick = onCollectionsClick, ) + if (onShuffleClick != null) { + // Starts a shuffle of the whole library; drawn as a chip so it + // matches the tabs beside it. + Surface( + onClick = onShuffleClick, + enabled = shuffleEnabled, + shape = RoundedCornerShape(999.dp), + color = PrairieSurfaceElevated, + contentColor = MaterialTheme.colorScheme.onSurface, + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + imageVector = Icons.Filled.Shuffle, + contentDescription = null, + modifier = Modifier.size(14.dp), + ) + Text(text = "Shuffle", fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + } + } + } } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/pairing/CompanionPairingBottomOverlay.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/pairing/CompanionPairingBottomOverlay.kt index 940449ab48..b5e9b6c422 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/pairing/CompanionPairingBottomOverlay.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/pairing/CompanionPairingBottomOverlay.kt @@ -55,27 +55,89 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.paneTitle import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontFamily +import org.prairieserver.prairie.android.R +import org.prairieserver.prairie.model.auth.DeviceCodeFormat import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.prairieserver.prairie.android.ui.navigation.LocalBottomChromeInset +import org.prairieserver.prairie.android.ui.screens.auth.tvSignInLine import org.prairieserver.prairie.common.pairing.CompanionPairingApproval import org.prairieserver.prairie.common.pairing.CompanionPairingServer import org.prairieserver.prairie.common.pairing.CompanionPairingStatus import org.prairieserver.prairie.common.pairing.CompanionPairingTarget +/** + * App-wide host for the nearby-TV offer (iOS shows its card app-wide too). + * Shown only where the user is signed in and not in the middle of playback; + * [enabled] is decided by the navigation host. + */ +@Composable +fun CompanionPairingHost( + enabled: Boolean, + viewModel: CompanionPairingViewModel = org.koin.compose.viewmodel.koinViewModel(), +) { + LaunchedEffect(enabled) { viewModel.setActive(enabled) } + val offers by viewModel.offers.collectAsState() + val status by viewModel.status.collectAsState() + val approval by viewModel.pendingApproval.collectAsState() + val serverChoices by viewModel.serverChoices.collectAsState() + var presented by remember { mutableStateOf(null) } + LaunchedEffect(offers, status, enabled) { + // A route that hides the offer hides it mid-pairing too; the view + // model cancels that pairing (setActive). + if (!enabled) { + presented = null + return@LaunchedEffect + } + if (status !is CompanionPairingStatus.Idle) return@LaunchedEffect + val current = presented + presented = if (current == null) { + offers.firstOrNull() + } else { + // Android NSD can resolve the same TV again with a new listener + // port after its screen restarts. Keep the visible card, but pair + // with the freshest endpoint. + offers.firstOrNull { it.target.deviceId == current.target.deviceId } ?: offers.firstOrNull() + } + } + CompanionPairingBottomOverlay( + target = presented?.target, + signInServerName = presented?.signInServer?.displayName, + status = status, + approval = approval, + serverChoices = serverChoices, + onPair = { presented?.let(viewModel::pair) }, + onServersSelected = viewModel::continueWithServers, + onApprove = viewModel::approveMatchCode, + onDecline = viewModel::cancelMatchCode, + onDismiss = { + // Every close hides this TV's session, or the card would come + // straight back once the status returns to Idle. + val offer = presented + if (offer != null) viewModel.dismiss(offer.target) else viewModel.dismissPairing() + presented = null + }, + ) +} + /** Bottom-anchored companion setup card matching the iOS presentation. */ @Composable fun CompanionPairingBottomOverlay( target: CompanionPairingTarget?, + signInServerName: String? = null, status: CompanionPairingStatus, approval: CompanionPairingApproval?, serverChoices: List?, - onPair: (CompanionPairingTarget) -> Unit, + onPair: () -> Unit, onServersSelected: (Set) -> Unit, onApprove: () -> Unit, onDecline: () -> Unit, @@ -127,10 +189,11 @@ fun CompanionPairingBottomOverlay( retainedTarget?.let { rememberedTarget -> PairingCard( target = rememberedTarget, + signInServerName = signInServerName, status = status, approval = approval, serverChoices = serverChoices, - onPair = { onPair(rememberedTarget) }, + onPair = onPair, onServersSelected = onServersSelected, onApprove = onApprove, onDecline = onDecline, @@ -144,6 +207,7 @@ fun CompanionPairingBottomOverlay( @Composable private fun PairingCard( target: CompanionPairingTarget, + signInServerName: String?, status: CompanionPairingStatus, approval: CompanionPairingApproval?, serverChoices: List?, @@ -158,6 +222,11 @@ private fun PairingCard( WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding(), ) + // A signed-out TV (`st=login`) is being signed in, not set up; the + // progress and result copy says so. + val signIn = signInServerName != null + val paneTitle = stringResource(R.string.companion_pane_title) + Card( modifier = Modifier .windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal)) @@ -170,7 +239,7 @@ private fun PairingCard( .widthIn(max = 640.dp) .fillMaxWidth() .animateContentSize() - .semantics { paneTitle = "TV setup" }, + .semantics { this.paneTitle = paneTitle }, shape = RoundedCornerShape(30.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), elevation = CardDefaults.cardElevation(defaultElevation = 24.dp), @@ -207,27 +276,40 @@ private fun PairingCard( ) status is CompanionPairingStatus.Idle -> DiscoveryStep( target = target, + signInServerName = signInServerName, onPair = onPair, onDismiss = onDismiss, ) status is CompanionPairingStatus.Completed -> TerminalStep( successful = true, - title = status.serverNames.takeIf { it.isNotEmpty() } - ?.joinToString(prefix = "Set up ") - ?: "TV setup complete", - message = "${status.targetName} is ready.", - primaryLabel = "Done", + title = if (signIn) { + stringResource(R.string.companion_signin_done_title, status.targetName) + } else { + status.serverNames.takeIf { it.isNotEmpty() } + ?.let { stringResource(R.string.companion_setup_done_title, it.joinToString()) } + ?: stringResource(R.string.companion_setup_done_title_generic) + }, + message = if (signIn) { + stringResource(R.string.companion_signin_done_body, signInServerName.orEmpty()) + } else { + stringResource(R.string.companion_setup_done_body, status.targetName) + }, + primaryLabel = stringResource(R.string.companion_done), onPrimary = onDismiss, ) status is CompanionPairingStatus.Failed -> TerminalStep( successful = false, - title = "TV setup needs attention", + title = if (signIn) { + stringResource(R.string.companion_signin_failed_title) + } else { + stringResource(R.string.companion_setup_failed_title) + }, message = status.message, - primaryLabel = "Try Again", + primaryLabel = stringResource(R.string.companion_try_again), onPrimary = onPair, onSecondary = onDismiss, ) - else -> ProgressStep(status = status, onCancel = onDismiss) + else -> ProgressStep(status = status, signIn = signIn, onCancel = onDismiss) } } } @@ -240,7 +322,10 @@ private fun ServerPicker( onContinue: (Set) -> Unit, onCancel: () -> Unit, ) { - var selectedIds by remember(servers) { mutableStateOf(emptySet()) } + // The phone's active server is preselected (the usual answer). + var selectedIds by remember(servers) { + mutableStateOf(servers.filter { it.isActive }.map { it.id }.toSet()) + } Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth(), @@ -255,9 +340,9 @@ private fun ServerPicker( modifier = Modifier.size(40.dp), ) Column(modifier = Modifier.padding(start = 12.dp)) { - Text("Choose servers", style = MaterialTheme.typography.titleLarge) + Text(stringResource(R.string.companion_choose_servers_title), style = MaterialTheme.typography.titleLarge) Text( - text = "Sign $targetName in to…", + text = stringResource(R.string.companion_choose_servers_body, targetName), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -317,10 +402,10 @@ private fun ServerPicker( .height(48.dp) .padding(top = 6.dp), ) { - Text("Continue", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.companion_continue), style = MaterialTheme.typography.titleMedium) } TextButton(onClick = onCancel, modifier = Modifier.fillMaxWidth()) { - Text("Cancel") + Text(stringResource(R.string.companion_cancel)) } } } @@ -345,9 +430,13 @@ internal fun companionServerAddressLabel(url: String): String? { @Composable private fun DiscoveryStep( target: CompanionPairingTarget, + signInServerName: String?, onPair: () -> Unit, onDismiss: () -> Unit, ) { + // A signed-out TV wants one server this phone holds: "Sign in ?". + // A first-run TV gets the setup offer and the server chooser. + val signIn = signInServerName != null Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp), @@ -358,19 +447,33 @@ private fun DiscoveryStep( modifier = Modifier.size(72.dp), ) Text( - text = "Set Up ${target.name}", + text = if (signIn) { + stringResource(R.string.companion_offer_signin_title, target.name, signInServerName.orEmpty()) + } else { + stringResource(R.string.companion_offer_setup_title, target.name) + }, style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center, ) Text( - text = "Sign ${target.name} in to your servers from this phone.", + text = if (signInServerName != null) { + stringResource(R.string.companion_offer_signin_body, target.name) + } else { + stringResource(R.string.companion_offer_setup_body, target.name) + }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) - PrimaryAction(label = "Set Up", onClick = onPair, modifier = Modifier.padding(top = 14.dp)) + PrimaryAction( + label = stringResource( + if (signIn) R.string.companion_offer_signin_action else R.string.companion_offer_setup_action, + ), + onClick = onPair, + modifier = Modifier.padding(top = 14.dp), + ) TextButton(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) { - Text("Not Now") + Text(stringResource(R.string.companion_offer_not_now)) } } } @@ -385,48 +488,91 @@ private fun MatchConfirmation( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp), ) { + // People compare the TV's sign-in code, the one it shows on screen; + // the match words are checked in code and never shown. Text( - text = "Make sure ${approval.targetName} shows", + text = stringResource(R.string.companion_confirm_check, approval.targetName), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) + val spoken = DeviceCodeFormat.spoken(approval.userCode) Text( - text = approval.serverMatchCode.uppercase(), - style = MaterialTheme.typography.headlineLarge, - letterSpacing = 8.sp, + text = DeviceCodeFormat.display(approval.userCode), + style = MaterialTheme.typography.headlineLarge.copy(fontFamily = FontFamily.Monospace), + letterSpacing = 4.sp, textAlign = TextAlign.Center, + modifier = Modifier.semantics(mergeDescendants = true) { contentDescription = spoken }, ) + // TV apps released before user codes show only the match words. + // Remove this line together with the web /activate card's + // "Older TV apps show ..." line. + approval.serverMatchCode.trim().takeIf { it.isNotEmpty() }?.let { words -> + Text( + text = stringResource(R.string.companion_confirm_older_tv, words.uppercase()), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + // The same content model as "Sign in a TV", the web /activate card + // and the iPhone app: which server and account, what approving + // grants, and the warning. Text( - text = "for ${approval.serverName}", + text = companionSignInLine(approval), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 6.dp), + ) + Text( + text = stringResource(R.string.sign_in_tv_profiles_note), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Text( + text = stringResource(R.string.sign_in_tv_warning), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, ) PrimaryAction( - label = "Yes, this matches", + label = stringResource(R.string.companion_confirm_yes), onClick = onApprove, modifier = Modifier.padding(top = 14.dp), ) TextButton(onClick = onDecline, modifier = Modifier.fillMaxWidth()) { - Text("Doesn't match") + Text(stringResource(R.string.companion_confirm_no)) } } } +/** The same line as the code-entry approval ([tvSignInLine]). */ @Composable -private fun ProgressStep(status: CompanionPairingStatus, onCancel: () -> Unit) { +private fun companionSignInLine(approval: CompanionPairingApproval): String = + tvSignInLine(approval.serverName, approval.serverHost, approval.accountName) ?: approval.serverName + +@Composable +private fun ProgressStep(status: CompanionPairingStatus, signIn: Boolean, onCancel: () -> Unit) { + val working = stringResource( + if (signIn) R.string.companion_signin_progress_title else R.string.companion_setup_progress_title, + ) + val connecting = stringResource(R.string.companion_connecting) val (title, message) = when (status) { - is CompanionPairingStatus.Connecting -> "Connecting…" to status.targetName - is CompanionPairingStatus.PickServers -> "Connecting…" to status.targetName - is CompanionPairingStatus.PushingServer -> - "Setting up…" to "Continue on ${status.targetName} — allow this phone to set it up." + is CompanionPairingStatus.Connecting -> connecting to status.targetName + is CompanionPairingStatus.PickServers -> connecting to status.targetName + is CompanionPairingStatus.PushingServer -> working to if (signIn) { + stringResource(R.string.companion_signin_progress_allow, status.targetName) + } else { + stringResource(R.string.companion_setup_progress_allow, status.targetName) + } is CompanionPairingStatus.AwaitingMatchConfirmation -> - "Setting up…" to "Waiting for the match code." + working to stringResource(R.string.companion_progress_waiting_code, status.approval.targetName) is CompanionPairingStatus.Approving -> - "Setting up…" to "Approving ${status.serverName} on ${status.targetName}." + working to stringResource(R.string.companion_progress_approving, status.serverName, status.targetName) is CompanionPairingStatus.SignedIn -> - "Setting up…" to "${status.serverName} signed in." - else -> "Setting up…" to "Please wait." + working to stringResource(R.string.companion_progress_signed_in, status.serverName) + else -> working to stringResource(R.string.companion_progress_wait) } Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -447,7 +593,7 @@ private fun ProgressStep(status: CompanionPairingStatus, onCancel: () -> Unit) { strokeWidth = 3.dp, ) TextButton(onClick = onCancel, modifier = Modifier.fillMaxWidth()) { - Text("Cancel") + Text(stringResource(R.string.companion_cancel)) } } } @@ -482,7 +628,7 @@ private fun TerminalStep( PrimaryAction(label = primaryLabel, onClick = onPrimary, modifier = Modifier.padding(top = 8.dp)) onSecondary?.let { secondary -> TextButton(onClick = secondary, modifier = Modifier.fillMaxWidth()) { - Text("Close") + Text(stringResource(R.string.companion_close)) } } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/pairing/CompanionPairingViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/pairing/CompanionPairingViewModel.kt index 3b3dc05796..2d35e22ee7 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/pairing/CompanionPairingViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/pairing/CompanionPairingViewModel.kt @@ -4,30 +4,70 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.prairieserver.prairie.common.pairing.CompanionPairingApproval import org.prairieserver.prairie.common.pairing.CompanionPairingCoordinator import org.prairieserver.prairie.common.pairing.CompanionPairingNsdBrowser import org.prairieserver.prairie.common.pairing.CompanionPairingResult import org.prairieserver.prairie.common.pairing.CompanionPairingServer +import org.prairieserver.prairie.common.pairing.CompanionPairingServerStore import org.prairieserver.prairie.common.pairing.CompanionPairingStatus import org.prairieserver.prairie.common.pairing.CompanionPairingTarget +/** + * A nearby TV this phone can help. [signInServer] is set for a signed-out + * TV (`st=login`): the one saved server whose verified identity matches the + * TV's `srv`, pushed without a chooser. + */ +data class CompanionOffer( + val target: CompanionPairingTarget, + val signInServer: CompanionPairingServer? = null, +) + +/** + * The app-wide nearby-TV offer. Only TVs this phone can actually serve are + * offered: first-run (`st=setup`, or no `st` from older TVs) when the phone + * has a signed-in server, and signed-out (`st=login`) TVs only when the phone + * holds the server they advertise. Closing the card hides one TV advertising + * session; a new session is offered again. + */ +@OptIn(ExperimentalCoroutinesApi::class) class CompanionPairingViewModel( private val browser: CompanionPairingNsdBrowser, private val coordinator: CompanionPairingCoordinator, + private val serverStore: CompanionPairingServerStore, ) : ViewModel() { - val targets: StateFlow> = browser.targets.stateIn( + private val dismissed = MutableStateFlow>(emptySet()) + + val offers: StateFlow> = combine(browser.targets, dismissed) { targets, hidden -> + targets.filter { it.isOfferable && it.dismissalKey !in hidden } + }.mapLatest { targets -> + if (targets.isEmpty()) return@mapLatest emptyList() + val hasServers = serverStore.snapshot().servers.isNotEmpty() + if (!hasServers) return@mapLatest emptyList() + targets.mapNotNull { target -> + if (target.isSignedOutTv) { + coordinator.serverForSignedOutTv(target)?.let { CompanionOffer(target, it) } + } else { + CompanionOffer(target) + } + } + }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), initialValue = emptyList(), ) + val status: StateFlow = coordinator.status private val _pendingApproval = MutableStateFlow(null) @@ -39,18 +79,34 @@ class CompanionPairingViewModel( private var pendingServerSelection: CompletableDeferred>? = null private var pendingDecision: CompletableDeferred? = null private var pairingJob: Job? = null + private var active = false - init { - browser.start() + /** + * Discover nearby TVs only while the offer can be shown: not over the + * sign-in chain or playback, where the host hides it, so those routes run + * no multicast discovery or identity probes. A pairing in progress when + * the offer is hidden (a sign-out, an expired session) is cancelled. + */ + fun setActive(enabled: Boolean) { + if (enabled == active) return + active = enabled + if (enabled) { + browser.start() + } else { + dismissPairing() + browser.stop() + } } - fun pair(target: CompanionPairingTarget) { + fun pair(offer: CompanionOffer) { if (pairingJob?.isActive == true) return + val target = offer.target val job = viewModelScope.launch(start = CoroutineStart.LAZY) { val self = kotlin.coroutines.coroutineContext[Job] try { - val result = coordinator.pair( + coordinator.pair( target = target, + signInServer = offer.signInServer, chooseServers = { choices -> val selection = CompletableDeferred>() pendingServerSelection = selection @@ -73,9 +129,10 @@ class CompanionPairingViewModel( pendingDecision = null } }, - ) - if (result is CompanionPairingResult.Completed) { - browser.stop() + ).also { result -> + // The TV stops advertising once it's set up; its session + // must not be offered again. + if (result is CompanionPairingResult.Completed) hide(target) } } finally { if (pairingJob === self) pairingJob = null @@ -101,7 +158,19 @@ class CompanionPairingViewModel( pendingDecision?.complete(false) } + /** + * The person closed the card ("Not now", Cancel, Back, Close or Done): + * hide this TV's advertising session and reset. The TV is offered again + * on its next advertising session (a new `sid`); "Try again" on a failure + * re-offers it explicitly. + */ + fun dismiss(target: CompanionPairingTarget) { + hide(target) + dismissPairing() + } + fun dismissPairing() { + val finished = coordinator.status.value is CompanionPairingStatus.Completed pendingServerSelection?.cancel() pendingServerSelection = null _serverChoices.value = null @@ -111,11 +180,26 @@ class CompanionPairingViewModel( pairingJob?.cancel() pairingJob = null coordinator.reset() + if (finished && active) { + // Discovery restarts after a setup so the next TV (or this one, + // signed out later) is found fresh rather than from stale results. + browser.stop() + browser.start() + } + } + + private fun hide(target: CompanionPairingTarget) { + dismissed.update { it + target.dismissalKey } } override fun onCleared() { dismissPairing() + active = false browser.stop() super.onCleared() } } + +/** One TV advertising session: a TV that restarts advertising gets a new `sid`. */ +val CompanionPairingTarget.dismissalKey: String + get() = "$deviceId:${sessionId ?: serviceName}" diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/personal/PersonalMediaGridContent.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/personal/PersonalMediaGridContent.kt index e2613afcd1..b0939c772b 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/personal/PersonalMediaGridContent.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/personal/PersonalMediaGridContent.kt @@ -1,5 +1,8 @@ package org.prairieserver.prairie.android.ui.screens.personal +import org.prairieserver.prairie.common.cards.LocalEpisodeSpoilerPrefs +import org.prairieserver.prairie.model.settings.EpisodeSpoilers + import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable @@ -63,7 +66,7 @@ import org.prairieserver.prairie.android.ui.components.WatchedBadge import org.prairieserver.prairie.android.ui.components.rememberBrowseItemCardActions import org.prairieserver.prairie.common.cards.LocalCardPresentation import org.prairieserver.prairie.common.ui.components.DeferImagePresentationWhileScrolling -import org.prairieserver.prairie.common.ui.components.ThumbhashImage +import org.prairieserver.prairie.common.ui.components.SpoilerImage import org.prairieserver.prairie.common.overlays.CardOverlayVariant import org.prairieserver.prairie.common.overlays.CardOverlays import org.prairieserver.prairie.common.overlays.LocalCardOverlayUiState @@ -392,7 +395,10 @@ fun MediaGridItem( onWatchlistToggle: (() -> Unit)? = null, isInWatchlist: Boolean = false, ) { - val (actions, userState) = rememberBrowseItemCardActions(item) + val (actions, userState, positionSeconds) = rememberBrowseItemCardActions(item) + val spoilerPrefs = LocalEpisodeSpoilerPrefs.current + val hideArtwork = EpisodeSpoilers.hidesBrowseArtwork(item, spoilerPrefs, userState.played, positionSeconds = positionSeconds) + val hideDetailArtwork = EpisodeSpoilers.hidesBrowseArtwork(item, spoilerPrefs, userState.played, selectBackdrop = true, positionSeconds = positionSeconds) val overlayState = LocalCardOverlayUiState.current var menuExpanded by remember { mutableStateOf(false) } val heroHandoff = LocalHeroSourceHandoff.current @@ -400,8 +406,8 @@ fun MediaGridItem( androidx.compose.foundation.layout.Column( modifier = modifier.combinedClickable( onClick = { - heroHandoff?.pendingArtworkUrl = item.backdropUrl ?: item.posterUrl - heroHandoff?.pendingArtworkThumbhash = item.backdropThumbhash ?: item.posterThumbhash + heroHandoff?.pendingArtworkUrl = (item.backdropUrl ?: item.posterUrl).takeUnless { hideDetailArtwork } + heroHandoff?.pendingArtworkThumbhash = if (hideDetailArtwork) null else item.backdropThumbhash ?: item.posterThumbhash onClick() }, onLongClick = { menuExpanded = true }, @@ -414,7 +420,8 @@ fun MediaGridItem( .aspectRatio(2f / 3.3f) .clip(RoundedCornerShape(8.dp)), ) { - ThumbhashImage( + SpoilerImage( + hidden = hideArtwork, url = item.posterUrl, thumbhash = item.posterThumbhash, contentDescription = item.title, diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/AiTranslateSheet.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/AiTranslateSheet.kt index 794f848c20..079a2aeb9b 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/AiTranslateSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/AiTranslateSheet.kt @@ -1,5 +1,7 @@ package org.prairieserver.prairie.android.ui.screens.player +import org.prairieserver.prairie.android.ui.components.PrairieDropdownMenuItem +import org.prairieserver.prairie.android.ui.components.PrairieDropdownMenu import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -18,9 +20,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator @@ -44,7 +45,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import org.prairieserver.prairie.android.ui.util.LanguageNames +import org.prairieserver.prairie.common.ui.LanguageNames import org.prairieserver.prairie.model.catalog.AudioTrack import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo import org.prairieserver.prairie.model.subtitles.SubtitleAiJobKind @@ -99,35 +100,24 @@ fun AiTranslateSheet( } // On job completion return to the tracks sheet (via onBack) so the freshly // generated track is visible, rather than dropping onto bare player controls. + val controller = rememberPlayerMenuController(onDismiss) + val back = onBack?.let { parent -> { controller.dismiss(then = parent) } } LaunchedEffect(tools.jobJustCompleted) { - if (tools.jobJustCompleted) (onBack ?: onDismiss)() + if (tools.jobJustCompleted) (back ?: { controller.dismiss() })() } - PlayerModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - tabletopPaneHeight = tabletopPaneHeight, - ) { + PlayerMenu(controller = controller, tabletopPaneHeight = tabletopPaneHeight) { + PlayerSheetHeader( + title = "Translate with AI", + onBack = back, + onDismiss = { controller.dismiss() }, + ) Column( modifier = Modifier .fillMaxWidth() - .playerSheetContent(tabletopPaneHeight) - .nestedScroll(PlayerSheetFlingGuard) - .background( - brush = Brush.verticalGradient( - colors = listOf( - Color(0xFF1F2937).copy(alpha = 0.95f), - Color.Black.copy(alpha = 0.92f), - ), - ), - ) + .weight(1f, fill = false) .verticalScroll(rememberScrollState()), ) { - PlayerSheetHeader( - title = "Translate with AI", - onBack = onBack, - onDismiss = onDismiss, - ) val activeJob = tools.activeJob when { @@ -254,12 +244,16 @@ fun AiTranslateSheet( !(mode == AiMode.Audio && quotaExhausted) && (if (mode == AiMode.Audio) audioTracks.isNotEmpty() else sourceTracks.isNotEmpty()), modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = PlayerChrome.Paper, + contentColor = PlayerChrome.Ink, + ), ) { if (tools.translateSubmitting) { CircularProgressIndicator( modifier = Modifier.size(18.dp), strokeWidth = 2.dp, - color = Color.White, + color = PlayerChrome.Ink, ) } else { Text(if (mode == AiMode.Audio) "Generate" else "Translate") @@ -268,7 +262,7 @@ fun AiTranslateSheet( } } - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(12.dp)) } } } @@ -310,7 +304,7 @@ private fun PickerRow( Row( modifier = Modifier .fillMaxWidth() - .background(Color.White.copy(alpha = 0.08f), RoundedCornerShape(8.dp)) + .background(PlayerChrome.Raised, RoundedCornerShape(12.dp)) .clickable { expanded = true } .padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, @@ -328,13 +322,13 @@ private fun PickerRow( tint = Color.White.copy(alpha = 0.7f), ) } - DropdownMenu( + PrairieDropdownMenu( expanded = expanded, onDismissRequest = { expanded = false }, modifier = Modifier.heightIn(max = 320.dp), ) { options.forEachIndexed { index, option -> - DropdownMenuItem( + PrairieDropdownMenuItem( text = { Text(option) }, onClick = { onSelect(index) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/ChaptersSheet.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/ChaptersSheet.kt index 5d486244e4..0c8d18c494 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/ChaptersSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/ChaptersSheet.kt @@ -1,49 +1,42 @@ package org.prairieserver.prairie.android.ui.screens.player -import androidx.compose.foundation.border +import androidx.compose.foundation.background import androidx.compose.foundation.clickable 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.itemsIndexed -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import kotlinx.coroutines.launch import org.prairieserver.prairie.android.ui.util.formatClockTime import org.prairieserver.prairie.model.catalog.VersionChapter -/** Adaptive chapter picker shared by regular phones and foldable tabletop mode. */ -@OptIn(ExperimentalMaterial3Api::class) +/** + * Chapter picker. Number, title, start and length line up in columns; the + * chapter that is playing is marked and the list opens scrolled to it. + */ @Composable fun ChaptersSheet( isVisible: Boolean, @@ -51,175 +44,148 @@ fun ChaptersSheet( onSelect: (chapterIndex: Int) -> Unit, onDismiss: () -> Unit, position: Double = 0.0, + /** The title's length: the last chapter's end when the server gives none. */ + duration: Double = 0.0, tabletopPaneHeight: Dp? = null, ) { if (!isVisible) return val currentChapterIndex = chapters.indexOfLast { it.startSeconds <= position } - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val scope = rememberCoroutineScope() - val dismissSheet = { - scope.launch { sheetState.hide() } - onDismiss() - } - - LaunchedEffect(isVisible) { - if (isVisible) sheetState.show() - } + val controller = rememberPlayerMenuController(onDismiss) + val listState = rememberLazyListState( + initialFirstVisibleItemIndex = (currentChapterIndex - 2).coerceAtLeast(0), + ) - PlayerModalBottomSheet( - onDismissRequest = dismissSheet, - sheetState = sheetState, - tabletopPaneHeight = tabletopPaneHeight, - ) { - Column( + PlayerMenu(controller = controller, tabletopPaneHeight = tabletopPaneHeight) { + PlayerPanelHeader(title = "Chapters", onClose = { controller.dismiss() }) + val current = chapters.getOrNull(currentChapterIndex) + val countLabel = if (chapters.size == 1) "1 chapter" else "${chapters.size} chapters" + Text( + text = if (current != null) { + val left = (chapterEnd(chapters, currentChapterIndex, duration) - position).coerceAtLeast(0.0) + "$countLabel · ${formatClockTime(left)} left in this chapter" + } else { + countLabel + }, + style = PlayerType.RowDetail.copy(fontFeatureSettings = "tnum"), + modifier = Modifier.padding(start = 18.dp, end = 18.dp, bottom = 6.dp), + ) + if (chapters.isEmpty()) { + PlayerFootnote("No chapters are available for this title.") + return@PlayerMenu + } + LazyColumn( + state = listState, modifier = Modifier + .weight(1f, fill = false) .fillMaxWidth() - .playerSheetContent(tabletopPaneHeight) - .nestedScroll(PlayerSheetFlingGuard), + .playerFadingEdges(listState) + .padding(horizontal = 8.dp), ) { - PlayerSheetHeader( - title = "Chapters", - subtitle = when (chapters.size) { - 1 -> "1 chapter" - else -> "${chapters.size} chapters" - }, - onDismiss = dismissSheet, - ) - - if (chapters.isEmpty()) { - PlayerSheetCard( - modifier = Modifier - .fillMaxWidth() - .padding( - horizontal = playerSheetHorizontalPadding(tabletopPaneHeight), - vertical = 8.dp, - ), - ) { - Text( - text = "No chapters are available for this title.", - color = Color.White.copy(alpha = 0.62f), - fontSize = 14.sp, - modifier = Modifier.padding(20.dp), - ) - } - } else { - LazyVerticalGrid( - columns = GridCells.Fixed(if (tabletopPaneHeight == null) 1 else 2), - modifier = Modifier - .fillMaxWidth() - .then( - if (tabletopPaneHeight == null) Modifier else Modifier.weight(1f), - ), - contentPadding = PaddingValues( - start = playerSheetHorizontalPadding(tabletopPaneHeight), - end = playerSheetHorizontalPadding(tabletopPaneHeight), - top = 8.dp, - bottom = 24.dp, - ), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - itemsIndexed( - chapters, - key = { _, chapter -> chapter.index }, - contentType = { _, _ -> "chapter-card" }, - ) { index, chapter -> - ChapterCard( - chapter = chapter, - isCurrent = index == currentChapterIndex, - onClick = { - onSelect(index) - dismissSheet() - }, - ) - } - } + itemsIndexed(chapters, key = { _, chapter -> chapter.index }) { index, chapter -> + val length = chapterEnd(chapters, index, duration) - chapter.startSeconds + ChapterRow( + number = index + 1, + title = chapter.title.ifBlank { "Chapter ${chapter.index + 1}" }, + start = chapter.startSeconds, + length = length, + progress = if (index == currentChapterIndex) { + if (length > 0) ((position - chapter.startSeconds) / length).toFloat() else 0f + } else { + null + }, + onClick = { + onSelect(index) + controller.dismiss() + }, + ) } } } } +private fun chapterEnd(chapters: List, index: Int, duration: Double): Double { + val chapter = chapters[index] + return when { + chapter.endSeconds > chapter.startSeconds -> chapter.endSeconds + index + 1 < chapters.size -> chapters[index + 1].startSeconds + duration > chapter.startSeconds -> duration + else -> chapter.startSeconds + } +} + @Composable -private fun ChapterCard( - chapter: VersionChapter, - isCurrent: Boolean, +private fun ChapterRow( + number: Int, + title: String, + start: Double, + length: Double, + progress: Float?, onClick: () -> Unit, ) { - val shape = RoundedCornerShape(16.dp) - Surface( - color = if (isCurrent) PlayerSheetSelectedColor else PlayerSheetCardColor, - shape = shape, + val isCurrent = progress != null + Column( modifier = Modifier .fillMaxWidth() - .heightIn(min = 72.dp) - .then( + .clip(RoundedCornerShape(12.dp)) + .background(if (isCurrent) PlayerChrome.Raised else androidx.compose.ui.graphics.Color.Transparent) + .clickable(role = Role.Button, onClickLabel = "Play from $title", onClick = onClick) + .heightIn(min = 44.dp) + .padding(horizontal = 10.dp), + verticalArrangement = Arrangement.Center, + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 10.dp)) { + Box(modifier = Modifier.width(34.dp)) { if (isCurrent) { - Modifier.border( - width = 1.dp, - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.55f), - shape = shape, + Icon( + imageVector = Icons.Rounded.PlayArrow, + contentDescription = "Now playing", + tint = PlayerChrome.Paper, + modifier = Modifier.size(18.dp), ) } else { - Modifier - }, - ) - .clickable(onClick = onClick), - ) { - Row( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 14.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Surface( - shape = CircleShape, - color = if (isCurrent) { - MaterialTheme.colorScheme.primary.copy(alpha = 0.18f) - } else { - Color.White.copy(alpha = 0.07f) - }, - modifier = Modifier.size(38.dp), - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - if (isCurrent) { - Icon( - imageVector = Icons.Filled.PlayArrow, - contentDescription = "Now playing", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(22.dp), - ) - } else { - Text( - text = (chapter.index + 1).toString(), - color = Color.White.copy(alpha = 0.72f), - fontSize = 13.sp, - fontWeight = FontWeight.SemiBold, - ) - } + Text( + text = number.toString().padStart(2, '0'), + style = PlayerType.Value.copy(fontWeight = FontWeight.SemiBold), + ) } } - Column( + Text( + text = title, + style = PlayerType.RowTitle.copy( + fontWeight = if (isCurrent) FontWeight.SemiBold else FontWeight.Medium, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(3.dp), + ) + Text( + text = formatClockTime(start), + style = PlayerType.Value, + textAlign = TextAlign.End, + modifier = Modifier.width(64.dp), + ) + Text( + text = formatClockTime(length.coerceAtLeast(0.0)), + style = PlayerType.Value, + textAlign = TextAlign.End, + modifier = Modifier.width(56.dp), + ) + } + if (progress != null) { + Box( + modifier = Modifier + .padding(start = 34.dp, end = 120.dp, bottom = 6.dp) + .fillMaxWidth() + .height(2.dp) + .clip(RoundedCornerShape(1.dp)) + .background(androidx.compose.ui.graphics.Color.White.copy(alpha = 0.14f)), ) { - Text( - text = chapter.title.ifBlank { "Chapter ${chapter.index + 1}" }, - color = Color.White, - fontSize = 15.sp, - fontWeight = if (isCurrent) FontWeight.SemiBold else FontWeight.Medium, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = formatClockTime(chapter.startSeconds), - color = Color.White.copy(alpha = 0.52f), - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + Box( + modifier = Modifier + .fillMaxWidth(progress.coerceIn(0f, 1f)) + .height(2.dp) + .background(PlayerChrome.Paper), ) } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/IntroAutoSkipBanner.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/IntroAutoSkipBanner.kt index b767642087..5d827d8b73 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/IntroAutoSkipBanner.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/IntroAutoSkipBanner.kt @@ -190,3 +190,9 @@ private fun IntroPromptPill( } } } + +/** Manual recap/credits action uses the intro pill's existing visual treatment. */ +@Composable +fun MarkerSkipPill(label: String, onClick: () -> Unit) { + IntroPromptPill(label = label, icon = Icons.Filled.SkipNext, progress = 0f, onClick = onClick) +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/LocalWatchMetadata.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/LocalWatchMetadata.kt index 175072eaf1..8f7daf18e3 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/LocalWatchMetadata.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/LocalWatchMetadata.kt @@ -15,6 +15,8 @@ internal suspend fun loadLocalWatchMetadata( profileId: String, contentId: String, libraryId: Int? = null, + /** The downloaded file, so the server fills that version's on-demand markers. */ + fileId: Int? = null, stillCurrent: () -> Boolean, ): WatchDetail? { if (owner == null || owner.serverId != serverId || owner.profileId != profileId) return null @@ -24,7 +26,7 @@ internal suspend fun loadLocalWatchMetadata( return valid && stillCurrent() } if (!current()) return null - val result = repository.getWatchDetail(contentId, owner, libraryId) + val result = repository.getWatchDetail(contentId, owner, libraryId, fileId) if (!current()) return null return (result as? ApiResult.Success)?.data } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleAutoSelection.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleAutoSelection.kt index f015f03488..a65ece5e41 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleAutoSelection.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleAutoSelection.kt @@ -2,8 +2,11 @@ package org.prairieserver.prairie.android.ui.screens.player import org.prairieserver.prairie.model.catalog.AudioTrack import org.prairieserver.prairie.model.catalog.SubtitleTrack +import org.prairieserver.prairie.model.playback.AutoSubtitleCandidate import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo import org.prairieserver.prairie.model.playback.SubtitleIdentity +import org.prairieserver.prairie.model.playback.autoSubtitlePreferenceOrder +import org.prairieserver.prairie.model.playback.toAutoSubtitleCandidate import org.prairieserver.prairie.playback.canonicalSubtitleLanguage import org.prairieserver.prairie.playback.hasPositiveSubtitleDiscriminator import org.prairieserver.prairie.playback.isBitmapSubtitleCodecFamily @@ -131,7 +134,7 @@ internal fun resolveMobileAutoSubtitleSelection( targetLanguage = targetLanguage, preferForced = showForcedSubtitles, ) ?: if (showForcedSubtitles) { - subtitles.indexOfFirst { it.forced == true }.takeIf { it >= 0 } + bestRankedOrdinal(subtitles.withIndex().filter { (_, subtitle) -> subtitle.forced == true }) } else { null } @@ -215,6 +218,11 @@ private fun normalizedSubtitleCodec(codecOrMime: String?): String? { } } +/** + * Ranks with the shared [autoSubtitlePreferenceOrder] — burn-in, then + * forced/SDH, then embedded over external over downloaded — so the mounted + * order (externals first) no longer decides a tie. + */ private fun bestAutoSubtitleOrdinal( subtitles: List, targetLanguage: String?, @@ -226,37 +234,44 @@ private fun bestAutoSubtitleOrdinal( if (pool.isEmpty()) return null if (preferForced) { - pool.firstOrNull { (_, subtitle) -> - subtitle.forced == true && !subtitle.isEffectivelyHearingImpaired() && !subtitle.isBitmap() - }?.let { return it.index } + // Only a plain TEXT forced track jumps the queue here, as before: this + // step predates the shared "forced never outranks full dialogue" rule, + // and widening it to every renderable bitmap would let a forced PGS + // track displace the full one. + bestRankedOrdinal( + pool.filter { (_, subtitle) -> + subtitle.forced == true && + !subtitle.isEffectivelyHearingImpaired() && + !subtitle.isBitmap() + }, + )?.let { return it } } - pool.firstOrNull { (_, subtitle) -> - subtitle.forced != true && !subtitle.isEffectivelyHearingImpaired() && !subtitle.isBitmap() - }?.let { return it.index } - pool.firstOrNull { (_, subtitle) -> - subtitle.forced != true && !subtitle.isBitmap() - }?.let { return it.index } - pool.firstOrNull { (_, subtitle) -> !subtitle.isBitmap() } - ?.let { return it.index } - return pool.first().index + return bestRankedOrdinal(pool) } private fun bestForcedAutoSubtitleOrdinal( subtitles: List, targetLanguage: String?, -): Int? { - val pool = subtitles.withIndex().filter { (_, subtitle) -> +): Int? = bestRankedOrdinal( + subtitles.withIndex().filter { (_, subtitle) -> subtitle.forced == true && (targetLanguage == null || canonicalSubtitleLanguage(subtitle.language) == targetLanguage) - } - if (pool.isEmpty()) return null + }, +) - pool.firstOrNull { (_, subtitle) -> !subtitle.isEffectivelyHearingImpaired() && !subtitle.isBitmap() } - ?.let { return it.index } - pool.firstOrNull { (_, subtitle) -> !subtitle.isEffectivelyHearingImpaired() } - ?.let { return it.index } - return pool.first().index -} +/** The ordinal of the best-ranked row, or null for an empty pool. */ +private fun bestRankedOrdinal(pool: List>): Int? = + pool + .map { (ordinal, subtitle) -> ordinal to subtitle.toMobileAutoSubtitleCandidate() } + .minWithOrNull(compareBy(autoSubtitlePreferenceOrder) { it.second }) + ?.first + +/** + * The phone's SDH predicate reads the runtime label, source and URL rather + * than the catalog title, so it replaces the shared title check. + */ +private fun PlayerSubtitleInfo.toMobileAutoSubtitleCandidate(): AutoSubtitleCandidate = + toAutoSubtitleCandidate().copy(title = label, hearingImpaired = isEffectivelyHearingImpaired()) private fun PlayerSubtitleInfo.isEffectivelyHearingImpaired(): Boolean = subtitleLabelIndicatesHearingImpaired(label) || diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleTransactionAdapter.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleTransactionAdapter.kt index 264aeb5d8f..3514d289e6 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleTransactionAdapter.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleTransactionAdapter.kt @@ -31,6 +31,7 @@ import org.prairieserver.prairie.model.playback.SubtitleIdentity import org.prairieserver.prairie.model.playback.SubtitleTransitionEvent import org.prairieserver.prairie.model.playback.SubtitleTransitionState import org.prairieserver.prairie.model.playback.UpdateAudioPreference +import org.prairieserver.prairie.model.playback.UpdateQualityPreference import org.prairieserver.prairie.model.playback.isLocalDownloadedSubtitle import org.prairieserver.prairie.model.playback.rebaseDownloadedSubtitleUrl import org.prairieserver.prairie.model.playback.reduceSubtitleTransition @@ -115,6 +116,8 @@ internal data class MobileSubtitleTransactionSnapshot( val pendingIdentity: SubtitleIdentity? = transition.pending?.identity, val localMountIdentity: SubtitleIdentity? = null, val failureMessage: String? = null, + /** The failure belongs to a quality change, not a subtitle or audio one. */ + val qualityChangeFailed: Boolean = false, ) { val committedIdentity: SubtitleIdentity get() = transition.committed.identity @@ -205,6 +208,7 @@ internal class MobileSubtitleTransactionAdapter( private var refreshGeneration = 0L private var subtitleIntentGeneration = 0L private var failureMessage: String? = null + private var qualityChangeFailed = false private var pendingLocalSelection: PendingLocalSelection? = null private var pendingLocalRestore: PendingLocalRestore? = null private var localMountGeneration = 0L @@ -224,6 +228,7 @@ internal class MobileSubtitleTransactionAdapter( pendingIdentity = queuedIdentity ?: localIdentity ?: transition.pending?.identity, localMountIdentity = if (queuedIdentity == null) localIdentity else null, failureMessage = failureMessage, + qualityChangeFailed = qualityChangeFailed && failureMessage != null, ) } @@ -372,6 +377,11 @@ internal class MobileSubtitleTransactionAdapter( mutate(UpdateAudioPreference(audioTrackIndex), explicit = true) } + /** Replans the same file at [qualityPreference] (a protocol-v3 quality label). */ + fun selectQuality(qualityPreference: String?) { + mutate(UpdateQualityPreference(qualityPreference), explicit = true) + } + /** * Records audio that was switched on the player, with no server replan. * @@ -980,6 +990,10 @@ internal class MobileSubtitleTransactionAdapter( } private fun fail(generation: Long, message: String) { + val failedPending = transition.pending?.takeIf { it.generation == generation } + val failedQualityChange = failedPending != null && + failedPending.qualityPreferenceSpecified && + failedPending.qualityPreference != transition.committed.qualityPreference val failedLocalOwner = pendingLocalSelection?.takeIf { owner -> owner.proposedState.pending?.generation == generation } @@ -996,6 +1010,7 @@ internal class MobileSubtitleTransactionAdapter( } transition = failed.state failureMessage = message + qualityChangeFailed = failedQualityChange val priorIdentity = transition.committed.identity if ( failedLocalOwner?.mountedBeforeAdoption == true && @@ -1025,12 +1040,14 @@ internal class MobileSubtitleTransactionAdapter( } invalidateLocalMount() failureMessage = "Embedded subtitles couldn't load. Retrying with a sidecar." + qualityChangeFailed = false publish() onEmbeddedSubtitleFailure(failedIdentity.serverIndex) return } invalidateLocalMount() failureMessage = "The selected subtitle could not be mounted." + qualityChangeFailed = false publish() } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileVideoPlaybackStarter.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileVideoPlaybackStarter.kt index 5c5d822e21..f74fc587aa 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileVideoPlaybackStarter.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileVideoPlaybackStarter.kt @@ -56,6 +56,7 @@ internal data class MobileVideoSessionAllocation( /** `playback.max_bitrate_kbps`; null is uncapped. */ val maxBitrateKbps: Int? = null, val expectedMetadataOwner: org.prairieserver.prairie.network.AuthScopeSnapshot? = null, + val allowAlternateVersions: Boolean? = null, ) internal fun interface MobileVideoSessionAllocator { @@ -202,13 +203,38 @@ internal class MobileVideoPlaybackStarter( val maxBitrateKbps = playerSettingsStore.maxBitrateKbpsFlow.first() val configuredAudioLanguage = playerSettingsStore.audioLanguageFlow .first().ifBlank { null } - val version = request.preferredFileId - ?.let { id -> watchDetail.versions.firstOrNull { it.fileId == id } } - ?: selectPlaybackVersion( - watchDetail.versions, - watchDetail.userData?.lastFileId, - preferredQuality, - ) + val room = request.room + // A room plays exactly its file; never a preferred or remembered one. + val version = if (room != null) { + watchDetail.versions.firstOrNull { it.fileId == room.fileId } + ?: return failure( + request.contentId, + "The Watch Party's version of this title isn't available to this profile.", + diagnosticsCode = PlaybackDiagnosticsCode.NO_VERSIONS, + ) + } else { + request.preferredFileId + ?.let { id -> watchDetail.versions.firstOrNull { it.fileId == id } } + ?: selectPlaybackVersion( + watchDetail.versions, + watchDetail.userData?.lastFileId, + preferredQuality, + ).let { selected -> + // A start from the beginning opens a multi-part item at its first part. + if (org.prairieserver.prairie.model.playback.isExplicitStartOver(request.resumePositionOverride)) { + org.prairieserver.prairie.playback.firstPlaybackPart(watchDetail.versions, selected) + } else { + selected + } + } + } + // Room starts keep the file through every replan; renewals keep + // whatever the original start chose. + val allowAlternateVersions = if (room != null) { + false + } else { + request.recoveryStartParams?.allowAlternateVersions + } val persistedTrackSelection = if ( userItemStatePort != null && (request.audioTrackIndex == null || request.subtitleTrackIndex == null) @@ -252,11 +278,11 @@ internal class MobileVideoPlaybackStarter( capabilities = capabilities, ) // Skip-back-on-resume: nudge a genuine resume back a few seconds. - // Suppressed for Start Over / retry (request flag) and Watch Together + // Suppressed for Start Over / retry (request flag) and Watch Party // (roomId — all participants must land on the synced anchor). The same // rewound value drives BOTH the server seek and the player start, so // a transcode cut and the player position never disagree. - val suppressRewind = request.suppressResumeRewind || request.roomId != null || + val suppressRewind = request.suppressResumeRewind || request.roomId != null || room != null || org.prairieserver.prairie.model.playback.isExplicitStartOver(request.resumePositionOverride) // Per-profile setting (default 7; 0 = off). Read once per start. val rewindSeconds = playerSettingsStore.resumeRewindSecondsFlow.first().toDouble() @@ -267,7 +293,9 @@ internal class MobileVideoPlaybackStarter( rewindSeconds = rewindSeconds, ) } - val startRequestPosition = rewound( + // A room starts at the room position, including an explicit zero; + // personal resume never applies. + val startRequestPosition = room?.positionSeconds ?: rewound( resolvePlaybackStartRequestPosition( overridePosition = request.resumePositionOverride, detailPosition = watchDetail.userData?.positionSeconds, @@ -289,6 +317,7 @@ internal class MobileVideoPlaybackStarter( startPosition = startRequestPosition, maxBitrateKbps = maxBitrateKbps, expectedMetadataOwner = expectedMetadataOwner, + allowAlternateVersions = allowAlternateVersions, ), ) ?: playbackSessionManager.startVideoSessionV3( fileId = version.fileId, @@ -301,6 +330,7 @@ internal class MobileVideoPlaybackStarter( startPosition = startRequestPosition, maxBitrateKbps = maxBitrateKbps, expectedMetadataOwner = expectedMetadataOwner, + allowAlternateVersions = allowAlternateVersions, ) ) { is ApiResult.Success -> r.data @@ -322,6 +352,7 @@ internal class MobileVideoPlaybackStarter( request.contentId, serverTerminalUserMessage(v3Start.message), diagnosticsCode = PlaybackDiagnosticsCode.serverTerminal(v3Start.reason), + terminalReason = v3Start.reason, ) VideoSessionStartV3.ServerUpgradeRequired -> return failure( request.contentId, @@ -340,6 +371,15 @@ internal class MobileVideoPlaybackStarter( val effectiveFileId = resolved.mediaFileId.takeIf { it > 0 } ?: readyV3.plan.effectiveMediaFileId ?: version.fileId + if (room != null && effectiveFileId != room.fileId) { + stopAllocatedButUnpublishedSession(allocatedButUnpublishedSessionId) + allocatedButUnpublishedSessionId = null + return failure( + request.contentId, + "The server offered a different version than the Watch Party's.", + diagnosticsCode = PlaybackDiagnosticsCode.START_REQUEST, + ) + } val effectiveVersion = watchDetail.versions.firstOrNull { it.fileId == effectiveFileId } val resolvedDelivery = resolved.resolvedPlaybackDelivery() val resolvedStreamUrl = resolved.playbackPlan?.stream?.url @@ -368,6 +408,7 @@ internal class MobileVideoPlaybackStarter( qualityPreference = playbackQualityIntent, startPosition = sourceStartPos, clientPlaybackContext = readyV3.clientPlaybackContext, + allowAlternateVersions = allowAlternateVersions, ) val adopted = if (sessionAdopter != null) { sessionAdopter.adopt(startParams, resolved) @@ -460,10 +501,10 @@ internal class MobileVideoPlaybackStarter( showForcedSubtitles = watchDetail.effectiveShowForcedSubtitles ?: activeProfile?.showForcedSubtitles ?: true, - intro = watchDetail.intro, - credits = watchDetail.credits, - recap = watchDetail.recap, - preview = watchDetail.preview, + intro = effectiveVersion?.intro, + credits = effectiveVersion?.credits, + recap = effectiveVersion?.recap, + preview = effectiveVersion?.preview, chapters = effectiveVersion?.chapters.orEmpty(), seriesId = watchDetail.seriesId, seriesTitle = watchDetail.seriesTitle, @@ -500,6 +541,7 @@ internal class MobileVideoPlaybackStarter( message: String, cause: Throwable? = null, diagnosticsCode: PlaybackDiagnosticsCode? = null, + terminalReason: String? = null, ): VideoPlaybackStartResult.Error { // Log the throwable here instead of stashing it on the (unread) result — // the message already carries the human-facing detail. @@ -508,6 +550,7 @@ internal class MobileVideoPlaybackStarter( contentId = contentId, message = message, diagnosticsCode = diagnosticsCode, + terminalReason = terminalReason, ) } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/OnDeckItems.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/OnDeckItems.kt index 9d8960dad8..52445a085b 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/OnDeckItems.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/OnDeckItems.kt @@ -1,6 +1,7 @@ package org.prairieserver.prairie.android.ui.screens.player import org.prairieserver.prairie.model.section.ResolvedSection +import org.prairieserver.prairie.model.settings.EpisodeSpoilers /** Existing On Deck projection: ordered, bounded cards with usable landscape art. */ internal fun List.toOnDeckItems(contentId: String, seriesId: String?): List { @@ -33,6 +34,9 @@ internal fun List.toOnDeckItems(contentId: String, seriesId: St artUrl = item.backdropUrl, artThumbhash = item.backdropThumbhash, progressFraction = progress, + imageIsEpisodeStill = item.backdropIsEpisodeStill, + isUnwatchedEpisode = item.type.equals("episode", ignoreCase = true) && + EpisodeSpoilers.isUnwatched(item), ) } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlaybackQualitySheet.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlaybackQualitySheet.kt new file mode 100644 index 0000000000..83ec1f9fbd --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlaybackQualitySheet.kt @@ -0,0 +1,66 @@ +package org.prairieserver.prairie.android.ui.screens.player + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.prairieserver.prairie.model.playback.PlaybackQualityOption +import org.prairieserver.prairie.model.playback.activePlaybackQualityId + +/** + * The [menu] row the committed preference selects, judged against what the + * plan actually delivers, so a stored "1080p" that a bitrate cap held to + * 720p marks the 720p entry. + */ +internal fun PlayerViewModel.PlayerUiState.activeQualityId(menu: List): String? = + activePlaybackQualityId(menu, committedQualityPreference, playbackPlan?.effectiveRecipe) + +/** + * The plan's Quality menu: Auto, then the server's entries in its order, each + * with its bitrate and a check on the active one. A pick re-plans the same + * title on the server. The title's separate files are the Version menu, + * [VersionSelector]. + */ +@Composable +fun PlaybackQualitySheet( + options: List, + activeId: String?, + // A Watch Party keeps every member on the room's file; quality still + // applies to this device's stream. + inRoom: Boolean, + onSelect: (String) -> Unit, + onDismiss: () -> Unit, + tabletopPaneHeight: Dp? = null, +) { + val controller = rememberPlayerMenuController(onDismiss) + + PlayerMenu(controller = controller, tabletopPaneHeight = tabletopPaneHeight) { + PlayerPanelHeader(title = "Quality", onClose = { controller.dismiss() }) + PlayerMenuScrollColumn(horizontalPadding = 12.dp) { + if (inRoom) { + PlayerFootnote( + "Watch Party keeps everyone on the same version. " + + "You can adjust your streaming quality below.", + ) + } + Spacer(Modifier.height(8.dp)) + PlayerGroup { + options.forEachIndexed { index, option -> + PlayerCheckRow( + label = option.name, + detail = option.bitrateLabel, + selected = option.id == activeId, + onClick = { + onSelect(option.id) + controller.dismiss() + }, + separator = index > 0, + ) + } + } + Spacer(Modifier.height(8.dp)) + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlaybackRealtimeController.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlaybackRealtimeController.kt index a1f8744d14..c790428000 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlaybackRealtimeController.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlaybackRealtimeController.kt @@ -5,6 +5,8 @@ import org.prairieserver.prairie.network.PlaybackRealtimeEvent import org.prairieserver.prairie.playback.PlaybackAction import org.prairieserver.prairie.playback.decodeMarkersUpdate import org.prairieserver.prairie.playback.decodePlaybackSubtitleReady +import org.prairieserver.prairie.playback.decodePlaybackSubtitleSyncUpdated +import org.prairieserver.prairie.playback.decodePlaybackSubtitleTimingChanged import org.prairieserver.prairie.playback.decidePlaybackAction import org.prairieserver.prairie.playback.isTransport import kotlinx.coroutines.CancellationException @@ -27,6 +29,12 @@ class PlaybackRealtimeController( private val client: PlaybackRealtimeClient, private val viewModel: PlayerViewModel, private val scope: CoroutineScope, + /** + * Watch Party only: routes a transport command through the room's + * permission decision and says whether the room took it. Stop is not + * transport here; it ends only this device's engagement. + */ + private val roomTransport: ((PlaybackAction) -> Boolean)? = null, ) { private companion object { const val BACKOFF_START_MS = 2_000L @@ -42,7 +50,10 @@ class PlaybackRealtimeController( try { client.connect(sessionId).collect { event -> when (event) { - is PlaybackRealtimeEvent.Opened -> client.sendHello(sessionId) + is PlaybackRealtimeEvent.Opened -> { + client.sendHello(sessionId) + viewModel.reconcileMarkers(sessionId) + } is PlaybackRealtimeEvent.Command -> handleCommand(event) is PlaybackRealtimeEvent.ServerEvent -> handleServerEvent(event) is PlaybackRealtimeEvent.Closed -> { /* fall through to reconnect */ } @@ -64,14 +75,20 @@ class PlaybackRealtimeController( private suspend fun handleCommand(cmd: PlaybackRealtimeEvent.Command) { client.sendAck(sessionId, cmd.commandId) val action = decidePlaybackAction(cmd) - // Watch Together is authoritative for transport — reject (don't apply) - // transport commands while in a room so an admin can't desync members. - val gated = action.isTransport && viewModel.remoteTransportSuppressed - val status = if (action is PlaybackAction.Reject || gated) STATUS_REJECTED else STATUS_COMPLETED + // A Watch Party is authoritative for transport: play, pause, and seek + // ask the room like any other input and never move this player alone. + // Stop still leaves (see PlayerScreen), ending only this engagement. + val roomRouted = roomTransport != null && action.isTransport && action !is PlaybackAction.Stop + val gated = roomTransport == null && action.isTransport && viewModel.remoteTransportSuppressed + val status = when { + action is PlaybackAction.Reject || gated -> STATUS_REJECTED + roomRouted -> if (requireNotNull(roomTransport)(action)) STATUS_COMPLETED else STATUS_REJECTED + else -> STATUS_COMPLETED + } // Send the result BEFORE applying: a Stop tears the screen down (which // cancels this coroutine), so applying first could drop the result. client.sendResult(sessionId, cmd.commandId, status) - if (!gated) applyAction(action) + if (!gated && !roomRouted) applyAction(action) } private fun applyAction(action: PlaybackAction) { @@ -91,10 +108,11 @@ class PlaybackRealtimeController( private fun handleServerEvent(event: PlaybackRealtimeEvent.ServerEvent) { when (event.name) { "subtitle_ready" -> viewModel.applySubtitleReady(decodePlaybackSubtitleReady(event)) - "markers_updated" -> { - val markers = decodeMarkersUpdate(event) - viewModel.applyUpdatedMarkers(markers.intro, markers.credits, markers.recap, markers.preview) - } + "subtitle_timing_changed" -> + viewModel.applySubtitleTimingChanged(decodePlaybackSubtitleTimingChanged(event)) + "subtitle_sync_updated" -> + viewModel.applySubtitleSyncUpdated(decodePlaybackSubtitleSyncUpdated(event)) + "markers_updated" -> viewModel.applyMarkersUpdate(decodeMarkersUpdate(event)) // chapter_thumbnail_ready: no scrubber-thumbnail UI yet → nothing to update. else -> { /* ignore */ } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlaybackStatsSheet.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlaybackStatsSheet.kt index a2fc4b0416..d1389af096 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlaybackStatsSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlaybackStatsSheet.kt @@ -1,29 +1,19 @@ package org.prairieserver.prairie.android.ui.screens.player -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -34,14 +24,12 @@ import org.prairieserver.prairie.playback.stats.PlaybackStatsContext import org.prairieserver.prairie.playback.stats.displayLine import org.prairieserver.prairie.playback.stats.planRows -@OptIn(ExperimentalMaterial3Api::class) @Composable fun PlaybackStatsSheet( isVisible: Boolean, stats: PlayerStatsSnapshot, onDismiss: () -> Unit, - // Gear-submenu back affordance: dismisses this sheet and reopens the - // parent settings sheet (wired in PlayerOverlay). + // Back affordance: hands over to the settings menu (wired in PlayerOverlay). onBack: (() -> Unit)? = null, sessionId: String? = null, playMethod: String? = null, @@ -52,99 +40,57 @@ fun PlaybackStatsSheet( ) { if (!isVisible) return - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val scope = rememberCoroutineScope() - val dismissSheet = { scope.dismissPlayerSheet(sheetState, onDismiss) } + val controller = rememberPlayerMenuController(onDismiss) - LaunchedEffect(isVisible) { - if (isVisible) sheetState.show() - } - - PlayerModalBottomSheet( - onDismissRequest = dismissSheet, - sheetState = sheetState, - tabletopPaneHeight = tabletopPaneHeight, - ) { - Box( - modifier = Modifier - .fillMaxWidth() - // Cap below the top edge + keep content flings from - // dismissing the sheet — see PlayerSheetSupport. - .playerSheetContent(tabletopPaneHeight) - .nestedScroll(PlayerSheetFlingGuard) - .background( - brush = Brush.verticalGradient( - colors = listOf( - Color(0xFF111827).copy(alpha = 0.97f), - Color.Black.copy(alpha = 0.94f), - ), - ), - ), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(start = 20.dp, end = 20.dp, top = 20.dp, bottom = 24.dp), - ) { - PlayerSheetHeader( - title = "Stats for nerds", - onBack = onBack?.let { back -> - { scope.dismissPlayerSheet(sheetState, back) } - }, - onDismiss = dismissSheet, - ) - Spacer(modifier = Modifier.height(6.dp)) - Text( - text = "Current stream", - color = Color.White.copy(alpha = 0.62f), - fontSize = 13.sp, - ) - Spacer(modifier = Modifier.height(18.dp)) - - val planRows = playbackContext.planRows() - val rows = buildList { - sessionId?.takeIf { it.isNotBlank() }?.let { add("Session" to it) } - playMethod?.takeIf { it.isNotBlank() }?.let { add("Play method" to it) } - positionLabel?.takeIf { it.isNotBlank() }?.let { add("Position" to it) } - addAll(stats.mobileStatsRows()) - stats.healthRows().forEach { add(it.label to it.value) } - } - if (planRows.isNotEmpty()) { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - planRows.forEach { row -> StatsBlockRow(label = row.label, value = row.value) } + PlayerMenu(controller = controller, tabletopPaneHeight = tabletopPaneHeight) { + PlayerPanelHeader( + title = "Stats for nerds", + onClose = { controller.dismiss() }, + onBack = onBack?.let { back -> { controller.dismiss(then = back) } }, + backDescription = "Back to settings", + ) + PlayerMenuScrollColumn(horizontalPadding = 12.dp) { + val planRows = playbackContext.planRows() + if (planRows.isNotEmpty()) { + PlayerSectionHeader("Playback plan") + PlayerGroup { + planRows.forEachIndexed { index, row -> + StatsBlockRow(label = row.label, value = row.value, separator = index > 0) } - Spacer(modifier = Modifier.height(16.dp)) } - if (rows.isEmpty() && planRows.isEmpty()) { - Text( - text = "Waiting for player data", - color = Color.White.copy(alpha = 0.66f), - fontSize = 15.sp, - ) + Spacer(Modifier.height(12.dp)) + } + + PlayerSectionHeader("Current stream") + val rows = buildList { + sessionId?.takeIf { it.isNotBlank() }?.let { add("Session" to it) } + playMethod?.takeIf { it.isNotBlank() }?.let { add("Play method" to it) } + positionLabel?.takeIf { it.isNotBlank() }?.let { add("Position" to it) } + addAll(stats.mobileStatsRows()) + stats.healthRows().forEach { add(it.label to it.value) } + } + PlayerGroup { + if (rows.isEmpty()) { + StatsRow(label = "Waiting for player data", value = "", separator = false) } else { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - rows.forEach { (label, value) -> - StatsRow(label = label, value = value) - } + rows.forEachIndexed { index, (label, value) -> + StatsRow(label = label, value = value, separator = index > 0) } } - if (!stats.events.isEmpty) { - Spacer(modifier = Modifier.height(18.dp)) - Text( - text = "Recent player events", - color = Color.White.copy(alpha = 0.62f), - fontSize = 13.sp, - ) - Spacer(modifier = Modifier.height(8.dp)) - val offsetMs = remember(stats.events) { - java.util.TimeZone.getDefault().getOffset(System.currentTimeMillis()).toLong() - } - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + } + + if (!stats.events.isEmpty) { + Spacer(Modifier.height(12.dp)) + PlayerSectionHeader("Recent player events") + val offsetMs = remember(stats.events) { + java.util.TimeZone.getDefault().getOffset(System.currentTimeMillis()).toLong() + } + PlayerGroup { + Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) { stats.events.entries.forEach { event -> Text( text = event.displayLine(offsetMs), - color = Color.White.copy(alpha = 0.86f), + style = PlayerType.RowDetail.copy(color = PlayerChrome.Paper), fontSize = 12.sp, fontFamily = FontFamily.Monospace, ) @@ -152,47 +98,59 @@ fun PlaybackStatsSheet( } } } + Spacer(Modifier.height(12.dp)) } } } /** Label over a wrapping value, for the long plan / stream / track lines. */ @Composable -private fun StatsBlockRow(label: String, value: String) { - Column(modifier = Modifier.fillMaxWidth()) { +private fun StatsBlockRow(label: String, value: String, separator: Boolean) { + Column( + modifier = Modifier + .fillMaxWidth() + .playerGroupSeparator(separator) + .heightIn(min = 40.dp) + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { Text( text = label, - color = Color.White.copy(alpha = 0.62f), - fontSize = 13.sp, + style = PlayerType.RowDetail.copy(fontSize = PlayerType.Value.fontSize), ) Text( text = value, - color = Color.White, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, + style = PlayerType.Value.copy(color = PlayerChrome.Paper), ) } } @Composable -private fun StatsRow(label: String, value: String) { - Row(modifier = Modifier.fillMaxWidth()) { +private fun StatsRow(label: String, value: String, separator: Boolean) { + Row( + modifier = Modifier + .fillMaxWidth() + .playerGroupSeparator(separator) + .heightIn(min = 40.dp) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { Text( text = label, - color = Color.White.copy(alpha = 0.62f), - fontSize = 14.sp, - modifier = Modifier.weight(1f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = value, - color = Color.White, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, + style = PlayerType.RowDetail.copy(fontSize = PlayerType.Value.fontSize), maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), ) + Column(modifier = Modifier.weight(1.4f)) { + Text( + text = value, + style = PlayerType.Value.copy(color = PlayerChrome.Paper), + textAlign = TextAlign.End, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerChrome.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerChrome.kt new file mode 100644 index 0000000000..218d800caa --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerChrome.kt @@ -0,0 +1,875 @@ +package org.prairieserver.prairie.android.ui.screens.player + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +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.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import org.prairieserver.prairie.android.ui.screens.settings.settingsSwitchColors +import org.prairieserver.prairie.android.ui.theme.PrairieDestructive +import org.prairieserver.prairie.android.ui.theme.PrairieInterFamily + +/** + * The video player's visual language, shared by the controls and every player + * menu. Monochrome like the Apple clients: smoked panels over the picture, + * Paper for whatever is selected or focused, Inter with tabular figures for + * anything that counts time. There is no live blur on Android, so "glass" is a + * dark wash at a fixed opacity — the same fallback iOS uses over video on + * devices that cannot afford to re-blur every frame. + */ +internal object PlayerChrome { + val Paper = Color(0xFFEDEDED) + val Ink = Color(0xFF0B0B0C) + val Graphite = Paper.copy(alpha = 0.60f) + val Faint = Paper.copy(alpha = 0.38f) + + /** Docked panels and sheets over the picture. */ + val Smoke = Color(0xFF131417).copy(alpha = 0.955f) + + /** Resting fill of a disc or pill floating directly on the video. */ + val Disc = Color(0xFF16171A).copy(alpha = 0.55f) + val DiscStroke = Color.White.copy(alpha = 0.13f) + val PanelStroke = Color.White.copy(alpha = 0.09f) + + /** Wash behind a selected row, and the resting fill of in-panel controls. */ + val Raised = Color.White.copy(alpha = 0.075f) + val Group = Color.White.copy(alpha = 0.05f) + val GroupSeparator = Color.White.copy(alpha = 0.06f) + val Chip = Color.White.copy(alpha = 0.10f) + + /** The one destructive tint, shared with the rest of the app. */ + val Stop = PrairieDestructive + + val DiscSize = 40.dp + val TouchSize = 48.dp + val PanelRadius = 24.dp + val PanelWidth = 372.dp +} + +/** Type for the player chrome. Inter throughout; timecodes use tabular figures. */ +internal object PlayerType { + private val base = TextStyle(fontFamily = PrairieInterFamily, color = PlayerChrome.Paper) + + val Eyebrow = base.copy( + fontSize = 10.5.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.09.em, + color = PlayerChrome.Graphite, + ) + val HudTitle = base.copy(fontSize = 15.5.sp, fontWeight = FontWeight.SemiBold, letterSpacing = (-0.01).em) + val Time = base.copy(fontSize = 12.5.sp, fontWeight = FontWeight.SemiBold, fontFeatureSettings = "tnum") + val PillLabel = base.copy(fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + val PillValue = base.copy(fontSize = 13.sp, fontWeight = FontWeight.Medium, color = PlayerChrome.Graphite) + val PanelTitle = base.copy(fontSize = 17.sp, fontWeight = FontWeight.SemiBold, letterSpacing = (-0.01).em) + val RowTitle = base.copy(fontSize = 14.5.sp, fontWeight = FontWeight.Medium, lineHeight = 19.sp) + val RowDetail = base.copy(fontSize = 11.5.sp, color = PlayerChrome.Graphite, lineHeight = 15.sp) + val Value = base.copy(fontSize = 13.sp, color = PlayerChrome.Graphite, fontFeatureSettings = "tnum") + val Section = base.copy( + fontSize = 10.5.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.1.em, + color = PlayerChrome.Graphite, + ) + val Segment = base.copy(fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + val SmallButton = base.copy(fontSize = 12.5.sp, fontWeight = FontWeight.SemiBold) + val Chip = base.copy( + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.07.em, + color = PlayerChrome.Paper.copy(alpha = 0.78f), + ) + val FieldLabel = base.copy(fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = PlayerChrome.Graphite) + val Footnote = base.copy(fontSize = 12.sp, color = PlayerChrome.Graphite, lineHeight = 16.sp) + val BubbleTime = base.copy( + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + letterSpacing = (-0.01).em, + fontFeatureSettings = "tnum", + ) + val BubbleDetail = base.copy( + fontSize = 11.5.sp, + fontWeight = FontWeight.SemiBold, + color = PlayerChrome.Paper.copy(alpha = 0.62f), + fontFeatureSettings = "tnum", + ) +} + +// --------------------------------------------------------------------------- +// Controls that sit directly on the picture +// --------------------------------------------------------------------------- + +/** + * A smoked disc on the video. The visual disc is [size]; the touch target is + * never smaller than 48dp, so a row of discs keeps Android's minimum target + * without the discs themselves growing. + */ +@Composable +internal fun PlayerDisc( + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + size: Dp = PlayerChrome.DiscSize, + enabled: Boolean = true, + fill: Color = PlayerChrome.Disc, + contentColor: Color = PlayerChrome.Paper, + stroke: Color = PlayerChrome.DiscStroke, + content: @Composable () -> Unit, +) { + Box( + modifier = modifier + .minimumInteractiveComponentSize() + .size(size) + .clip(CircleShape) + .background(fill) + .border(1.dp, stroke, CircleShape) + // The disc is the control a screen reader names; its glyph says nothing. + .semantics { this.contentDescription = contentDescription } + .clickable( + enabled = enabled, + role = Role.Button, + onClickLabel = contentDescription, + onClick = onClick, + ) + .alpha(if (enabled) 1f else 0.32f), + contentAlignment = Alignment.Center, + ) { + Box(modifier = Modifier.clearAndSetSemantics {}, contentAlignment = Alignment.Center) { + CompositionLocalProvider(LocalContentColor provides contentColor) { + content() + } + } + } +} + +@Composable +internal fun PlayerIconDisc( + icon: ImageVector, + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + size: Dp = PlayerChrome.DiscSize, + iconSize: Dp = 22.dp, + enabled: Boolean = true, +) { + PlayerDisc( + contentDescription = contentDescription, + onClick = onClick, + modifier = modifier, + size = size, + enabled = enabled, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = PlayerChrome.Paper, + modifier = Modifier.size(iconSize), + ) + } +} + +/** + * A labeled control on the bottom row: icon, what it opens, and what it is set + * to now ("Chapters · Scene 10"). Compact mode keeps only the icon for narrow + * windows, the way iOS falls back through `ViewThatFits`. + */ +@Composable +internal fun PlayerActionPill( + icon: ImageVector, + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + value: String? = null, + compact: Boolean = false, + enabled: Boolean = true, +) { + val shape = RoundedCornerShape(18.dp) + Row( + modifier = modifier + .minimumInteractiveComponentSize() + .height(36.dp) + .clip(shape) + .background(PlayerChrome.Disc) + .border(1.dp, PlayerChrome.DiscStroke, shape) + .clickable(enabled = enabled, role = Role.Button, onClickLabel = label, onClick = onClick) + .alpha(if (enabled) 1f else 0.32f) + .padding(start = if (compact) 8.5.dp else 11.dp, end = if (compact) 8.5.dp else 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(7.dp), + ) { + Icon( + imageVector = icon, + contentDescription = if (compact) label else null, + tint = PlayerChrome.Paper, + modifier = Modifier.size(19.dp), + ) + if (!compact) { + Text(text = label, style = PlayerType.PillLabel, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (!value.isNullOrBlank()) { + // The value gives way first when the pill is squeezed. + Text( + text = value, + style = PlayerType.PillValue, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f, fill = false) + .widthIn(max = 160.dp), + ) + } + } + } +} + +// --------------------------------------------------------------------------- +// Panel content: shared by the docked landscape panel and the portrait sheet +// --------------------------------------------------------------------------- + +/** Title row of a player menu, with an optional back chevron and a close disc. */ +@Composable +internal fun PlayerPanelHeader( + title: String, + onClose: () -> Unit, + onBack: (() -> Unit)? = null, + backDescription: String = "Back", +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = if (onBack != null) 4.dp else 18.dp, end = 6.dp, top = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (onBack != null) { + Box( + modifier = Modifier + .size(PlayerChrome.TouchSize) + .clip(CircleShape) + .clickable(onClickLabel = backDescription, role = Role.Button, onClick = onBack), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowLeft, + contentDescription = backDescription, + tint = PlayerChrome.Paper, + modifier = Modifier.size(26.dp), + ) + } + } + Text( + text = title, + style = PlayerType.PanelTitle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + PlayerDisc( + contentDescription = "Close", + onClick = onClose, + size = 32.dp, + fill = Color.White.copy(alpha = 0.08f), + stroke = Color.Transparent, + ) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = null, + tint = PlayerChrome.Paper, + modifier = Modifier.size(20.dp), + ) + } + } +} + +internal data class PlayerSegment(val label: String, val count: Int? = null) + +/** + * Pill segmented control. The selected segment inverts to Paper, the same + * rule the TV uses for focus. + */ +@Composable +internal fun PlayerSegmentedControl( + segments: List, + selectedIndex: Int, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(19.dp)) + .background(PlayerChrome.Raised) + .padding(3.dp), + ) { + segments.forEachIndexed { index, segment -> + val selected = index == selectedIndex + Row( + modifier = Modifier + .weight(1f) + .height(32.dp) + .clip(RoundedCornerShape(16.dp)) + .background(if (selected) PlayerChrome.Paper else Color.Transparent) + .selectable(selected = selected, role = Role.Tab, onClick = { onSelect(index) }), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + val color = if (selected) PlayerChrome.Ink else PlayerChrome.Graphite + Text(text = segment.label, style = PlayerType.Segment.copy(color = color), maxLines = 1) + segment.count?.let { count -> + Spacer(Modifier.width(6.dp)) + Text( + text = count.toString(), + style = PlayerType.Segment.copy( + color = color.copy(alpha = color.alpha * 0.55f), + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + ), + ) + } + } + } + } +} + +/** + * Compact segmented control for value choices inside a panel (subtitle size, + * background, position). Selection is a raised wash rather than an inversion + * so a page of them does not turn into a wall of white. + */ +@Composable +internal fun PlayerChoiceSegments( + options: List, + selectedIndex: Int, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(PlayerChrome.Raised) + .padding(3.dp), + ) { + val segmentShape = RoundedCornerShape(9.dp) + options.forEachIndexed { index, label -> + val selected = index == selectedIndex + Box( + modifier = Modifier + .weight(1f) + .height(32.dp) + .clip(segmentShape) + .then( + if (selected) { + Modifier + .background(Color.White.copy(alpha = 0.16f)) + .border(1.dp, Color.White.copy(alpha = 0.12f), segmentShape) + } else { + Modifier + }, + ) + .selectable(selected = selected, role = Role.RadioButton, onClick = { onSelect(index) }), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + style = PlayerType.SmallButton.copy( + color = if (selected) PlayerChrome.Paper else PlayerChrome.Graphite, + ), + maxLines = 1, + ) + } + } + } +} + +/** "SDH", "FORCED": short facts about a track that belong next to its name. */ +@Composable +internal fun PlayerChip(text: String, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .clip(RoundedCornerShape(4.dp)) + .background(PlayerChrome.Chip) + .padding(horizontal = 5.dp, vertical = 1.5.dp), + ) { + Text(text = text.uppercase(), style = PlayerType.Chip, maxLines = 1) + } +} + +/** + * One selectable track or option: title (with chips), an optional detail line, + * and a trailing check when selected. The selected row also gets a soft wash. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun PlayerTrackRow( + title: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + detail: String? = null, + chips: List = emptyList(), + enabled: Boolean = true, + leading: (@Composable () -> Unit)? = null, +) { + // A long list opens with the current choice on screen, not row one, and + // with the rows after it: revealing the row alone parked it under the + // list's bottom fade. + val revealRequester = remember { BringIntoViewRequester() } + var rowSize by remember { mutableStateOf(IntSize.Zero) } + val revealContextPx = with(LocalDensity.current) { 96.dp.toPx() } + if (selected && rowSize != IntSize.Zero) { + LaunchedEffect(Unit) { + revealRequester.bringIntoView( + Rect(0f, 0f, rowSize.width.toFloat(), rowSize.height + revealContextPx), + ) + } + } + Row( + modifier = modifier + .onSizeChanged { rowSize = it } + .bringIntoViewRequester(revealRequester) + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(if (selected) PlayerChrome.Raised else Color.Transparent) + .selectable(selected = selected, enabled = enabled, role = Role.RadioButton, onClick = onClick) + .alpha(if (enabled) 1f else 0.4f) + .heightIn(min = 46.dp) + .padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + leading?.invoke() + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = title, + style = PlayerType.RowTitle.copy( + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, + ), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + chips.forEach { chip -> + Spacer(Modifier.width(6.dp)) + PlayerChip(chip) + } + } + if (!detail.isNullOrBlank()) { + Text( + text = detail, + style = PlayerType.RowDetail, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp), + ) + } + } + if (selected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = "Selected", + tint = PlayerChrome.Paper, + modifier = Modifier.size(21.dp), + ) + } + } +} + +/** Small caps header above a group of rows. */ +@Composable +internal fun PlayerSectionHeader(text: String, modifier: Modifier = Modifier) { + Text( + text = text.uppercase(), + style = PlayerType.Section, + modifier = modifier.padding(start = 12.dp, end = 12.dp, top = 14.dp, bottom = 6.dp), + ) +} + +/** An inset group of rows with hairlines between them. */ +@Composable +internal fun PlayerGroup( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(PlayerChrome.Group), + content = content, + ) +} + +/** Draws the group hairline above every row except the first. */ +internal fun Modifier.playerGroupSeparator(show: Boolean): Modifier = + if (show) { + drawBehind { + drawLine( + color = PlayerChrome.GroupSeparator, + start = Offset(0f, 0f), + end = Offset(size.width, 0f), + strokeWidth = 1.dp.toPx(), + ) + } + } else { + this + } + +@Composable +private fun PlayerGroupRow( + modifier: Modifier, + separator: Boolean, + content: @Composable RowScope.() -> Unit, +) { + Row( + modifier = modifier + .fillMaxWidth() + .playerGroupSeparator(separator) + .heightIn(min = 44.dp) + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + content = content, + ) +} + +/** Label · value · chevron. Values line up in one right-aligned column. */ +@Composable +internal fun PlayerValueRow( + label: String, + value: String?, + onClick: () -> Unit, + separator: Boolean = false, + enabled: Boolean = true, + detail: String? = null, +) { + PlayerGroupRow( + modifier = Modifier + .clickable(enabled = enabled, onClick = onClick) + .alpha(if (enabled) 1f else 0.4f), + separator = separator, + ) { + // The label keeps its natural width; the value takes the rest of the + // row and ends at the chevron, so every value lines up on one edge. + Column(modifier = if (value.isNullOrBlank()) Modifier.weight(1f) else Modifier) { + Text(text = label, style = PlayerType.RowTitle, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (!detail.isNullOrBlank()) { + Text(text = detail, style = PlayerType.RowDetail, modifier = Modifier.padding(top = 1.dp)) + } + } + if (!value.isNullOrBlank()) { + Text( + text = value, + style = PlayerType.Value, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.End, + modifier = Modifier.weight(1f), + ) + } + PlayerRowChevron() + } +} + +@Composable +private fun PlayerRowChevron() { + Icon( + imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight, + contentDescription = null, + tint = PlayerChrome.Faint, + modifier = Modifier + .size(20.dp) + .offset(x = 4.dp), + ) +} + +/** A switch row matching the app's Settings switch (green track, white thumb). */ +@Composable +internal fun PlayerSwitchRow( + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + separator: Boolean = false, + enabled: Boolean = true, + detail: String? = null, +) { + PlayerGroupRow( + modifier = Modifier + .toggleable(value = checked, enabled = enabled, role = Role.Switch, onValueChange = onCheckedChange) + .alpha(if (enabled) 1f else 0.4f), + separator = separator, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(text = label, style = PlayerType.RowTitle, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (!detail.isNullOrBlank()) { + Text(text = detail, style = PlayerType.RowDetail, modifier = Modifier.padding(top = 1.dp)) + } + } + Switch( + checked = checked, + onCheckedChange = null, + enabled = enabled, + thumbContent = {}, + colors = settingsSwitchColors(), + ) + } +} + +/** A choice inside a group: label, optional detail, trailing check when chosen. */ +@Composable +internal fun PlayerCheckRow( + label: String, + selected: Boolean, + onClick: () -> Unit, + separator: Boolean = false, + detail: String? = null, + labelColor: Color = PlayerChrome.Paper, +) { + PlayerGroupRow( + modifier = Modifier.selectable(selected = selected, role = Role.RadioButton, onClick = onClick), + separator = separator, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + style = PlayerType.RowTitle.copy( + color = labelColor, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, + fontFeatureSettings = "tnum", + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!detail.isNullOrBlank()) { + Text(text = detail, style = PlayerType.RowDetail, modifier = Modifier.padding(top = 1.dp)) + } + } + if (selected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = "Selected", + tint = PlayerChrome.Paper, + modifier = Modifier.size(20.dp), + ) + } + } +} + +/** Flat button for a panel footer: "Find more", "Translate", "Timing". */ +@Composable +internal fun PlayerFooterButton( + icon: ImageVector, + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + Row( + modifier = modifier + .heightIn(min = 40.dp) + .clip(RoundedCornerShape(20.dp)) + .background(PlayerChrome.Raised) + .clickable(enabled = enabled, role = Role.Button, onClick = onClick) + .alpha(if (enabled) 1f else 0.4f) + .padding(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(imageVector = icon, contentDescription = null, tint = PlayerChrome.Paper, modifier = Modifier.size(18.dp)) + Text(text = label, style = PlayerType.SmallButton, maxLines = 1) + } +} + +/** "Size ........ Large": the label over a control, with its current value. */ +@Composable +internal fun PlayerFieldLabel(label: String, value: String?, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(start = 4.dp, end = 4.dp, top = 14.dp, bottom = 7.dp), + ) { + Text(text = label, style = PlayerType.FieldLabel, modifier = Modifier.weight(1f)) + if (!value.isNullOrBlank()) { + Text(text = value, style = PlayerType.FieldLabel.copy(color = PlayerChrome.Paper)) + } + } +} + +/** Color choices as swatches; the chosen one gets a Paper ring. */ +@Composable +internal fun PlayerSwatchRow( + colors: List>, + selectedIndex: Int, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + // Nine swatches need about 330dp; a narrow docked panel has less, so the + // row scrolls rather than clip the last colors. + Row( + modifier = modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(3.dp), + ) { + colors.forEachIndexed { index, (color, name) -> + val selected = index == selectedIndex + Box( + modifier = Modifier + .size(34.dp) + .clip(CircleShape) + .selectable(selected = selected, role = Role.RadioButton, onClick = { onSelect(index) }) + .then( + if (selected) Modifier.border(2.dp, PlayerChrome.Paper, CircleShape) else Modifier, + ) + .padding(4.dp) + .clip(CircleShape) + .background(color) + .border(1.dp, Color.White.copy(alpha = 0.18f), CircleShape) + .semantics { contentDescription = name }, + ) + } + } +} + +/** Explanatory text under a group. */ +@Composable +internal fun PlayerFootnote(text: String, modifier: Modifier = Modifier) { + Text( + text = text, + style = PlayerType.Footnote, + modifier = modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp), + ) +} + +/** "− +0.25 s +" stepper used for delays. */ +@Composable +internal fun PlayerStepperRow( + label: String, + value: String, + onDecrease: () -> Unit, + onIncrease: () -> Unit, + separator: Boolean = false, + detail: String? = null, + enabled: Boolean = true, +) { + PlayerGroupRow(modifier = Modifier.alpha(if (enabled) 1f else 0.4f), separator = separator) { + Column(modifier = Modifier.weight(1f)) { + Text(text = label, style = PlayerType.RowTitle, maxLines = 1) + if (!detail.isNullOrBlank()) { + Text(text = detail, style = PlayerType.RowDetail, modifier = Modifier.padding(top = 1.dp)) + } + } + StepperButton(symbol = "−", description = "Decrease $label", enabled = enabled, onClick = onDecrease) + Text( + text = value, + style = PlayerType.Value.copy(color = PlayerChrome.Paper, fontWeight = FontWeight.SemiBold), + textAlign = TextAlign.Center, + modifier = Modifier.width(72.dp), + ) + StepperButton(symbol = "+", description = "Increase $label", enabled = enabled, onClick = onIncrease) + } +} + +@Composable +private fun StepperButton(symbol: String, description: String, enabled: Boolean, onClick: () -> Unit) { + PlayerDisc( + contentDescription = description, + onClick = onClick, + size = 34.dp, + enabled = enabled, + fill = Color.White.copy(alpha = 0.09f), + stroke = Color.Transparent, + ) { + Text(text = symbol, style = PlayerType.PanelTitle.copy(fontWeight = FontWeight.Medium)) + } +} + +internal val PlayerPanelBorder = BorderStroke(1.dp, PlayerChrome.PanelStroke) + +/** An action inside a group: leading icon and a label, no value. */ +@Composable +internal fun PlayerActionRow( + icon: ImageVector, + label: String, + onClick: () -> Unit, + separator: Boolean = false, + enabled: Boolean = true, + tint: Color = PlayerChrome.Paper, +) { + PlayerGroupRow( + modifier = Modifier + .clickable(enabled = enabled, role = Role.Button, onClick = onClick) + .alpha(if (enabled) 1f else 0.4f), + separator = separator, + ) { + Icon(imageVector = icon, contentDescription = null, tint = tint, modifier = Modifier.size(20.dp)) + Text( + text = label, + style = PlayerType.RowTitle.copy(color = tint), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + PlayerRowChevron() + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerControls.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerControls.kt index 59c805c105..e4f15e3921 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerControls.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerControls.kt @@ -6,7 +6,10 @@ import android.media.AudioManager import android.os.Handler import android.os.Looper import android.provider.Settings +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -19,6 +22,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size @@ -27,26 +31,25 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft -import androidx.compose.material.icons.automirrored.filled.List -import androidx.compose.material.icons.automirrored.filled.VolumeUp -import androidx.compose.material.icons.filled.Brightness6 -import androidx.compose.material.icons.filled.HighQuality -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.Pause -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.ScreenLockRotation -import androidx.compose.material.icons.filled.ScreenRotation -import androidx.compose.material.icons.filled.SkipNext -import androidx.compose.material.icons.filled.Speed -import androidx.compose.material.icons.automirrored.filled.SpeakerNotes -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.FormatListBulleted +import androidx.compose.material.icons.automirrored.rounded.VolumeUp +import androidx.compose.material.icons.rounded.Brightness6 +import androidx.compose.material.icons.rounded.MoreHoriz +import androidx.compose.material.icons.rounded.Pause +import androidx.compose.material.icons.rounded.PictureInPictureAlt +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.ScreenLockRotation +import androidx.compose.material.icons.rounded.ScreenRotation +import androidx.compose.material.icons.rounded.SkipNext +import androidx.compose.material.icons.rounded.Speed +import androidx.compose.material.icons.rounded.Subtitles +import androidx.compose.material.icons.rounded.Tune import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue @@ -56,33 +59,37 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection -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 org.prairieserver.prairie.android.ui.components.SeekIntervalIcon -import org.prairieserver.prairie.android.ui.layout.useCompactPlayerToolbar /** - * Transport controls overlay for the video player. Top-bar icon layout - * mirrors iOS phone's `MobilePlayerControls` (lock | chapters | tracks | - * settings) — see `iosApp/Screens/Player/iOS/MobilePlayerControls.swift:73`. + * Transport controls over the video, after iOS `MobilePlayerControls`: * - * Fullscreen uses the existing three-row HUD; tabletop groups the same shared - * controls into the lower pane, keeps brightness available at every size, and - * adds volume, speed, and next-episode shortcuts when the pane is large enough. + * - Top: back, eyebrow + title, then utility discs (Picture in Picture, Cast, + * rotation lock). + * - Center, on the true center of the screen: skip back, a Paper play disc, + * skip forward. + * - Bottom: elapsed and remaining time, the seek bar, and a row of labeled + * actions that also say what they are set to (Audio & Subtitles · English, + * Chapters · Scene 10, Quality · 1080p), with the full settings menu behind ⋯. * - * Core rows: - * - Top: Back (chevron) · title · orientation lock toggle · chapters (when - * present) · tracks (audio + subs) · quality (when multiple versions) · - * settings (gear) - * - Center: Skip back · play/pause · skip forward - * - Bottom: Seek bar with timestamps + * While the seek bar is being scrubbed everything except the bar fades to 12% + * so the bubble and the picture carry the moment. + * + * Tabletop posture groups the same pieces into the lower pane and adds the + * brightness, volume, speed, and next-episode shortcuts when they fit. */ @Composable fun PlayerControls( @@ -95,9 +102,10 @@ fun PlayerControls( bufferedPosition: Double, hasChapters: Boolean, hasTracks: Boolean, - // Quality lives on the HUD (chapters + tracks + quality product decision); - // hidden when the item has a single file version. - hasMultipleVersions: Boolean, + // The plan's Quality menu; hidden only when there is no playback plan + // (offline downloads). Version, for a title with several files, lives in + // the settings menu behind ⋯. + hasQualityMenu: Boolean, chapters: List = emptyList(), intro: org.prairieserver.prairie.model.catalog.TimeRange? = null, trickplay: org.prairieserver.prairie.playback.TrickplayInfo? = null, @@ -108,9 +116,11 @@ fun PlayerControls( orientationLockSupported: Boolean = true, tabletopMode: Boolean = false, playbackSpeed: Double = 1.0, + // False in a Watch Party, where speed is a session-only 1x. + playbackSpeedEnabled: Boolean = true, nextEpisode: PlayerViewModel.NextEpisodeInfo? = null, brightnessFraction: Float = 0.5f, - // Watch Together guest gate: when false the scrubber + skip buttons are + // Watch Party guest gate: when false the scrubber + skip buttons are // inert and dimmed (seek is host-only, so disabled for all guests). // Defaults true for solo playback. seekEnabled: Boolean = true, @@ -121,6 +131,14 @@ fun PlayerControls( // revision-9 server, the legacy fixed pair otherwise). skipBackSeconds: Int = PlayerViewModel.LEGACY_VIDEO_SEEK_INTERVALS.backSeconds, skipForwardSeconds: Int = PlayerViewModel.LEGACY_VIDEO_SEEK_INTERVALS.forwardSeconds, + // "MOVIE · 2026" or "SEVERANCE · S2:E4" above the title. + eyebrow: String = subtitle, + // Current values shown on the action pills. + tracksValue: String? = null, + chapterValue: String? = null, + qualityValue: String? = null, + pictureInPictureAvailable: Boolean = false, + onEnterPictureInPicture: () -> Unit = {}, onBack: () -> Unit, onPlayPause: () -> Unit, onSeek: (Double) -> Unit, @@ -134,19 +152,31 @@ fun PlayerControls( onSetPlaybackSpeed: (Double) -> Unit = {}, onPlayNextEpisode: () -> Unit = {}, onSetBrightness: (Float) -> Unit = {}, + // True while the seek bar is being dragged, so the auto-hide can hold off. + onScrubbingChange: (Boolean) -> Unit = {}, // Google Cast (Chromecast) button — sits in the top bar alongside the other // controls. Provided by PlayerScreen; empty by default so this stateless - // composable stays test-friendly and decoupled from the Cast SDK. - castSlot: @Composable () -> Unit = {}, + // composable stays test-friendly and decoupled from the Cast SDK. The + // modifier dresses it as a player disc. + castSlot: @Composable (Modifier) -> Unit = {}, modifier: Modifier = Modifier, ) { - // iOS dims the entire screen with a flat `Color.black.opacity(0.4)` - // backdrop (MobilePlayerControls) — no top/bottom gradients. The VStack - // sits inside the dim with iOS's default 16pt edge padding. + var isScrubbing by remember { mutableStateOf(false) } + val chromeAlpha by animateFloatAsState( + targetValue = if (isScrubbing) 0.12f else 1f, + animationSpec = tween(180), + label = "playerChromeAlpha", + ) + val scrimBoost by animateFloatAsState( + targetValue = if (isScrubbing) 0.18f else 0f, + animationSpec = tween(180), + label = "playerScrimBoost", + ) + Box( modifier = modifier .fillMaxSize() - .background(Color.Black.copy(alpha = 0.4f)), + .playerScrim(boost = scrimBoost), ) { val contentModifier = if (tabletopMode) { Modifier @@ -155,7 +185,7 @@ fun PlayerControls( // bar inset here would create a fake gap below the hinge. Only // reserve the real bottom navigation/gesture inset. .windowInsetsPadding(WindowInsets.navigationBars) - .padding(horizontal = 24.dp, vertical = 12.dp) + .padding(horizontal = 20.dp, vertical = 10.dp) } else { // In landscape the safe-drawing insets are lopsided (camera cutout // on one edge, nothing on the other), so padding by them directly @@ -183,28 +213,24 @@ fun PlayerControls( Modifier .fillMaxSize() .padding(horizontal = horizontalInset) - .padding(16.dp) + .padding(horizontal = 18.dp, vertical = 6.dp) } - val toolbar: @Composable () -> Unit = { - PlayerToolbar( + val topBar: @Composable () -> Unit = { + PlayerTopBar( + eyebrow = eyebrow, title = title, - subtitle = subtitle, isOrientationLocked = isOrientationLocked, orientationLockSupported = orientationLockSupported, - hasChapters = hasChapters, - hasTracks = hasTracks, - hasMultipleVersions = hasMultipleVersions, + pictureInPictureAvailable = pictureInPictureAvailable, onBack = onBack, + onEnterPictureInPicture = onEnterPictureInPicture, onToggleOrientationLock = onToggleOrientationLock, - onOpenChapters = onOpenChapters, - onOpenTracks = onOpenTracks, - onOpenQuality = onOpenQuality, - onOpenSettings = onOpenSettings, castSlot = castSlot, + modifier = Modifier.alpha(chromeAlpha), ) } - val transportControls: @Composable () -> Unit = { + val transport: @Composable () -> Unit = { PlayerTransportControls( isPlaying = isPlaying, isPaused = isPaused, @@ -215,22 +241,42 @@ fun PlayerControls( onPlayPause = onPlayPause, onSkipForward = onSkipForward, onSkipBackward = onSkipBackward, + modifier = Modifier.alpha(chromeAlpha), ) } - val progressBar: @Composable () -> Unit = { - PlayerProgressBar( - position = position, - duration = duration, - bufferedPosition = bufferedPosition, - onSeek = onSeek, - enabled = seekEnabled, - chapters = chapters, - intro = intro, - trickplay = trickplay, - credits = credits, - recap = recap, - preview = preview, - ) + val bottom: @Composable () -> Unit = { + Column(modifier = Modifier.padding(horizontal = 4.dp)) { + PlayerProgressBar( + position = position, + duration = duration, + bufferedPosition = bufferedPosition, + onSeek = onSeek, + enabled = seekEnabled, + chapters = chapters, + intro = intro, + trickplay = trickplay, + credits = credits, + recap = recap, + preview = preview, + onScrubbingChange = { + isScrubbing = it + onScrubbingChange(it) + }, + ) + PlayerActionPillRow( + hasTracks = hasTracks, + hasChapters = hasChapters, + hasQualityMenu = hasQualityMenu, + tracksValue = tracksValue, + chapterValue = chapterValue, + qualityValue = qualityValue, + onOpenTracks = onOpenTracks, + onOpenChapters = onOpenChapters, + onOpenQuality = onOpenQuality, + onOpenSettings = onOpenSettings, + modifier = Modifier.alpha(chromeAlpha), + ) + } } if (tabletopMode) { @@ -243,11 +289,12 @@ fun PlayerControls( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.SpaceBetween, ) { - toolbar() - transportControls() - progressBar() + topBar() + transport() + bottom() TabletopUtilityRow( playbackSpeed = playbackSpeed, + playbackSpeedEnabled = playbackSpeedEnabled, nextEpisode = nextEpisode, compact = !showFullUtilityRow, brightnessFraction = brightnessFraction, @@ -258,101 +305,118 @@ fun PlayerControls( } } } else { - // The toolbar and progress bar respect the safe-drawing insets, but - // those insets are asymmetric in landscape (cutout on one side, - // navigation bar on the other), so a transport row inside the same - // padded column lands visibly off-center. Anchor the transport - // cluster to the true center of the overlay instead. Column(modifier = contentModifier) { - toolbar() + topBar() Spacer(modifier = Modifier.weight(1f)) - progressBar() + bottom() } + // The insets are asymmetric in landscape, so a transport row inside + // the padded column lands off-centre. Anchor it to the true centre. Box( modifier = Modifier.align(Alignment.Center), contentAlignment = Alignment.Center, ) { - transportControls() + transport() } } } } +/** + * Gradients instead of a flat dim: dark enough at the top and bottom edges for + * the chrome to read over any frame, while a paused picture stays visible in + * the middle. [boost] adds an even dim while scrubbing. + */ +private fun Modifier.playerScrim(boost: Float): Modifier = drawBehind { + drawRect(Color.Black.copy(alpha = 0.12f + boost)) + val top = 116.dp.toPx() + drawRect( + brush = Brush.verticalGradient(0f to Color.Black.copy(alpha = 0.6f), 1f to Color.Transparent, endY = top), + size = Size(size.width, top), + ) + val bottom = 200.dp.toPx() + drawRect( + brush = Brush.verticalGradient( + 0f to Color.Transparent, + 1f to Color.Black.copy(alpha = 0.78f), + startY = size.height - bottom, + endY = size.height, + ), + topLeft = Offset(0f, size.height - bottom), + size = Size(size.width, bottom), + ) +} + @Composable -private fun PlayerToolbar( +private fun PlayerTopBar( + eyebrow: String, title: String, - subtitle: String, isOrientationLocked: Boolean, orientationLockSupported: Boolean, - hasChapters: Boolean, - hasTracks: Boolean, - hasMultipleVersions: Boolean, + pictureInPictureAvailable: Boolean, onBack: () -> Unit, + onEnterPictureInPicture: () -> Unit, onToggleOrientationLock: () -> Unit, - onOpenChapters: () -> Unit, - onOpenTracks: () -> Unit, - onOpenQuality: () -> Unit, - onOpenSettings: () -> Unit, - castSlot: @Composable () -> Unit, + castSlot: @Composable (Modifier) -> Unit, + modifier: Modifier = Modifier, ) { - BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { - val trailingActionCount = 3 + - (if (orientationLockSupported) 1 else 0) + - (if (hasChapters) 1 else 0) + - (if (hasMultipleVersions) 1 else 0) - val compact = useCompactPlayerToolbar( - availableWidthDp = maxWidth.value, - trailingActionCount = trailingActionCount, + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + PlayerIconDisc( + icon = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = "Back", + onClick = onBack, ) - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + Column( + modifier = Modifier + .weight(1f) + .padding(start = 8.dp), ) { - ControlButton( - icon = Icons.AutoMirrored.Filled.KeyboardArrowLeft, - contentDescription = "Back", - onClick = onBack, - ) - PlayerToolbarTitle( - title = title, - subtitle = subtitle, - modifier = Modifier.weight(1f), - ) - if (compact) { - castSlot() - PlayerToolbarOverflow( - isOrientationLocked = isOrientationLocked, - orientationLockSupported = orientationLockSupported, - hasChapters = hasChapters, - hasTracks = hasTracks, - hasMultipleVersions = hasMultipleVersions, - onToggleOrientationLock = onToggleOrientationLock, - onOpenChapters = onOpenChapters, - onOpenTracks = onOpenTracks, - onOpenQuality = onOpenQuality, - onOpenSettings = onOpenSettings, - ) - } else { - PlayerToolbarActions( - isOrientationLocked = isOrientationLocked, - orientationLockSupported = orientationLockSupported, - hasChapters = hasChapters, - hasTracks = hasTracks, - hasMultipleVersions = hasMultipleVersions, - onToggleOrientationLock = onToggleOrientationLock, - onOpenChapters = onOpenChapters, - onOpenTracks = onOpenTracks, - onOpenQuality = onOpenQuality, - onOpenSettings = onOpenSettings, - castSlot = castSlot, + if (eyebrow.isNotBlank()) { + Text( + text = eyebrow.uppercase(), + style = PlayerType.Eyebrow, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } + Text( + text = title, + style = PlayerType.HudTitle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 1.dp), + ) + } + if (pictureInPictureAvailable) { + PlayerIconDisc( + icon = Icons.Rounded.PictureInPictureAlt, + contentDescription = "Picture in picture", + onClick = onEnterPictureInPicture, + ) + } + castSlot(Modifier.playerDiscSurface()) + if (orientationLockSupported) { + PlayerIconDisc( + icon = if (isOrientationLocked) Icons.Rounded.ScreenLockRotation else Icons.Rounded.ScreenRotation, + contentDescription = if (isOrientationLocked) "Landscape locked" else "Rotate freely", + onClick = onToggleOrientationLock, + ) } } } +/** Dresses a foreign button (Cast) as a player disc. */ +private fun Modifier.playerDiscSurface(): Modifier = this + .minimumInteractiveComponentSize() + .size(PlayerChrome.DiscSize) + .clip(CircleShape) + .background(PlayerChrome.Disc) + .border(1.dp, PlayerChrome.DiscStroke, CircleShape) + @Composable private fun PlayerTransportControls( isPlaying: Boolean, @@ -364,83 +428,134 @@ private fun PlayerTransportControls( onPlayPause: () -> Unit, onSkipForward: () -> Unit, onSkipBackward: () -> Unit, + modifier: Modifier = Modifier, ) { + val showPlay = isPaused || !isPlaying Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(48.dp, Alignment.CenterHorizontally), + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(46.dp, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically, ) { - IconButton( + PlayerDisc( + contentDescription = "Skip back $skipBackSeconds seconds", onClick = onSkipBackward, + size = 54.dp, enabled = seekEnabled, - modifier = Modifier.size(52.dp), ) { SeekIntervalIcon( forward = false, seconds = skipBackSeconds, - contentDescription = "Skip back $skipBackSeconds seconds", - tint = if (seekEnabled) Color.White else Color.White.copy(alpha = 0.3f), - modifier = Modifier.size(32.dp), + contentDescription = null, + tint = PlayerChrome.Paper, + modifier = Modifier.size(31.dp), ) } - IconButton( + PlayerDisc( + contentDescription = if (showPlay) "Play" else "Pause", onClick = onPlayPause, + size = 70.dp, enabled = playPauseEnabled, - modifier = Modifier - .size(64.dp) - .background(Color.White.copy(alpha = 0.14f), CircleShape), + fill = PlayerChrome.Paper, + contentColor = PlayerChrome.Ink, + stroke = Color.Transparent, ) { Icon( - imageVector = if (isPaused || !isPlaying) { - Icons.Default.PlayArrow - } else { - Icons.Default.Pause - }, - contentDescription = if (isPaused || !isPlaying) "Play" else "Pause", - tint = if (playPauseEnabled) Color.White else Color.White.copy(alpha = 0.3f), - modifier = Modifier.size(38.dp), + imageVector = if (showPlay) Icons.Rounded.PlayArrow else Icons.Rounded.Pause, + contentDescription = null, + tint = PlayerChrome.Ink, + // The play triangle's visual centre sits left of its box. + modifier = Modifier + .size(44.dp) + .offset(x = if (showPlay) 2.dp else 0.dp), ) } - IconButton( + PlayerDisc( + contentDescription = "Skip forward $skipForwardSeconds seconds", onClick = onSkipForward, + size = 54.dp, enabled = seekEnabled, - modifier = Modifier.size(52.dp), ) { SeekIntervalIcon( forward = true, seconds = skipForwardSeconds, - contentDescription = "Skip forward $skipForwardSeconds seconds", - tint = if (seekEnabled) Color.White else Color.White.copy(alpha = 0.3f), - modifier = Modifier.size(32.dp), + contentDescription = null, + tint = PlayerChrome.Paper, + modifier = Modifier.size(31.dp), ) } } } +/** How much of each action pill fits: label and value, label, or icon only. */ +private enum class ActionPillDensity { Full, Labels, Icons } + @Composable -private fun PlayerToolbarTitle( - title: String, - subtitle: String, +private fun PlayerActionPillRow( + hasTracks: Boolean, + hasChapters: Boolean, + hasQualityMenu: Boolean, + tracksValue: String?, + chapterValue: String?, + qualityValue: String?, + onOpenTracks: () -> Unit, + onOpenChapters: () -> Unit, + onOpenQuality: () -> Unit, + onOpenSettings: () -> Unit, modifier: Modifier = Modifier, ) { - Column( - modifier = modifier, - verticalArrangement = Arrangement.Center, - ) { - Text( - text = title, - fontSize = 15.sp, - color = Color.White, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - if (subtitle.isNotBlank()) { - Text( - text = subtitle, - fontSize = 12.sp, - color = Color.White.copy(alpha = 0.64f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, + BoxWithConstraints(modifier = modifier.fillMaxWidth()) { + val density = when { + maxWidth >= 680.dp -> ActionPillDensity.Full + maxWidth >= 480.dp -> ActionPillDensity.Labels + else -> ActionPillDensity.Icons + } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + // The left pills share what Quality and ⋯ leave, so a long track or + // chapter name ellipsizes instead of pushing them off the row. + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + PlayerActionPill( + icon = Icons.Rounded.Subtitles, + label = "Audio & Subtitles", + value = tracksValue.takeIf { density == ActionPillDensity.Full }, + compact = density == ActionPillDensity.Icons, + enabled = hasTracks, + onClick = onOpenTracks, + modifier = Modifier.weight(1f, fill = false), + ) + if (hasChapters) { + PlayerActionPill( + icon = Icons.AutoMirrored.Rounded.FormatListBulleted, + label = "Chapters", + value = chapterValue.takeIf { density == ActionPillDensity.Full }, + compact = density == ActionPillDensity.Icons, + onClick = onOpenChapters, + modifier = Modifier.weight(1f, fill = false), + ) + } + } + if (hasQualityMenu) { + PlayerActionPill( + icon = Icons.Rounded.Tune, + label = "Quality", + value = qualityValue.takeIf { density != ActionPillDensity.Icons }, + compact = density == ActionPillDensity.Icons, + onClick = onOpenQuality, + ) + } + PlayerIconDisc( + icon = Icons.Rounded.MoreHoriz, + contentDescription = "Playback settings", + onClick = onOpenSettings, + size = 36.dp, + iconSize = 21.dp, ) } } @@ -449,6 +564,7 @@ private fun PlayerToolbarTitle( @Composable private fun TabletopUtilityRow( playbackSpeed: Double, + playbackSpeedEnabled: Boolean, nextEpisode: PlayerViewModel.NextEpisodeInfo?, compact: Boolean, brightnessFraction: Float, @@ -460,7 +576,7 @@ private fun TabletopUtilityRow( val brightnessControl: @Composable (Modifier) -> Unit = { modifier -> TabletopSliderControl( - icon = Icons.Default.Brightness6, + icon = Icons.Rounded.Brightness6, contentDescription = "Player brightness", value = brightnessFraction, onValueChange = onSetBrightness, @@ -502,7 +618,7 @@ private fun TabletopUtilityRow( verticalAlignment = Alignment.CenterVertically, ) { TabletopSliderControl( - icon = Icons.AutoMirrored.Filled.VolumeUp, + icon = Icons.AutoMirrored.Rounded.VolumeUp, contentDescription = "Media volume", value = volumeFraction, onValueChange = { fraction -> @@ -516,14 +632,16 @@ private fun TabletopUtilityRow( modifier = Modifier.weight(1f), ) brightnessControl(Modifier.weight(1f)) - TabletopActionButton( - icon = Icons.Default.Speed, - label = playbackSpeedLabel(playbackSpeed), - onClick = { onSetPlaybackSpeed(nextTabletopPlaybackSpeed(playbackSpeed)) }, - ) + if (playbackSpeedEnabled) { + TabletopActionButton( + icon = Icons.Rounded.Speed, + label = playbackSpeedLabel(playbackSpeed), + onClick = { onSetPlaybackSpeed(nextTabletopPlaybackSpeed(playbackSpeed)) }, + ) + } nextEpisode?.let { episode -> TabletopActionButton( - icon = Icons.Default.SkipNext, + icon = Icons.Rounded.SkipNext, label = "Next S${episode.seasonNumber}·E${episode.episodeNumber}", onClick = onPlayNextEpisode, ) @@ -533,7 +651,7 @@ private fun TabletopUtilityRow( @Composable private fun TabletopSliderControl( - icon: androidx.compose.ui.graphics.vector.ImageVector, + icon: ImageVector, contentDescription: String, value: Float, onValueChange: (Float) -> Unit, @@ -542,21 +660,26 @@ private fun TabletopSliderControl( Row( modifier = modifier .height(48.dp) - .clip(RoundedCornerShape(14.dp)) - .background(Color.White.copy(alpha = 0.08f)) - .padding(horizontal = 10.dp), + .clip(RoundedCornerShape(16.dp)) + .background(PlayerChrome.Raised) + .padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Icon( imageVector = icon, contentDescription = contentDescription, - tint = Color.White.copy(alpha = 0.82f), + tint = PlayerChrome.Paper.copy(alpha = 0.82f), modifier = Modifier.size(20.dp), ) Slider( value = value, onValueChange = onValueChange, + colors = SliderDefaults.colors( + thumbColor = PlayerChrome.Paper, + activeTrackColor = PlayerChrome.Paper, + inactiveTrackColor = Color.White.copy(alpha = 0.22f), + ), modifier = Modifier.weight(1f), ) } @@ -564,7 +687,7 @@ private fun TabletopSliderControl( @Composable private fun TabletopActionButton( - icon: androidx.compose.ui.graphics.vector.ImageVector, + icon: ImageVector, label: String, onClick: () -> Unit, ) { @@ -572,8 +695,8 @@ private fun TabletopActionButton( modifier = Modifier .height(48.dp) .widthIn(min = 112.dp) - .clip(RoundedCornerShape(14.dp)) - .background(Color.White.copy(alpha = 0.10f)) + .clip(RoundedCornerShape(16.dp)) + .background(PlayerChrome.Raised) .clickable(onClick = onClick) .padding(horizontal = 14.dp), verticalAlignment = Alignment.CenterVertically, @@ -582,16 +705,10 @@ private fun TabletopActionButton( Icon( imageVector = icon, contentDescription = null, - tint = Color.White, + tint = PlayerChrome.Paper, modifier = Modifier.size(19.dp), ) - Text( - text = label, - color = Color.White, - fontSize = 13.sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - ) + Text(text = label, style = PlayerType.PillLabel, maxLines = 1) } } @@ -601,155 +718,3 @@ internal fun nextTabletopPlaybackSpeed(current: Double): Double = internal fun playbackSpeedLabel(speed: Double): String { return "${formatPlaybackSpeed(speed)}× Speed" } - -@Composable -private fun PlayerToolbarActions( - isOrientationLocked: Boolean, - orientationLockSupported: Boolean, - hasChapters: Boolean, - hasTracks: Boolean, - hasMultipleVersions: Boolean, - onToggleOrientationLock: () -> Unit, - onOpenChapters: () -> Unit, - onOpenTracks: () -> Unit, - onOpenQuality: () -> Unit, - onOpenSettings: () -> Unit, - castSlot: @Composable () -> Unit, -) { - if (orientationLockSupported) { - ControlButton( - icon = if (isOrientationLocked) { - Icons.Default.ScreenLockRotation - } else { - Icons.Default.ScreenRotation - }, - contentDescription = if (isOrientationLocked) "Landscape Locked" else "Rotate Freely", - onClick = onToggleOrientationLock, - ) - } - if (hasChapters) { - ControlButton( - icon = Icons.AutoMirrored.Filled.List, - contentDescription = "Chapters", - onClick = onOpenChapters, - ) - } - ControlButton( - icon = Icons.AutoMirrored.Filled.SpeakerNotes, - contentDescription = "Audio and subtitles", - onClick = onOpenTracks, - enabled = hasTracks, - ) - if (hasMultipleVersions) { - ControlButton( - icon = Icons.Default.HighQuality, - contentDescription = "Quality", - onClick = onOpenQuality, - ) - } - castSlot() - ControlButton( - icon = Icons.Default.Settings, - contentDescription = "Playback settings", - onClick = onOpenSettings, - ) -} - -@Composable -private fun PlayerToolbarOverflow( - isOrientationLocked: Boolean, - orientationLockSupported: Boolean, - hasChapters: Boolean, - hasTracks: Boolean, - hasMultipleVersions: Boolean, - onToggleOrientationLock: () -> Unit, - onOpenChapters: () -> Unit, - onOpenTracks: () -> Unit, - onOpenQuality: () -> Unit, - onOpenSettings: () -> Unit, -) { - var expanded by remember { mutableStateOf(false) } - - Box { - ControlButton( - icon = Icons.Default.MoreVert, - contentDescription = "More playback controls", - onClick = { expanded = true }, - ) - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - if (orientationLockSupported) { - DropdownMenuItem( - text = { - Text(if (isOrientationLocked) "Unlock orientation" else "Lock orientation") - }, - onClick = { - expanded = false - onToggleOrientationLock() - }, - ) - } - if (hasChapters) { - DropdownMenuItem( - text = { Text("Chapters") }, - onClick = { - expanded = false - onOpenChapters() - }, - ) - } - DropdownMenuItem( - text = { Text("Audio and subtitles") }, - enabled = hasTracks, - onClick = { - expanded = false - onOpenTracks() - }, - ) - if (hasMultipleVersions) { - DropdownMenuItem( - text = { Text("Quality") }, - onClick = { - expanded = false - onOpenQuality() - }, - ) - } - DropdownMenuItem( - text = { Text("Playback settings") }, - onClick = { - expanded = false - onOpenSettings() - }, - ) - } - } -} - -/** - * Top-bar control button matching iOS `controlButton`: a `size 20` icon inside - * a 48x48 tap target (Android minimum touch target), white, dimmed to 0.3 when - * disabled. - */ -@Composable -private fun ControlButton( - icon: androidx.compose.ui.graphics.vector.ImageVector, - contentDescription: String, - onClick: () -> Unit, - enabled: Boolean = true, -) { - IconButton( - onClick = onClick, - enabled = enabled, - modifier = Modifier.size(48.dp), - ) { - Icon( - imageVector = icon, - contentDescription = contentDescription, - tint = if (enabled) Color.White else Color.White.copy(alpha = 0.3f), - modifier = Modifier.size(22.dp), - ) - } -} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerGestureHandler.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerGestureHandler.kt index 0a26a19d0a..3f99ba8afc 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerGestureHandler.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerGestureHandler.kt @@ -67,7 +67,7 @@ fun PlayerGestureHandler( onToggleControls: () -> Unit, onSkipForward: () -> Unit, onSkipBackward: () -> Unit, - // When false (a Watch Together guest without seek authority), the outer + // When false (a Watch Party guest without seek authority), the outer // double-tap skip bands fall through to a controls toggle rather than // firing a gated no-op skip and flashing a "+10s" badge that lies about a // position that never moves. Mirrors PlayerControls' seekEnabled gating. diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerMenu.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerMenu.kt new file mode 100644 index 0000000000..adc794f4d3 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerMenu.kt @@ -0,0 +1,332 @@ +package org.prairieserver.prairie.android.ui.screens.player + +import android.os.SystemClock +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.displayCutout +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetState +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +/** + * How player menus present themselves. + * + * [Docked] is the landscape treatment: the menu docks as a smoked panel on the + * right edge and the picture stays live beside it, so a subtitle or style + * change is visible as it is made. [Sheet] is the portrait (and tabletop) + * bottom sheet. The content of every menu is identical in both. + */ +internal enum class PlayerMenuPresentation { Sheet, Docked } + +internal val LocalPlayerMenuPresentation = staticCompositionLocalOf { PlayerMenuPresentation.Sheet } + +/** + * Reports the left edge (root px) of an open docked panel, or null once it + * closes, so the subtitle canvas can re-center in the part of the picture that + * is still visible. + */ +internal val LocalPlayerMenuDockReporter = staticCompositionLocalOf<(Float?) -> Unit> { {} } + +/** + * Lets one docked menu hand over to the next without the panel sliding out and + * back in: "Settings → Subtitle style" swaps the content in place. + */ +@Stable +internal class PlayerMenuHandoff { + internal var handedOffAtMs: Long = 0L + + internal fun consumeRecent(): Boolean { + val recent = SystemClock.uptimeMillis() - handedOffAtMs < HANDOFF_WINDOW_MS + handedOffAtMs = 0L + return recent + } + + private companion object { + const val HANDOFF_WINDOW_MS = 400L + } +} + +internal val LocalPlayerMenuHandoff = staticCompositionLocalOf { PlayerMenuHandoff() } + +/** Landscape windows wide enough to keep a useful picture beside the panel. */ +internal fun playerMenuPresentationFor( + widthDp: Float, + heightDp: Float, + tabletopMode: Boolean, +): PlayerMenuPresentation = + if (!tabletopMode && widthDp > heightDp && widthDp >= 560f) { + PlayerMenuPresentation.Docked + } else { + PlayerMenuPresentation.Sheet + } + +@OptIn(ExperimentalMaterial3Api::class) +@Stable +internal class PlayerMenuController internal constructor( + val presentation: PlayerMenuPresentation, + val sheetState: SheetState, + val panelState: MutableTransitionState, + private val scope: CoroutineScope, + private val handoff: PlayerMenuHandoff, + private val onDismiss: State<() -> Unit>, +) { + /** + * Closes the menu, then runs [then]. A docked menu that hands over to + * another menu (a non-null [then]) swaps in place instead of animating out. + */ + fun dismiss(then: (() -> Unit)? = null) { + when (presentation) { + PlayerMenuPresentation.Sheet -> scope.launch { + sheetState.hide() + onDismiss.value() + then?.invoke() + } + PlayerMenuPresentation.Docked -> if (then != null) { + handoff.handedOffAtMs = SystemClock.uptimeMillis() + onDismiss.value() + then() + } else { + panelState.targetState = false + scope.launch { + snapshotFlow { panelState.isIdle && !panelState.currentState }.first { it } + onDismiss.value() + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun rememberPlayerMenuController(onDismiss: () -> Unit): PlayerMenuController { + val presentation = LocalPlayerMenuPresentation.current + val handoff = LocalPlayerMenuHandoff.current + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + val latestDismiss = rememberUpdatedState(onDismiss) + val panelState = remember { + MutableTransitionState(handoff.consumeRecent()).apply { targetState = true } + } + return remember(presentation, sheetState, panelState) { + PlayerMenuController(presentation, sheetState, panelState, scope, handoff, latestDismiss) + } +} + +/** + * The container every player menu renders into. Content is a column; menus + * that scroll put their list in [PlayerMenuScrollColumn] so it takes the room + * left under the header without pushing a footer off screen. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun PlayerMenu( + controller: PlayerMenuController, + tabletopPaneHeight: Dp?, + content: @Composable ColumnScope.() -> Unit, +) { + when (controller.presentation) { + PlayerMenuPresentation.Sheet -> { + LaunchedEffect(Unit) { controller.sheetState.show() } + PlayerModalBottomSheet( + onDismissRequest = { controller.dismiss() }, + sheetState = controller.sheetState, + tabletopPaneHeight = tabletopPaneHeight, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .playerSheetContent(tabletopPaneHeight) + .nestedScroll(PlayerSheetFlingGuard) + .padding(bottom = 12.dp), + content = content, + ) + } + } + PlayerMenuPresentation.Docked -> PlayerDockedPanel(controller, content) + } +} + +@Composable +private fun PlayerDockedPanel( + controller: PlayerMenuController, + content: @Composable ColumnScope.() -> Unit, +) { + val reportDockEdge = LocalPlayerMenuDockReporter.current + BackHandler { controller.dismiss() } + DisposableEffect(Unit) { + onDispose { reportDockEdge(null) } + } + + BoxWithConstraints(modifier = Modifier.fillMaxSize().zIndex(20f)) { + val panelWidth = minOf(PlayerChrome.PanelWidth, maxWidth * 0.46f).coerceAtLeast(320.dp) + // The camera cutout can sit on the right in one landscape and not the + // other; clear it so the panel never runs under the camera. + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current + val cutoutEnd = with(density) { + WindowInsets.displayCutout.getRight(this, layoutDirection).toDp() + } + + AnimatedVisibility( + visibleState = controller.panelState, + enter = fadeIn(tween(180)), + exit = fadeOut(tween(160)), + ) { + // Darken only the panel's side; the picture on the left stays as + // bright as it plays. A tap anywhere outside the panel closes it. + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.horizontalGradient( + 0.40f to Color.Transparent, + 0.62f to Color.Black.copy(alpha = 0.45f), + ), + ) + .pointerInput(Unit) { detectTapGestures { controller.dismiss() } }, + ) + } + + AnimatedVisibility( + visibleState = controller.panelState, + enter = slideInHorizontally(tween(240, easing = FastOutSlowInEasing)) { it / 3 } + + fadeIn(tween(180)), + exit = slideOutHorizontally(tween(180, easing = FastOutSlowInEasing)) { it / 3 } + + fadeOut(tween(140)), + modifier = Modifier.align(Alignment.CenterEnd), + ) { + val shape = RoundedCornerShape(PlayerChrome.PanelRadius) + Column( + modifier = Modifier + .padding(top = 10.dp, bottom = 10.dp, end = 10.dp + cutoutEnd) + .width(panelWidth) + .fillMaxHeight() + .onGloballyPositioned { reportDockEdge(it.boundsInRoot().left) } + .clip(shape) + .background(PlayerChrome.Smoke) + .border(PlayerPanelBorder, shape) + // Taps on the panel's own empty space must not fall through + // to the dismiss scrim underneath. + .pointerInput(Unit) { detectTapGestures { } } + .padding(bottom = 6.dp), + content = content, + ) + } + } +} + +/** + * The scrolling body of a menu: fills what the header and footer leave, wraps + * when shorter, and fades its bottom edge instead of cutting a row in half. + */ +@Composable +internal fun ColumnScope.PlayerMenuScrollColumn( + modifier: Modifier = Modifier, + horizontalPadding: Dp = 8.dp, + content: @Composable ColumnScope.() -> Unit, +) { + val scrollState = rememberScrollState() + Column( + modifier = modifier + .weight(1f, fill = false) + .fillMaxWidth() + .playerFadingEdges(scrollState) + .verticalScroll(scrollState) + .padding(horizontal = horizontalPadding), + content = content, + ) +} + +/** + * Fades the top and bottom edges of a scrolling list while there is more to + * scroll in that direction, so the last visible row reads as "continues" + * rather than as clipped. + */ +internal fun Modifier.playerFadingEdges( + state: ScrollableState, + top: Dp = 16.dp, + bottom: Dp = 40.dp, +): Modifier = this + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + if (state.canScrollBackward) { + val height = top.toPx() + drawRect( + brush = Brush.verticalGradient(0f to Color.Transparent, 1f to Color.Black, endY = height), + size = Size(size.width, height), + blendMode = BlendMode.DstIn, + ) + } + if (state.canScrollForward) { + val height = bottom.toPx() + drawRect( + brush = Brush.verticalGradient( + 0f to Color.Black, + 1f to Color.Transparent, + startY = size.height - height, + endY = size.height, + ), + topLeft = Offset(0f, size.height - height), + size = Size(size.width, height), + blendMode = BlendMode.DstIn, + ) + } + } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerNextUpScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerNextUpScreen.kt index 0885e631fd..dec18afca1 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerNextUpScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerNextUpScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Shuffle import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon @@ -53,7 +54,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.prairieserver.prairie.common.player.videoViewportBounds import org.prairieserver.prairie.common.ui.components.DeferImagePresentationWhileScrolling -import org.prairieserver.prairie.common.ui.components.ThumbhashImage +import org.prairieserver.prairie.common.cards.LocalEpisodeSpoilerPrefs +import org.prairieserver.prairie.common.ui.components.SpoilerImage /** Episode details and controls beside the mounted player's preview pane. */ @OptIn(ExperimentalLayoutApi::class) @@ -72,6 +74,11 @@ fun PlayerNextUpScreen( onBack: () -> Unit, onVideoBoundsChanged: (Rect) -> Unit, compactTabletop: Boolean = false, + // Set while a shuffle plays: the next item is a random pick, and the + // viewer can pick another or stop shuffling. + shuffle: PlayerViewModel.ShuffleUiState? = null, + onPickAnother: () -> Unit = {}, + onStopShuffling: () -> Unit = {}, modifier: Modifier = Modifier, ) { BoxWithConstraints(modifier = modifier.fillMaxSize()) { @@ -83,9 +90,35 @@ fun PlayerNextUpScreen( horizontalAlignment = panelAlignment, verticalArrangement = Arrangement.spacedBy(8.dp), ) { + shuffle?.scopeLabel?.let { scope -> + Row( + modifier = Modifier + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.10f)) + .padding(horizontal = 12.dp, vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + imageVector = Icons.Filled.Shuffle, + contentDescription = null, + tint = Color.White.copy(alpha = 0.75f), + modifier = Modifier.size(14.dp), + ) + Text( + text = "Shuffling $scope", + color = Color.White.copy(alpha = 0.75f), + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } Text( text = when { nextEpisode == null -> "FINISHED" + shuffle != null -> "UP NEXT AT RANDOM" videoEnded -> "PLAYING NEXT" else -> "UP NEXT" }, @@ -95,7 +128,10 @@ fun PlayerNextUpScreen( letterSpacing = 2.sp, ) if (nextEpisode != null) { - nextEpisode.seriesTitle?.takeIf { it.isNotBlank() }?.let { title -> + // A shuffled movie has no series line; its own title heads the card. + val heading = nextEpisode.seriesTitle?.takeIf { it.isNotBlank() } + ?: nextEpisode.title.takeUnless { nextEpisode.isEpisode } + heading?.let { title -> Text( text = title, color = Color.White, @@ -105,14 +141,16 @@ fun PlayerNextUpScreen( overflow = TextOverflow.Ellipsis, ) } - Text( - text = nextEpisode.label, - color = Color.White, - fontSize = 17.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) + if (nextEpisode.isEpisode) { + Text( + text = nextEpisode.label, + color = Color.White, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } if (nextEpisode.runtimeMinutes > 0) { Text( text = "${nextEpisode.runtimeMinutes} min", @@ -120,7 +158,10 @@ fun PlayerNextUpScreen( fontSize = 13.sp, ) } - nextEpisode.overview?.takeIf { it.isNotBlank() }?.let { overview -> + val hidesOverview = + LocalEpisodeSpoilerPrefs.current.hidesOverview(nextEpisode.isUnwatched) + val overview = nextEpisode.overview?.takeIf { it.isNotBlank() && !hidesOverview } + if (overview != null) { Text( text = overview, color = Color.White.copy(alpha = 0.58f), @@ -142,18 +183,38 @@ fun PlayerNextUpScreen( onPlayNow = onPlayNow, onKeepWatching = onKeepWatching, onBack = onBack, + pickAnother = if (shuffle != null && nextEpisode != null) { + PickAnotherAction(enabled = !shuffle.pickingAnother, onClick = onPickAnother) + } else { + null + }, ) if (countdownSeconds != null) { CountdownRing(countdownSeconds, countdownTotalSeconds) } } - Text( - text = "Auto-play is ${if (autoPlayEnabled) "On" else "Off"}", - color = Color.White.copy(alpha = 0.54f), - fontSize = 12.sp, - modifier = Modifier.clip(CircleShape).clickable(onClick = onToggleAutoPlay) - .padding(horizontal = 12.dp, vertical = 6.dp), - ) + shuffle?.message?.let { message -> + Text(message, color = Color(0xFFFFB4AB), fontSize = 13.sp) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "Auto-play is ${if (autoPlayEnabled) "On" else "Off"}", + color = Color.White.copy(alpha = 0.54f), + fontSize = 12.sp, + modifier = Modifier.clip(CircleShape).clickable(onClick = onToggleAutoPlay) + .padding(horizontal = 12.dp, vertical = 6.dp), + ) + if (shuffle != null) { + Text("·", color = Color.White.copy(alpha = 0.54f), fontSize = 12.sp) + Text( + text = "Stop shuffling", + color = Color.White.copy(alpha = 0.54f), + fontSize = 12.sp, + modifier = Modifier.clip(CircleShape).clickable(onClick = onStopShuffling) + .padding(horizontal = 12.dp, vertical = 6.dp), + ) + } + } if (!compactTabletop && onDeckItems.isNotEmpty()) { Text("On Deck", color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) val onDeckState = rememberLazyListState() @@ -204,6 +265,8 @@ private fun NextUpVideoPane(onBoundsChanged: (Rect) -> Unit, modifier: Modifier) ) } +private class PickAnotherAction(val enabled: Boolean, val onClick: () -> Unit) + @Composable private fun NextUpActionButtons( hasNextEpisode: Boolean, @@ -211,6 +274,7 @@ private fun NextUpActionButtons( onPlayNow: () -> Unit, onKeepWatching: () -> Unit, onBack: () -> Unit, + pickAnother: PickAnotherAction? = null, modifier: Modifier = Modifier, ) { if (hasNextEpisode) { @@ -231,6 +295,22 @@ private fun NextUpActionButtons( Text("Play Now") } } + if (pickAnother != null) { + OutlinedButton( + onClick = pickAnother.onClick, + enabled = pickAnother.enabled, + modifier = modifier, + ) { + Icon( + imageVector = Icons.Filled.Shuffle, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text("Pick Another", color = Color.White) + } + } if (!videoEnded) { OutlinedButton( onClick = onKeepWatching, @@ -271,9 +351,10 @@ private fun OnDeckCard( .aspectRatio(16f / 9f) .clip(RoundedCornerShape(8.dp)), ) { - ThumbhashImage( + SpoilerImage( url = item.artUrl, thumbhash = item.artThumbhash, + hidden = LocalEpisodeSpoilerPrefs.current.hidesImage(item.isUnwatchedEpisode, item.imageIsEpisodeStill), contentDescription = item.title, modifier = Modifier.fillMaxSize(), ) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerOverlay.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerOverlay.kt index 0b93daca77..3923e05269 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerOverlay.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerOverlay.kt @@ -8,7 +8,10 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets @@ -17,19 +20,19 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Group -import androidx.compose.material.icons.outlined.Bedtime -import androidx.compose.material3.AlertDialog +import androidx.compose.material.icons.rounded.Bedtime +import androidx.compose.material.icons.rounded.Groups import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -38,34 +41,51 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex -import org.prairieserver.prairie.android.ui.util.LanguageNames import org.prairieserver.prairie.android.ui.util.formatClockTime +import org.prairieserver.prairie.common.ui.LanguageNames +import org.prairieserver.prairie.common.player.video.markerSkipTarget +import org.prairieserver.prairie.common.player.video.ManualMarkerKind +import androidx.compose.ui.res.stringResource +import org.prairieserver.prairie.android.R import org.prairieserver.prairie.common.player.SessionState import org.prairieserver.prairie.common.player.SleepTimerState +import org.prairieserver.prairie.model.playback.PlaybackQualityOption +import org.prairieserver.prairie.model.playback.playbackQualityMenu import org.prairieserver.prairie.model.watchtogether.MemberRole -import org.prairieserver.prairie.model.watchtogether.RoomPlaybackState import org.prairieserver.prairie.model.watchtogether.RoomSnapshot import org.prairieserver.prairie.watchtogether.RoomTransportIntent import org.prairieserver.prairie.watchtogether.roomTransportAuthorized import androidx.compose.material.icons.filled.Close +import org.prairieserver.prairie.playback.statusLabelFor +import org.prairieserver.prairie.playback.timingActionsFor /** * Full-screen overlay composable that layers gesture handling, transport controls, * and contextual buttons (skip intro, next episode) on top of the video surface. * - * Also manages bottom sheet display for subtitle, audio, and quality selection. + * Also manages bottom sheet display for subtitle, audio, quality, and version selection. */ @Composable fun PlayerOverlay( state: PlayerViewModel.PlayerUiState, viewModel: PlayerViewModel, roomSnapshot: RoomSnapshot? = null, + // True for the whole life of a Watch Party player, even while the room + // snapshot is briefly unavailable, so its gates never fall back to solo. + inRoom: Boolean = roomSnapshot != null, + // Playback is held locally (audio focus, sleep timer, background): Play + // stays available so the viewer can resume this device. + roomSuspended: Boolean = false, + // The party's current status line (waiting, catching up, host away, + // reconnecting); null shows the member count. + roomStatus: String? = null, isFastForwardHoldActive: Boolean = false, orientationLockSupported: Boolean = true, alwaysShowControls: Boolean = false, @@ -84,8 +104,14 @@ fun PlayerOverlay( onSelectAudio: (Int) -> Unit, onSelectVersion: (Int) -> Unit, onSelectQuality: (String) -> Unit = {}, - // Google Cast (Chromecast) button rendered in the transport top bar. - castSlot: @Composable () -> Unit = {}, + // Google Cast (Chromecast) button rendered in the transport top bar; the + // modifier dresses it as a player disc. + castSlot: @Composable (Modifier) -> Unit = {}, + pictureInPictureAvailable: Boolean = false, + onEnterPictureInPicture: () -> Unit = {}, + // Left edge (root px) of an open docked menu, null when none is open, so + // the subtitle canvas can re-center in the visible part of the picture. + onDockedMenuEdgeChanged: (Float?) -> Unit = {}, modifier: Modifier = Modifier, ) { // Sheet visibility — one bool per sheet. iOS uses a sealed `activeSheet` @@ -93,7 +119,9 @@ fun PlayerOverlay( // `rememberModalBottomSheetState`, so per-sheet bools are the natural // fit (and the sheets can't be nested anyway). var tracksSheetVisible by remember { mutableStateOf(false) } - var showQualitySelector by remember { mutableStateOf(false) } + var tracksInitialTab by remember { mutableStateOf(TracksTab.Subtitles) } + var qualitySheetVisible by remember { mutableStateOf(false) } + var versionSheetVisible by remember { mutableStateOf(false) } var settingsSheetVisible by remember { mutableStateOf(false) } var subtitleStyleVisible by remember { mutableStateOf(false) } var sleepTimerVisible by remember { mutableStateOf(false) } @@ -101,24 +129,61 @@ fun PlayerOverlay( var statsSheetVisible by remember { mutableStateOf(false) } var subtitleSearchVisible by remember { mutableStateOf(false) } var aiTranslateVisible by remember { mutableStateOf(false) } - // Host close-room confirm dialog (Watch Together): the host backing out of - // the player tears the room down for everyone, so confirm first. - var showCloseConfirm by remember { mutableStateOf(false) } - - // Watch Together transport gating. Seek is host-only (the server rejects - // guest seeks regardless of policy), so the scrubber / skip affordance is - // disabled for ALL guests — even one under guest_play_pause, who keeps the - // play/pause affordance. Solo playback (no room) enables both. - val inRoom = roomSnapshot != null + + // Watch Party transport gating. Seek is host-only (the server rejects + // guest seeks regardless of policy), so the scrubber, skips, chapters, and + // the intro pill are disabled for ALL guests — even one under + // guest_play_pause, who keeps play/pause. Solo playback enables both. val seekEnabled = !inRoom || roomTransportAuthorized(roomSnapshot, RoomTransportIntent.Seek) - val playPauseEnabled = !inRoom || roomTransportAuthorized(roomSnapshot, RoomTransportIntent.PlayPause) + val playPauseEnabled = !inRoom || + roomSuspended || + roomTransportAuthorized(roomSnapshot, RoomTransportIntent.PlayPause) val isRoomHost = roomSnapshot?.selfRole == MemberRole.Host - - // Back intercept: a host in a room confirms the room close; everyone else - // (guest, or solo playback) backs out immediately. - val handleBack: () -> Unit = { - if (inRoom && isRoomHost) showCloseConfirm = true else onBack() + // In a party an intro never skips on its own; members who may seek get + // the Skip pill as a room seek, and nobody else sees it (D8). + val introPillAllowed = !inRoom || seekEnabled + + // The plan's Quality menu, shown whenever there is a plan (a single entry + // still lists Auto + Original). A download playing offline has none. + val planQualityOptions = remember(state.playbackPlan) { + playbackQualityMenu(state.playbackPlan?.availableQualities.orEmpty()) + } + // Prairie: when the plan lists no qualities, fall back to the Prairie + // quality ladder (QualityLadder, GET /api/v1/playback/quality-ladder) the + // view model builds per file. Its picks restart the session through + // onSelectQuality (PlayerViewModel.switchQuality). Not offered offline + // (no session) or in a party, which plays exactly the room's stream. + val usePrairieLadder = planQualityOptions.isEmpty() && + state.qualityOptions.size > 1 && + state.sessionId != null && + !inRoom + val qualityOptions = remember(planQualityOptions, usePrairieLadder, state.qualityOptions) { + if (usePrairieLadder) { + state.qualityOptions.map { option -> + PlaybackQualityOption( + id = option.id, + name = option.label, + bitrateLabel = option.sublabel.ifBlank { null }, + bitrateKbps = option.bitrateKbps, + preservesSource = option.isOriginal, + ) + } + } else { + planQualityOptions + } + } + val activeQualityId = if (usePrairieLadder) { + qualityOptions.firstOrNull { it.id.equals(state.selectedQualityId, ignoreCase = true) }?.id + } else { + state.activeQualityId(qualityOptions) } + val onPickQuality: (String) -> Unit = if (usePrairieLadder) onSelectQuality else viewModel::onSelectQuality + val hasQualityMenu = qualityOptions.isNotEmpty() + // A party plays exactly the room's file: no version picker. + val hasVersionMenu = state.versions.size > 1 && !inRoom + + // In a party, Back opens the party panel (PlayerScreen); solo backs out. + val handleBack: () -> Unit = onBack val gatedSeek: (Double) -> Unit = { pos -> if (seekEnabled) onSeek(pos) } // Resolved profile-wide video intervals; read at press time so a change // made in settings applies to the next skip without restarting playback. @@ -159,7 +224,7 @@ fun PlayerOverlay( // behaves normally, because by then no pill is showing and this handler is // disabled. The player has no other BackHandler of its own — sheets live in // their own dialog windows, so an open sheet's Back never reaches here. - BackHandler(enabled = introSkipState.isVisible) { viewModel.onDismissIntroPrompt() } + BackHandler(enabled = introSkipState.isVisible && introPillAllowed) { viewModel.onDismissIntroPrompt() } val sleepTimerState by viewModel.sleepTimerState.collectAsState() val sleepTimerDefault by viewModel.sleepTimerDefaultMinutes.collectAsState() val videoGravity by viewModel.videoGravity.collectAsState() @@ -167,6 +232,8 @@ fun PlayerOverlay( val notice by viewModel.notice.collectAsState() val sessionState by viewModel.sessionState.collectAsState() val subtitleTools by viewModel.subtitleTools.collectAsState() + val subtitleSync by viewModel.subtitleSyncState.collectAsState() + val subtitleSyncNotice by viewModel.subtitleSyncNotice.collectAsState() // Pinch-to-scale (iOS parity): pinch-out steps Fit -> Fill -> Stretch, // pinch-in steps back, clamped at both ends. No-op steps (already at an // end) skip the toast so a clamped pinch stays quiet. @@ -199,7 +266,26 @@ fun PlayerOverlay( // stream URLs); hidden for offline/local playback. val subtitleToolsAvailable = state.sessionId != null && state.mediaFileId != null - Box(modifier = modifier.fillMaxSize()) { + val menuHandoff = remember { PlayerMenuHandoff() } + // A menu that is not drawn (no plan, or Version in a party) must not hide the controls. + val anyMenuOpen = tracksSheetVisible || (qualitySheetVisible && hasQualityMenu) || + (versionSheetVisible && hasVersionMenu) || settingsSheetVisible || + subtitleStyleVisible || sleepTimerVisible || chaptersSheetVisible || statsSheetVisible || + subtitleSearchVisible || aiTranslateVisible + + BoxWithConstraints(modifier = modifier.fillMaxSize()) { + val menuPresentation = playerMenuPresentationFor( + widthDp = maxWidth.value, + heightDp = maxHeight.value, + tabletopMode = tabletopMode || tabletopPaneHeight != null, + ) + // A docked menu replaces the controls; the picture beside it stays clear. + val menuDocked = anyMenuOpen && menuPresentation == PlayerMenuPresentation.Docked + CompositionLocalProvider( + LocalPlayerMenuPresentation provides menuPresentation, + LocalPlayerMenuHandoff provides menuHandoff, + LocalPlayerMenuDockReporter provides onDockedMenuEdgeChanged, + ) { // Gesture layer stays out of the tree while controls are visible so // full-screen pointer handlers cannot consume taps meant for buttons. if (!alwaysShowControls && !state.showControls && !state.showUpNext) { @@ -231,7 +317,7 @@ fun PlayerOverlay( modifier = Modifier .size(56.dp) .align(Alignment.Center), - color = Color.White, + color = PlayerChrome.Paper, strokeWidth = 3.dp, ) } @@ -246,16 +332,8 @@ fun PlayerOverlay( .padding(top = 64.dp) .zIndex(3f), ) { - Surface( - color = Color.Black.copy(alpha = 0.72f), - shape = RoundedCornerShape(999.dp), - ) { - Text( - text = "2x", - color = Color.White, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(horizontal = 18.dp, vertical = 8.dp), - ) + PlayerStatusChip { + Text(text = "2×", style = PlayerType.PillLabel.copy(fontSize = 15.sp)) } } @@ -273,6 +351,19 @@ fun PlayerOverlay( PlayerNoticeOverlay(notice = notice) } + // Subtitle sync card (top-right, below the top bar's actions): follows + // a sync this viewer started, whether or not the controls show. + Box( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing) + .padding(top = 72.dp, end = 16.dp) + .zIndex(9f), + contentAlignment = Alignment.TopEnd, + ) { + SubtitleSyncCard(notice = subtitleSyncNotice, onDismiss = viewModel::dismissSubtitleSyncNotice) + } + // Remote-control "display_message" toast (top-center), shown for a few // seconds regardless of controls visibility. zIndex above the controls // layer + WT badge so it's never obscured. @@ -285,24 +376,21 @@ fun PlayerOverlay( .zIndex(10f), contentAlignment = Alignment.TopCenter, ) { - Surface( - color = Color.Black.copy(alpha = 0.78f), - shape = MaterialTheme.shapes.medium, - ) { - Text( - text = message.text, - color = Color.White, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), - ) - } + Text( + text = message.text, + style = PlayerType.RowTitle, + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(PlayerChrome.Smoke) + .border(1.dp, PlayerChrome.PanelStroke, RoundedCornerShape(16.dp)) + .padding(horizontal = 20.dp, vertical = 12.dp), + ) } } - // Watch Together room indicator (top-center). Member count + host-offline - // / waiting-barrier state, and the invite code for the host. Stays - // visible regardless of controls visibility so members always know the - // room status. + // Watch Party status (top-center): the room's status line, or the + // member count, and the code for the host. Stays visible regardless of + // controls visibility so members always know the room status. if (roomSnapshot != null) { Box( modifier = Modifier @@ -311,32 +399,24 @@ fun PlayerOverlay( .padding(top = 16.dp), contentAlignment = Alignment.TopCenter, ) { - Row( - modifier = Modifier - .background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(20.dp)) - .padding(horizontal = 14.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { + PlayerStatusChip { Icon( - imageVector = Icons.Filled.Group, + imageVector = Icons.Rounded.Groups, contentDescription = null, - tint = Color.White, - modifier = Modifier.size(14.dp), + tint = PlayerChrome.Paper, + modifier = Modifier.size(15.dp), ) Spacer(modifier = Modifier.width(6.dp)) - val label = when { - roomSnapshot.playbackState == RoomPlaybackState.Waiting -> "Waiting for members…" - !roomSnapshot.hostConnected -> "${roomSnapshot.memberCount} · host offline" - else -> "${roomSnapshot.memberCount} watching" - } - Text(text = label, color = Color.White, fontSize = 13.sp) + val label = roomStatus ?: "${roomSnapshot.memberCount} watching" + Text( + text = label, + style = PlayerType.PillLabel.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Medium), + maxLines = 2, + modifier = Modifier.widthIn(max = 360.dp), + ) if (isRoomHost && roomSnapshot.code.isNotBlank()) { Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "Code ${roomSnapshot.code}", - color = Color.White, - fontSize = 12.sp, - ) + Text(text = "Code ${roomSnapshot.code}", style = PlayerType.Time.copy(color = PlayerChrome.Graphite)) } } } @@ -344,7 +424,7 @@ fun PlayerOverlay( // Transport controls (shown/hidden with animation) AnimatedVisibility( - visible = (alwaysShowControls || state.showControls) && !state.showUpNext, + visible = (alwaysShowControls || state.showControls) && !state.showUpNext && !menuDocked, enter = fadeIn(), exit = fadeOut(), modifier = Modifier @@ -354,6 +434,12 @@ fun PlayerOverlay( PlayerControls( title = state.title, subtitle = state.subtitle, + eyebrow = playerEyebrow(state), + tracksValue = playerTracksValue(state), + chapterValue = chapterTitleAt(state.chapters, state.position), + qualityValue = playerQualityLabel(qualityOptions, activeQualityId), + pictureInPictureAvailable = pictureInPictureAvailable, + onEnterPictureInPicture = onEnterPictureInPicture, isPlaying = state.isPlaying, isPaused = state.isPaused, position = state.position, @@ -367,12 +453,15 @@ fun PlayerOverlay( preview = state.preview, hasChapters = state.chapters.isNotEmpty(), hasTracks = state.subtitleTracks.isNotEmpty() || state.audioTracks.isNotEmpty(), - hasMultipleVersions = state.versions.size > 1 || state.qualityOptions.size > 1, + hasQualityMenu = hasQualityMenu, isOrientationLocked = isOrientationLocked, orientationLockSupported = orientationLockSupported, tabletopMode = tabletopMode, playbackSpeed = playbackSpeed, - nextEpisode = state.nextEpisode.takeUnless { inRoom }, + // A party plays at 1x; the saved speed is never changed from it. + playbackSpeedEnabled = !inRoom, + // A shuffle replaces the series order: no sequential next episode. + nextEpisode = state.nextEpisode.takeUnless { inRoom || state.shuffle != null }, brightnessFraction = brightnessFraction, seekEnabled = seekEnabled, playPauseEnabled = playPauseEnabled, @@ -389,12 +478,16 @@ fun PlayerOverlay( } }, onOpenChapters = { chaptersSheetVisible = true }, - onOpenTracks = { tracksSheetVisible = true }, - onOpenQuality = { showQualitySelector = true }, + onOpenTracks = { + tracksInitialTab = TracksTab.Subtitles + tracksSheetVisible = true + }, + onOpenQuality = { qualitySheetVisible = true }, onOpenSettings = { settingsSheetVisible = true }, onSetPlaybackSpeed = viewModel::onSetPlaybackSpeed, onPlayNextEpisode = viewModel::playUpNextNow, onSetBrightness = onSetBrightness, + onScrubbingChange = viewModel::onScrubbingChanged, castSlot = castSlot, ) } @@ -408,14 +501,21 @@ fun PlayerOverlay( // Intro skip pill (Hidden / Asking / Skipped). // Shares the bottom-end slot with the Up Next card; intro and credits // never overlap in practice, but the card wins the slot if both could show. - if (!state.showUpNext) { + if (!state.showUpNext && introPillAllowed) { Box( modifier = bottomEndSlotModifier, contentAlignment = Alignment.BottomEnd, ) { IntroAutoSkipBanner( state = introSkipState, - onSelect = viewModel::onSelectIntroPrompt, + onSelect = if (inRoom) { + { + val target = viewModel.selectIntroPromptTarget() + if (target != null) gatedSeek(target) + } + } else { + viewModel::onSelectIntroPrompt + }, totalSeconds = viewModel.introSkipTotalSeconds, countdownRun = introSkipCountdownRun, timerRunning = introSkipTimerRunning, @@ -423,6 +523,18 @@ fun PlayerOverlay( } } + if (!state.showUpNext && !introSkipState.isVisible && seekEnabled && !state.isLoading) { + val target = markerSkipTarget(state.position, state.recap, state.credits) + if (target != null) { + Box(modifier = bottomEndSlotModifier, contentAlignment = Alignment.BottomEnd) { + MarkerSkipPill( + label = stringResource(if (target.kind == ManualMarkerKind.Recap) R.string.marker_skip_recap else R.string.marker_skip_credits), + onClick = { gatedSeek(target.endSeconds) }, + ) + } + } + } + var retainedUpNextInfo by remember { mutableStateOf(null) } LaunchedEffect(state.showUpNext, state.nextEpisode) { state.nextEpisode?.let { retainedUpNextInfo = it } @@ -453,7 +565,10 @@ fun PlayerOverlay( modifier = Modifier.zIndex(3f), ) { PlayerNextUpScreen( - nextEpisode = state.nextEpisode ?: retainedUpNextInfo, + // The retained card covers the fade-out only; a shuffle that + // can no longer play anything shows Finished while open. + nextEpisode = state.nextEpisode + ?: retainedUpNextInfo.takeUnless { state.showUpNext && state.shuffle != null }, onVideoBoundsChanged = onNextUpVideoBoundsChanged, onDeckItems = state.onDeckItems, videoEnded = state.upNextVideoEnded, @@ -468,6 +583,13 @@ fun PlayerOverlay( onPlayOnDeckItem = viewModel::playOnDeckItemNow, onBack = handleBack, compactTabletop = tabletopMode, + shuffle = state.shuffle, + onPickAnother = viewModel::pickAnotherShuffle, + onStopShuffling = { + // Stop shuffling leaves the player, back to where the shuffle started. + viewModel.stopShuffling() + handleBack() + }, ) } @@ -494,244 +616,244 @@ fun PlayerOverlay( SleepTimerChip(remainingSeconds = active.remainingSeconds) } } - } - // Host close-room confirmation (Watch Together). Closing tears the room - // down for everyone; the actual close + teardown happens in onBack (the - // RoomSyncController.leave(closeRoom = true) path in PlayerScreen). - if (showCloseConfirm) { - AlertDialog( - onDismissRequest = { showCloseConfirm = false }, - title = { Text("Close room for everyone?") }, - text = { Text("Leaving as host ends the Watch Together room for all members.") }, - confirmButton = { - TextButton(onClick = { - showCloseConfirm = false - onBack() - }) { Text("Close room") } + // Player menus. In landscape they dock beside the picture (see + // PlayerMenu); the controls step aside while one is open. + // Audio & Subtitles — opened from its action pill or a settings row. + TracksSheet( + isVisible = tracksSheetVisible, + audioTracks = state.audioTracks, + selectedAudioIndex = state.selectedAudioIndex, + subtitles = state.subtitleTracks, + selectedSubtitleIndex = state.selectedSubtitleIndex, + onSelectAudio = onSelectAudio, + onSelectSubtitle = onSelectSubtitle, + onDismiss = { tracksSheetVisible = false }, + initialTab = tracksInitialTab, + showSearchAction = subtitleToolsAvailable, + showTranslateAction = subtitleToolsAvailable && + subtitleTools.aiStatus?.let { it.enabled || it.transcribeEnabled } == true, + onSearchSubtitles = { + tracksSheetVisible = false + subtitleSearchVisible = true }, - dismissButton = { - TextButton(onClick = { showCloseConfirm = false }) { Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Cancel") } + onTranslateWithAi = { + tracksSheetVisible = false + aiTranslateVisible = true }, + subtitleStatus = subtitleSync::statusLabelFor, + timingActions = subtitleSync.timingActionsFor( + state.subtitleTracks.getOrNull(state.selectedSubtitleIndex)?.syncKey, + ), + onSyncSubtitle = viewModel::requestSubtitleSync, + onResetTiming = viewModel::resetSubtitleTiming, + tabletopPaneHeight = tabletopPaneHeight, ) - } - // Combined audio + subtitle picker — opened from the top-bar - // captions.bubble icon (iOS parity). Replaces the previous side-popup - // "SettingsPanel" + individual SubtitleSelector / AudioTrackSelector - // sheets with a single sectioned ModalBottomSheet. - TracksSheet( - isVisible = tracksSheetVisible, - audioTracks = state.audioTracks, - selectedAudioIndex = state.selectedAudioIndex, - subtitles = state.subtitleTracks, - selectedSubtitleIndex = state.selectedSubtitleIndex, - onSelectAudio = onSelectAudio, - onSelectSubtitle = onSelectSubtitle, - onDismiss = { tracksSheetVisible = false }, - showSearchAction = subtitleToolsAvailable, - showTranslateAction = subtitleToolsAvailable && - subtitleTools.aiStatus?.let { it.enabled || it.transcribeEnabled } == true, - onSearchSubtitles = { - tracksSheetVisible = false - subtitleSearchVisible = true - }, - onTranslateWithAi = { - tracksSheetVisible = false - aiTranslateVisible = true - }, - tabletopPaneHeight = tabletopPaneHeight, - ) + if (subtitleSearchVisible) { + SubtitleSearchSheet( + tools = subtitleTools, + defaultLanguage = LanguageNames.searchCode(state.preferredTextLanguage), + onSearch = viewModel::searchSubtitles, + onDownload = viewModel::downloadSubtitle, + onDismiss = { + subtitleSearchVisible = false + viewModel.onSearchSheetClosed() + }, + onBack = { + subtitleSearchVisible = false + tracksSheetVisible = true + viewModel.onSearchSheetClosed() + }, + tabletopPaneHeight = tabletopPaneHeight, + ) + } + + if (aiTranslateVisible) { + AiTranslateSheet( + tools = subtitleTools, + subtitleTracks = state.subtitleTracks, + audioTracks = state.audioTracks, + defaultTargetLanguage = LanguageNames.searchCode(state.preferredTextLanguage), + onRefreshQuota = viewModel::refreshAiQuota, + onSubmit = viewModel::startAiJob, + onCancelJob = viewModel::cancelAiJob, + onDismiss = { + aiTranslateVisible = false + viewModel.onTranslateSheetClosed() + }, + onBack = { + aiTranslateVisible = false + tracksSheetVisible = true + viewModel.onTranslateSheetClosed() + }, + tabletopPaneHeight = tabletopPaneHeight, + ) + } - if (subtitleSearchVisible) { - SubtitleSearchSheet( - tools = subtitleTools, - defaultLanguage = LanguageNames.searchCode(state.preferredTextLanguage), - onSearch = viewModel::searchSubtitles, - onDownload = viewModel::downloadSubtitle, - onDismiss = { - subtitleSearchVisible = false - viewModel.onSearchSheetClosed() + if (qualitySheetVisible && hasQualityMenu) { + PlaybackQualitySheet( + options = qualityOptions, + activeId = activeQualityId, + inRoom = inRoom, + onSelect = onPickQuality, + onDismiss = { qualitySheetVisible = false }, + tabletopPaneHeight = tabletopPaneHeight, + ) + } + + if (versionSheetVisible && hasVersionMenu) { + VersionSelector( + versions = state.versions, + selectedIndex = state.selectedVersionIndex, + onSelect = onSelectVersion, + onDismiss = { versionSheetVisible = false }, + tabletopPaneHeight = tabletopPaneHeight, + ) + } + + // Glass-style playback settings sheet (speed / aspect / HDR / auto-skip / auto-play) + PlayerSettingsSheet( + isVisible = settingsSheetVisible, + onDismiss = { settingsSheetVisible = false }, + playbackSpeed = playbackSpeed, + onSetPlaybackSpeed = viewModel::onSetPlaybackSpeed, + videoGravity = videoGravity, + onSetVideoGravity = viewModel::onSetVideoGravity, + letterboxExpansion = viewModel.letterboxExpansion.collectAsState().value, + onSetLetterboxExpansion = viewModel::onSetLetterboxExpansion, + introSkipMode = viewModel.introSkipMode.collectAsState().value, + onSetIntroSkipMode = viewModel::onSetIntroSkipMode, + autoPlayNextEnabled = viewModel.autoPlayNextEnabled.collectAsState().value, + onSetAutoPlayNext = viewModel::onSetAutoPlayNext, + hdrEnabled = viewModel.hdrEnabled.collectAsState().value, + onSetHdrEnabled = viewModel::onSetHdrEnabled, + dolbyVisionEnabled = viewModel.dolbyVisionEnabled.collectAsState().value, + onSetDolbyVisionEnabled = viewModel::onSetDolbyVisionEnabled, + // A party hides speed (session-only 1x) and the version picker. + showPlaybackSpeed = !inRoom, + showQuality = hasQualityMenu, + qualityLabel = playerQualityLabel(qualityOptions, activeQualityId), + onOpenQuality = { + settingsSheetVisible = false + qualitySheetVisible = true }, - onBack = { - subtitleSearchVisible = false + showVersion = hasVersionMenu, + versionLabel = playerVersionLabel(state.versions, state.selectedVersionIndex), + onOpenVersion = { + settingsSheetVisible = false + versionSheetVisible = true + }, + audioLabel = playerAudioLabel(state.audioTracks, state.selectedAudioIndex), + subtitleLabel = playerSubtitleLabel(state.subtitleTracks, state.selectedSubtitleIndex), + subtitleStyleLabel = subtitleStyleSummary(viewModel.subtitleAppearance.collectAsState().value), + onOpenTracks = { tab -> + settingsSheetVisible = false + tracksInitialTab = tab tracksSheetVisible = true - viewModel.onSearchSheetClosed() }, + onOpenSubtitleStyle = { + settingsSheetVisible = false + subtitleStyleVisible = true + }, + onOpenSleepTimer = { + settingsSheetVisible = false + sleepTimerVisible = true + }, + stats = state.stats, + onOpenPlaybackStats = { + settingsSheetVisible = false + statsSheetVisible = true + }, + audioDelayMs = viewModel.audioDelayMs.collectAsState().value, + audioDelayEnabled = state.playbackPlan?.claims?.audio?.passthrough != true, + onSetAudioDelay = viewModel::onSetAudioDelay, + subtitleDelayMs = viewModel.subtitleDelayMs.collectAsState().value, + onSetSubtitleDelay = viewModel::onSetSubtitleDelay, + sleepTimerState = sleepTimerState, tabletopPaneHeight = tabletopPaneHeight, ) - } - if (aiTranslateVisible) { - AiTranslateSheet( - tools = subtitleTools, - subtitleTracks = state.subtitleTracks, - audioTracks = state.audioTracks, - defaultTargetLanguage = LanguageNames.searchCode(state.preferredTextLanguage), - onRefreshQuota = viewModel::refreshAiQuota, - onSubmit = viewModel::startAiJob, - onCancelJob = viewModel::cancelAiJob, - onDismiss = { - aiTranslateVisible = false - viewModel.onTranslateSheetClosed() - }, + PlaybackStatsSheet( + isVisible = statsSheetVisible, + stats = state.stats, + onDismiss = { statsSheetVisible = false }, onBack = { - aiTranslateVisible = false - tracksSheetVisible = true - viewModel.onTranslateSheetClosed() + statsSheetVisible = false + settingsSheetVisible = true }, + sessionId = state.sessionId, + playMethod = state.playMethod?.name, + positionLabel = formatClockTime(state.position) + " / " + formatClockTime(state.duration), tabletopPaneHeight = tabletopPaneHeight, + playbackContext = org.prairieserver.prairie.playback.stats.PlaybackStatsContext( + plan = state.playbackPlan, + playMethod = state.playMethod, + audioTrack = state.audioTracks.getOrNull(state.selectedAudioIndex)?.let { track -> + org.prairieserver.prairie.playback.stats.describeAudioTrackForStats( + ordinal = state.selectedAudioIndex, + language = track.language, + codec = track.codec, + channels = track.channels, + title = track.title, + ) + }, + streamUrl = state.streamUrl, + qualityPreference = playerQualityLabel(qualityOptions, activeQualityId), + ), ) - } - if (showQualitySelector) { - QualitySelector( - versions = state.versions, - selectedIndex = state.selectedVersionIndex, - onSelect = onSelectVersion, - onDismiss = { showQualitySelector = false }, - qualityOptions = state.qualityOptions, - selectedQualityId = state.selectedQualityId, - onSelectQuality = onSelectQuality, + // Chapters picker — opened from the HUD chapters button (HUD product + // decision: chapters + tracks + quality; no longer reachable from the + // gear sheet). Selecting a row seeks the player to the chapter's + // startSeconds. The HUD button hides when the active version has no + // embedded chapters. + ChaptersSheet( + isVisible = chaptersSheetVisible, + chapters = state.chapters, + position = state.position, + duration = state.duration, + // A room seek in a party (a guest is told only the host can seek). + onSelect = { idx -> + viewModel.onSeekToChapter(idx)?.let(onSeek) + }, + onDismiss = { chaptersSheetVisible = false }, tabletopPaneHeight = tabletopPaneHeight, ) - } - // Glass-style playback settings sheet (speed / aspect / HDR / auto-skip / auto-play) - PlayerSettingsSheet( - isVisible = settingsSheetVisible, - onDismiss = { settingsSheetVisible = false }, - playbackSpeed = playbackSpeed, - onSetPlaybackSpeed = viewModel::onSetPlaybackSpeed, - videoGravity = videoGravity, - onSetVideoGravity = viewModel::onSetVideoGravity, - letterboxExpansion = viewModel.letterboxExpansion.collectAsState().value, - onSetLetterboxExpansion = viewModel::onSetLetterboxExpansion, - introSkipMode = viewModel.introSkipMode.collectAsState().value, - onSetIntroSkipMode = viewModel::onSetIntroSkipMode, - autoPlayNextEnabled = viewModel.autoPlayNextEnabled.collectAsState().value, - onSetAutoPlayNext = viewModel::onSetAutoPlayNext, - hdrEnabled = viewModel.hdrEnabled.collectAsState().value, - onSetHdrEnabled = viewModel::onSetHdrEnabled, - dolbyVisionEnabled = viewModel.dolbyVisionEnabled.collectAsState().value, - onSetDolbyVisionEnabled = viewModel::onSetDolbyVisionEnabled, - qualityLabel = playerQualityLabel(state.versions, state.selectedVersionIndex), - onOpenQuality = { - settingsSheetVisible = false - showQualitySelector = true - }, - audioLabel = playerAudioLabel(state.audioTracks, state.selectedAudioIndex), - subtitleLabel = playerSubtitleLabel(state.subtitleTracks, state.selectedSubtitleIndex), - onOpenTracks = { - settingsSheetVisible = false - tracksSheetVisible = true - }, - onOpenSubtitleStyle = { - settingsSheetVisible = false - subtitleStyleVisible = true - }, - onOpenSleepTimer = { - settingsSheetVisible = false - sleepTimerVisible = true - }, - stats = state.stats, - onOpenPlaybackStats = { - settingsSheetVisible = false - statsSheetVisible = true - }, - audioDelayMs = viewModel.audioDelayMs.collectAsState().value, - audioDelayEnabled = state.playbackPlan?.claims?.audio?.passthrough != true, - onSetAudioDelay = viewModel::onSetAudioDelay, - subtitleDelayMs = viewModel.subtitleDelayMs.collectAsState().value, - onSetSubtitleDelay = viewModel::onSetSubtitleDelay, - sleepTimerState = sleepTimerState, - tabletopPaneHeight = tabletopPaneHeight, - ) - - PlaybackStatsSheet( - isVisible = statsSheetVisible, - stats = state.stats, - onDismiss = { statsSheetVisible = false }, - onBack = { - statsSheetVisible = false - settingsSheetVisible = true - }, - sessionId = state.sessionId, - playMethod = state.playMethod?.name, - positionLabel = formatClockTime(state.position) + " / " + formatClockTime(state.duration), - tabletopPaneHeight = tabletopPaneHeight, - playbackContext = org.prairieserver.prairie.playback.stats.PlaybackStatsContext( - plan = state.playbackPlan, - playMethod = state.playMethod, - audioTrack = state.audioTracks.getOrNull(state.selectedAudioIndex)?.let { track -> - org.prairieserver.prairie.playback.stats.describeAudioTrackForStats( - ordinal = state.selectedAudioIndex, - language = track.language, - codec = track.codec, - channels = track.channels, - title = track.title, - ) + // Subtitle styling sheet — opened from the "Subtitle Style" row in + // PlayerSettingsSheet. Material 3 sheets can't nest, so the parent sheet + // dismisses itself before we open this one. + SubtitleStyleSheet( + isVisible = subtitleStyleVisible, + appearance = viewModel.subtitleAppearance.collectAsState().value, + showTextOpacity = viewModel.subtitleTextOpacitySupported.collectAsState().value, + onUpdate = viewModel::onEditSubtitleAppearance, + onDismiss = { subtitleStyleVisible = false }, + onBack = { + subtitleStyleVisible = false + settingsSheetVisible = true }, - streamUrl = state.streamUrl, - qualityPreference = state.qualityOptions - .firstOrNull { it.id.equals(state.selectedQualityId, ignoreCase = true) } - ?.label - ?: state.selectedQualityId, - ), - ) - - // Chapters picker — opened from the HUD chapters button (HUD product - // decision: chapters + tracks + quality; no longer reachable from the - // gear sheet). Selecting a row seeks the player to the chapter's - // startSeconds. The HUD button hides when the active version has no - // embedded chapters. - ChaptersSheet( - isVisible = chaptersSheetVisible, - chapters = state.chapters, - position = state.position, - onSelect = { idx -> - viewModel.onSeekToChapter(idx)?.let { sec -> viewModel.onSeek(sec) } - }, - onDismiss = { chaptersSheetVisible = false }, - tabletopPaneHeight = tabletopPaneHeight, - ) - - // Subtitle styling sheet — opened from the "Subtitle Style" row in - // PlayerSettingsSheet. Material 3 sheets can't nest, so the parent sheet - // dismisses itself before we open this one. - SubtitleStyleSheet( - isVisible = subtitleStyleVisible, - appearance = viewModel.subtitleAppearance.collectAsState().value, - onUpdate = viewModel::onSetSubtitleAppearance, - onDismiss = { subtitleStyleVisible = false }, - onBack = { - subtitleStyleVisible = false - settingsSheetVisible = true - }, - tabletopPaneHeight = tabletopPaneHeight, - ) + tabletopPaneHeight = tabletopPaneHeight, + ) - // Sleep timer picker — opened from the "Sleep Timer" row in - // PlayerSettingsSheet. Same nested-sheet caveat as Subtitle Style above. - SleepTimerSheet( - isVisible = sleepTimerVisible, - activeState = sleepTimerState, - defaultMinutes = sleepTimerDefault, - onStart = viewModel::onStartSleepTimer, - onCancel = viewModel::onCancelSleepTimer, - onDismiss = { sleepTimerVisible = false }, - onBack = { - sleepTimerVisible = false - settingsSheetVisible = true - }, - tabletopPaneHeight = tabletopPaneHeight, - ) + // Sleep timer picker — opened from the "Sleep Timer" row in + // PlayerSettingsSheet. Same nested-sheet caveat as Subtitle Style above. + SleepTimerSheet( + isVisible = sleepTimerVisible, + activeState = sleepTimerState, + defaultMinutes = sleepTimerDefault, + onStart = viewModel::onStartSleepTimer, + onCancel = viewModel::onCancelSleepTimer, + onDismiss = { sleepTimerVisible = false }, + onBack = { + sleepTimerVisible = false + settingsSheetVisible = true + }, + tabletopPaneHeight = tabletopPaneHeight, + ) + } + } } /** @@ -740,28 +862,77 @@ fun PlayerOverlay( */ @Composable private fun SleepTimerChip(remainingSeconds: Int) { - Row( - modifier = Modifier - .background( - color = Color.Black.copy(alpha = 0.55f), - shape = RoundedCornerShape(20.dp), - ) - .padding(horizontal = 14.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { + PlayerStatusChip { Icon( - imageVector = Icons.Outlined.Bedtime, + imageVector = Icons.Rounded.Bedtime, contentDescription = "Sleep timer active", - tint = Color.White, + tint = PlayerChrome.Paper, modifier = Modifier.size(14.dp), ) Spacer(modifier = Modifier.width(6.dp)) - Text( - text = formatRemaining(remainingSeconds), - color = Color.White, - fontSize = 13.sp, - ) + Text(text = formatRemaining(remainingSeconds), style = PlayerType.Time) + } +} + +/** A small smoked capsule for status that sits on the picture. */ +@Composable +private fun PlayerStatusChip(content: @Composable RowScope.() -> Unit) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(20.dp)) + .background(Color.Black.copy(alpha = 0.55f)) + .border(1.dp, PlayerChrome.DiscStroke, RoundedCornerShape(20.dp)) + .padding(horizontal = 14.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + content = content, + ) +} + +/** + * The small line above the title: "MOVIE · 2026", or "SEVERANCE · S2:E4" for + * an episode, whose title is then the episode's own. + */ +internal fun playerEyebrow(state: PlayerViewModel.PlayerUiState): String { + val series = state.seriesTitle?.takeIf { it.isNotBlank() } + if (series != null) { + val code = if (state.seasonNumber != null && state.episodeNumber != null) { + "S${state.seasonNumber}:E${state.episodeNumber}" + } else { + null + } + return listOfNotNull(series, code).joinToString(" · ") + } + val kind = when { + state.contentType == "movie" -> "Movie" + else -> null } + return listOfNotNull(kind, state.subtitle.takeIf { it.isNotBlank() }).joinToString(" · ") +} + +/** The value on the Audio & Subtitles pill: the subtitle language when one is on. */ +internal fun playerTracksValue(state: PlayerViewModel.PlayerUiState): String? = + if (state.selectedSubtitleIndex >= 0) { + subtitleTrackShortLabel(state.subtitleTracks.getOrNull(state.selectedSubtitleIndex), state.selectedSubtitleIndex) + } else { + null + } + +/** "Large · White · Box" for the settings menu's Subtitle style row. */ +internal fun subtitleStyleSummary(appearance: org.prairieserver.prairie.model.settings.SubtitleAppearance): String { + val size = when (appearance.fontSize) { + org.prairieserver.prairie.model.settings.SubtitleFontSizePreset.Small -> "Small" + org.prairieserver.prairie.model.settings.SubtitleFontSizePreset.Medium -> "Medium" + org.prairieserver.prairie.model.settings.SubtitleFontSizePreset.Large -> "Large" + org.prairieserver.prairie.model.settings.SubtitleFontSizePreset.XLarge -> "Extra large" + else -> "Largest" + } + val background = when (appearance.backgroundStyle) { + org.prairieserver.prairie.model.settings.SubtitleBackgroundStylePreset.Box -> "Box" + org.prairieserver.prairie.model.settings.SubtitleBackgroundStylePreset.Shadow -> "Shadow" + org.prairieserver.prairie.model.settings.SubtitleBackgroundStylePreset.Outline -> "Outline" + else -> "No background" + } + return "$size · $background" } // Directional gravity steps, clamped at both ends (iOS nextVideoGravity / @@ -786,11 +957,14 @@ internal fun mobileVideoGravityLabel(value: String): String = when (value) { } /** - * Root-list values for the gear menu. These mirror what the quality and - * tracks sheets show when opened, so the menu can state the current pick + * Root-list values for the gear menu. These mirror what the quality, version, + * and tracks sheets show when opened, so the menu can state the current pick * without the user having to open anything. */ -internal fun playerQualityLabel( +internal fun playerQualityLabel(options: List, activeId: String?): String = + options.firstOrNull { it.id == activeId }?.name ?: "Auto" + +internal fun playerVersionLabel( versions: List, selectedIndex: Int, ): String { @@ -809,22 +983,8 @@ internal fun playerAudioLabel( val track = tracks.getOrNull(selectedIndex) ?: return "Default" // Language first, not title: a container's audio title is often the full // codec string ("ATSC A/52B (AC-3, E-AC-3)") and swamps the row. - val name = track.language?.takeIf { it.isNotBlank() }?.uppercase() - ?: track.title?.takeIf { it.isNotBlank() } - ?: "Audio ${selectedIndex + 1}" - val detail = listOfNotNull( - track.codec?.takeIf { it.isNotBlank() }?.uppercase(), - track.channels?.let(::audioChannelLabel), - ).joinToString(" ") - return if (detail.isBlank()) name else "$name · $detail" -} - -private fun audioChannelLabel(channels: Int): String = when (channels) { - 1 -> "Mono" - 2 -> "Stereo" - 6 -> "5.1" - 8 -> "7.1" - else -> "${channels}ch" + val presentation = audioTrackPresentation(track, selectedIndex) + return listOfNotNull(presentation.title, audioChannelsDisplayName(track.channels)).joinToString(" · ") } internal fun playerSubtitleLabel( @@ -832,6 +992,5 @@ internal fun playerSubtitleLabel( selectedIndex: Int, ): String { if (selectedIndex < 0) return "Off" - val track = tracks.getOrNull(selectedIndex) ?: return "Off" - return subtitleTrackLabel(track, selectedIndex) + return subtitleTrackShortLabel(tracks.getOrNull(selectedIndex), selectedIndex) ?: "Off" } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerProgressBar.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerProgressBar.kt index 2c90135ab5..a5c19a7efb 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerProgressBar.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerProgressBar.kt @@ -1,44 +1,62 @@ package org.prairieserver.prairie.android.ui.screens.player +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredHeight -import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Slider -import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableDoubleStateOf import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf 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 +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.setProgress +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import kotlin.math.roundToInt import org.prairieserver.prairie.android.ui.util.formatClockTime import org.prairieserver.prairie.common.player.TrickplayTileImage import org.prairieserver.prairie.model.catalog.TimeRange @@ -47,17 +65,16 @@ import org.prairieserver.prairie.playback.TrickplayInfo import org.prairieserver.prairie.playback.resolveTrickplayTile /** - * Seek bar with current/total time and a buffered-ahead track, mirroring - * iOS `MobilePlayerControls.progressSlider` plus web trickplay scrub previews: + * The seek bar. Like iOS, the whole bar is the handle: it grows from 5 to + * 11dp under the finger, a bubble above shows the target time, the chapter, + * and how far the scrub has moved, and a tick marks where it started. Chapters + * show as gaps in the bar rather than tick marks, and a light haptic fires as + * the scrub crosses into a new chapter. * - * - three track regions — played, buffered (safe to seek into), base; - * - detected marker ranges tinted as bands (intro cyan, recap green, - * credits orange, preview purple); - * - a 2dp chapter tick per chapter, drawn under the played fill; - * - while scrubbing, a preview bubble above the thumb with trickplay tiles - * when available, the target time, and the chapter title at that point. + * Track regions: played (Paper), buffered (safe to seek into), base. Detected + * markers are tinted bands under the played fill (intro cyan, recap green, + * credits orange, preview purple). */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun PlayerProgressBar( position: Double, @@ -72,54 +89,212 @@ fun PlayerProgressBar( credits: TimeRange? = null, recap: TimeRange? = null, preview: TimeRange? = null, + showTimes: Boolean = true, + onScrubbingChange: (Boolean) -> Unit = {}, ) { var isSeeking by remember { mutableStateOf(false) } - var seekPosition by remember { mutableFloatStateOf(0f) } + var seekPosition by remember { mutableDoubleStateOf(0.0) } + var scrubStart by remember { mutableDoubleStateOf(0.0) } var barWidthPx by remember { mutableFloatStateOf(0f) } + var bubbleWidthPx by remember { mutableIntStateOf(0) } + var bubbleHeightPx by remember { mutableIntStateOf(0) } + var lastChapter by remember { mutableIntStateOf(-1) } + var showTotal by remember { mutableStateOf(false) } + val haptics = LocalHapticFeedback.current + val density = LocalDensity.current + val latestOnSeek by rememberUpdatedState(onSeek) + val latestOnScrubbing by rememberUpdatedState(onScrubbingChange) + // The gesture coroutine outlives recompositions, so it reads these + // through state rather than the values it started with. + val latestPosition by rememberUpdatedState(position) + val latestChapters by rememberUpdatedState(chapters) val hasKnownDuration = duration.isFinite() && duration > 0.0 - val maxDuration = if (hasKnownDuration) duration.toFloat() else 1f - val rawDisplayPosition = if (isSeeking) seekPosition else position.toFloat() - val displayPosition = if (hasKnownDuration) { - rawDisplayPosition.coerceIn(0f, maxDuration) - } else { - rawDisplayPosition.coerceAtLeast(0f) - } - val sliderPosition = if (hasKnownDuration) displayPosition else 0f - val playedFraction = if (hasKnownDuration) displayPosition / maxDuration else 0f + val seekable = enabled && hasKnownDuration + val displayPosition = (if (isSeeking) seekPosition else position) + .let { if (hasKnownDuration) it.coerceIn(0.0, duration) else it.coerceAtLeast(0.0) } + val playedFraction = if (hasKnownDuration) (displayPosition / duration).toFloat() else 0f val bufferedFraction = if (hasKnownDuration) { - (bufferedPosition.toFloat().coerceIn(0f, maxDuration) / maxDuration) - .coerceIn(playedFraction, 1f) + (bufferedPosition.coerceIn(0.0, duration) / duration).toFloat().coerceAtLeast(playedFraction) } else { 0f } val trickplayTile = if (isSeeking) { - resolveTrickplayTile(trickplay, seekPosition.toDouble()) + resolveTrickplayTile(trickplay, seekPosition) } else { null } - Column( - modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Box(modifier = Modifier.fillMaxWidth()) { - if (isSeeking) { - val density = LocalDensity.current - val barWidthDp = with(density) { barWidthPx.toDp() } - val clampPad = 80.dp - val bubbleX = (barWidthDp * playedFraction) - .coerceIn(clampPad, (barWidthDp - clampPad).coerceAtLeast(clampPad)) - val chapterTitle = chapterTitleAt(chapters, seekPosition.toDouble()) + val barHeight by animateDpAsState( + targetValue = if (isSeeking) 11.dp else 5.dp, + animationSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow), + label = "seekBarHeight", + ) + val timesAlpha by animateFloatAsState(if (isSeeking) 0f else 1f, label = "seekTimesAlpha") + + fun positionAt(x: Float): Double = + if (barWidthPx <= 0f) 0.0 else (x / barWidthPx).coerceIn(0f, 1f).toDouble() * duration + + fun chapterIndexAt(seconds: Double): Int = latestChapters.indexOfLast { it.startSeconds <= seconds } + + Column(modifier = modifier.fillMaxWidth()) { + if (showTimes) { + Row( + modifier = Modifier + .fillMaxWidth() + .alpha(timesAlpha), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = formatClockTime(displayPosition), + style = PlayerType.Time, + modifier = Modifier.weight(1f), + ) + // Tap to switch between time left and total length (iOS). + Text( + text = if (showTotal && hasKnownDuration) { + formatClockTime(duration) + } else { + remainingTimeLabel(displayPosition, duration) + }, + style = PlayerType.Time.copy(color = PlayerChrome.Graphite), + modifier = Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClickLabel = "Switch between time left and total length", + ) { showTotal = !showTotal }, + ) + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(36.dp) + .onSizeChanged { barWidthPx = it.width.toFloat() } + .semantics { + contentDescription = "Playback position" + stateDescription = "${formatClockTime(displayPosition)} of ${formatClockTime(duration)}" + if (hasKnownDuration) { + progressBarRangeInfo = ProgressBarRangeInfo( + current = displayPosition.toFloat(), + range = 0f..duration.toFloat(), + ) + } + if (seekable) { + setProgress { target -> + latestOnSeek(target.toDouble().coerceIn(0.0, duration)) + true + } + } + } + .pointerInput(seekable, duration) { + if (!seekable) return@pointerInput + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + down.consume() + scrubStart = latestPosition + seekPosition = positionAt(down.position.x) + lastChapter = chapterIndexAt(seekPosition) + isSeeking = true + latestOnScrubbing(true) + var pointerId = down.id + // A restart of this block (seekability or duration + // changed mid-scrub) cancels the gesture: end the + // scrub without seeking so the chrome comes back. + try { + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == pointerId } + ?: event.changes.firstOrNull()?.also { pointerId = it.id } + ?: break + if (!change.pressed) { + change.consume() + break + } + if (change.positionChange() != Offset.Zero) { + change.consume() + seekPosition = positionAt(change.position.x) + val chapter = chapterIndexAt(seekPosition) + if (chapter != lastChapter) { + lastChapter = chapter + haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + } + } + } finally { + isSeeking = false + latestOnScrubbing(false) + } + latestOnSeek(seekPosition) + } + }, + contentAlignment = Alignment.CenterStart, + ) { + val markers = listOfNotNull( + intro?.let { it to Color(0xFF22D3EE) }, + recap?.let { it to Color(0xFF8BC34A) }, + credits?.let { it to Color(0xFFFFB74D) }, + preview?.let { it to Color(0xFFBA68C8) }, + ) + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(barHeight + 12.dp), + ) { + drawSeekBar( + barHeightPx = barHeight.toPx(), + playedFraction = playedFraction, + bufferedFraction = bufferedFraction, + chapterFractions = if (hasKnownDuration) { + chapters.map { (it.startSeconds / duration).toFloat() } + } else { + emptyList() + }, + markers = if (hasKnownDuration) { + markers.map { (range, color) -> + Triple((range.start / duration).toFloat(), (range.end / duration).toFloat(), color) + } + } else { + emptyList() + }, + startFraction = if (isSeeking && hasKnownDuration) (scrubStart / duration).toFloat() else null, + showHead = seekable && !isSeeking, + dimmed = !enabled, + ) + } + + if (isSeeking && hasKnownDuration) { + val x = barWidthPx * playedFraction + val half = bubbleWidthPx / 2f + val clampedX = if (barWidthPx > bubbleWidthPx) x.coerceIn(half, barWidthPx - half) else barWidthPx / 2f + val delta = seekPosition - scrubStart + val chapterTitle = chapterTitleAt(chapters, seekPosition) Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier - .offset(x = bubbleX - clampPad) - .padding(bottom = 4.dp) - .clip(RoundedCornerShape(8.dp)) - .background(Color.Black.copy(alpha = 0.82f)) - .padding(horizontal = 12.dp, vertical = 6.dp), + .wrapContentSize(unbounded = true) + .onSizeChanged { + bubbleWidthPx = it.width + bubbleHeightPx = it.height + } + .offset { + // Prairie: a trickplay tile makes the bubble taller; + // grow it upward so it never covers the bar. + val lift = with(density) { + maxOf(52.dp.toPx(), bubbleHeightPx + 8.dp.toPx()) + } + IntOffset( + x = (clampedX - half).roundToInt(), + y = -lift.roundToInt(), + ) + } + .clip(RoundedCornerShape(14.dp)) + .background(PlayerChrome.Smoke) + .border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(14.dp)) + .padding(horizontal = 14.dp, vertical = 9.dp), ) { + // Prairie: trickplay scrub preview when the server has tiles. if (trickplayTile != null) { TrickplayTileImage( tile = trickplayTile, @@ -127,148 +302,92 @@ fun PlayerProgressBar( modifier = Modifier.padding(bottom = 4.dp), ) } + Text(text = formatClockTime(seekPosition), style = PlayerType.BubbleTime) Text( - text = formatClockTime(seekPosition.toDouble()), - fontSize = 19.sp, - fontWeight = FontWeight.Bold, - fontFamily = FontFamily.Monospace, - color = Color.White, + text = listOfNotNull(chapterTitle, formatSeekDelta(delta)).joinToString(" · "), + style = PlayerType.BubbleDetail, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(top = 2.dp) + .widthIn(max = 220.dp), ) - chapterTitle?.let { title -> - Text( - text = title, - modifier = Modifier.widthIn(max = 160.dp), - fontSize = 12.sp, - color = Color.White, - textAlign = TextAlign.Center, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } } } } + } +} - Slider( - value = sliderPosition, - enabled = enabled && hasKnownDuration, - onValueChange = { value -> - isSeeking = true - seekPosition = value - }, - onValueChangeFinished = { - isSeeking = false - onSeek(seekPosition.toDouble()) - }, - valueRange = 0f..maxDuration, - colors = SliderDefaults.colors( - thumbColor = MaterialTheme.colorScheme.primary, - activeTrackColor = MaterialTheme.colorScheme.primary, - inactiveTrackColor = Color.White.copy(alpha = 0.3f), - ), - thumb = { - Box( - modifier = Modifier - .size(if (isSeeking) 20.dp else 14.dp) - .background(MaterialTheme.colorScheme.primary, CircleShape), - ) - }, - track = { - Box( - modifier = Modifier - .fillMaxWidth() - .height(6.dp) - .clip(CircleShape) - .background(Color.White.copy(alpha = 0.16f)) - .onSizeChanged { barWidthPx = it.width.toFloat() }, - ) { - Box( - modifier = Modifier - .fillMaxWidth(bufferedFraction) - .fillMaxHeight() - .background(Color.White.copy(alpha = 0.52f)), - ) - // Marker bands — tinted segments for each detected marker - // kind (intro/recap/credits/preview), drawn under the played - // fill so the playhead still reads clearly over them. - if (hasKnownDuration) { - val density = LocalDensity.current - val barWidthDp = with(density) { barWidthPx.toDp() } - val markers = listOfNotNull( - intro?.let { it to Color.Cyan }, - recap?.let { it to Color(0xFF8BC34A) }, - credits?.let { it to Color(0xFFFFB74D) }, - preview?.let { it to Color(0xFFBA68C8) }, - ) - markers.forEach { (range, color) -> - val startFraction = (range.start / maxDuration).toFloat().coerceIn(0f, 1f) - val endFraction = (range.end / maxDuration).toFloat().coerceIn(startFraction, 1f) - if (endFraction > startFraction) { - Box( - modifier = Modifier - .offset(x = barWidthDp * startFraction) - .width(barWidthDp * (endFraction - startFraction)) - .fillMaxHeight() - .background(color.copy(alpha = 0.4f)), - ) - } - } - } - // Chapter ticks, under the played fill (iOS: the fill - // covers ticks in played territory). - if (hasKnownDuration && chapters.isNotEmpty()) { - val density = LocalDensity.current - val barWidthDp = with(density) { barWidthPx.toDp() } - chapters.forEach { chapter -> - val fraction = (chapter.startSeconds / maxDuration).toFloat() - if (fraction > 0f && fraction < 1f) { - Box( - modifier = Modifier - .offset(x = barWidthDp * fraction - 1.dp) - .width(2.dp) - .fillMaxHeight() - .background(Color.White.copy(alpha = 0.72f)), - ) - } - } - } - Box( - modifier = Modifier - .fillMaxWidth(playedFraction) - .fillMaxHeight() - .background(MaterialTheme.colorScheme.primary), - ) - } - }, - // Keep a generous invisible touch target around the visual track. - // This makes fine seeking practical on a phone without turning the - // timeline itself into a chunky Material slider. - modifier = Modifier - .fillMaxWidth() - .requiredHeight(48.dp), - ) +private fun DrawScope.drawSeekBar( + barHeightPx: Float, + playedFraction: Float, + bufferedFraction: Float, + chapterFractions: List, + markers: List>, + startFraction: Float?, + showHead: Boolean, + dimmed: Boolean, +) { + val width = size.width + val top = (size.height - barHeightPx) / 2f + val radius = CornerRadius(barHeightPx / 2f) + val gap = 2.dp.toPx() + // Chapter boundaries become gaps only while every segment stays long + // enough to read as a segment; a title with dozens of short chapters + // falls back to one continuous bar. + val boundaries = chapterFractions.filter { it > 0.001f && it < 0.999f }.sorted() + val edges = (listOf(0f) + boundaries + listOf(1f)).map { it * width } + val useGaps = boundaries.isNotEmpty() && + edges.zipWithNext().all { (a, b) -> b - a >= barHeightPx * 2f + gap } + val segments = if (useGaps) edges.zipWithNext() else listOf(0f to width) + val alpha = if (dimmed) 0.35f else 1f - // Current + remaining is more useful during playback than current + - // total, especially when the controls are separated from the video in - // tabletop posture. - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = formatClockTime(displayPosition.toDouble()), - fontSize = 13.sp, - fontFamily = FontFamily.Monospace, - color = Color.White.copy(alpha = 0.86f), - ) - Text( - text = remainingTimeLabel(displayPosition.toDouble(), duration), - fontSize = 13.sp, - fontFamily = FontFamily.Monospace, - color = Color.White.copy(alpha = 0.86f), - ) + segments.forEachIndexed { index, (rawStart, rawEnd) -> + val start = if (index == 0) rawStart else rawStart + gap / 2f + val end = if (index == segments.lastIndex) rawEnd else rawEnd - gap / 2f + val segmentSize = Size(end - start, barHeightPx) + val segmentTopLeft = Offset(start, top) + fun fill(color: Color, untilX: Float, fromX: Float = start) { + if (untilX <= fromX) return + clipRect(left = fromX, top = 0f, right = untilX.coerceAtMost(end), bottom = size.height) { + drawRoundRect(color = color, topLeft = segmentTopLeft, size = segmentSize, cornerRadius = radius) + } } + drawRoundRect( + color = Color.White.copy(alpha = 0.22f * alpha), + topLeft = segmentTopLeft, + size = segmentSize, + cornerRadius = radius, + ) + fill(Color.White.copy(alpha = 0.22f * alpha), bufferedFraction * width) + markers.forEach { (from, to, color) -> + fill(color.copy(alpha = 0.45f * alpha), untilX = to * width, fromX = maxOf(start, from * width)) + } + fill(PlayerChrome.Paper.copy(alpha = alpha), playedFraction * width) + } + + startFraction?.let { fraction -> + val x = fraction * width + val tickWidth = 2.dp.toPx() + drawRoundRect( + color = PlayerChrome.Paper.copy(alpha = 0.55f), + topLeft = Offset(x - tickWidth / 2f, top - 5.dp.toPx()), + size = Size(tickWidth, barHeightPx + 10.dp.toPx()), + cornerRadius = CornerRadius(tickWidth / 2f), + ) } + + if (showHead) { + val center = Offset(playedFraction * width, size.height / 2f) + drawCircle(color = Color.Black.copy(alpha = 0.35f), radius = 8.dp.toPx(), center = center) + drawCircle(color = PlayerChrome.Paper, radius = 6.5.dp.toPx(), center = center) + } +} + +private fun formatSeekDelta(delta: Double): String? { + if (kotlin.math.abs(delta) < 1.0) return null + val sign = if (delta > 0) "+" else "−" + return sign + formatClockTime(kotlin.math.abs(delta)) } internal fun remainingTimeLabel(position: Double, duration: Double): String = diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerScreen.kt index a1bfe62880..3013502426 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerScreen.kt @@ -56,6 +56,7 @@ import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.repeatOnLifecycle import androidx.media3.common.Player @@ -105,7 +106,30 @@ import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo import org.prairieserver.prairie.model.playback.SubtitleIdentity import org.prairieserver.prairie.model.playback.executableMedia3ClientTransformations import org.prairieserver.prairie.model.playback.activeOriginalHttpClaims -import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.RoomPlaybackState +import org.prairieserver.prairie.android.cast.PrairieCastState +import org.prairieserver.prairie.android.ui.navigation.Route +import org.prairieserver.prairie.common.player.watchparty.IssuedSeekTracker +import org.prairieserver.prairie.common.player.watchparty.WatchPartyPlayback +import org.prairieserver.prairie.common.player.watchparty.watchPartyNoticeText +import org.prairieserver.prairie.android.ui.screens.watchparty.WatchPartyPanelSheet +import org.prairieserver.prairie.android.ui.screens.watchparty.formatWatchPartyNames +import org.prairieserver.prairie.android.ui.screens.watchparty.rememberWatchPartyReconnectNotice +import org.prairieserver.prairie.android.ui.screens.watchparty.watchPartyLeftBehindNames +import org.prairieserver.prairie.android.ui.screens.watchparty.watchPartyWaitingNames +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.ServerRegistry +import org.prairieserver.prairie.repository.WatchTogetherRepository +import org.prairieserver.prairie.watchtogether.RoomSession +import org.prairieserver.prairie.watchtogether.WatchPartyAvailability +import org.prairieserver.prairie.watchtogether.WatchPartyAvailabilityRepository +import org.prairieserver.prairie.watchtogether.watchPartyEligibility +import org.prairieserver.prairie.watchtogether.watchPartyErrorMessage +import org.prairieserver.prairie.watchtogether.watchPartyInviteUrl +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.saveable.rememberSaveable import com.google.common.util.concurrent.MoreExecutors import kotlinx.coroutines.delay import kotlinx.coroutines.isActive @@ -113,6 +137,10 @@ import kotlinx.coroutines.launch import org.prairieserver.prairie.android.cast.PrairieCastButton import org.prairieserver.prairie.android.cast.PrairieCastOverlay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull import kotlin.math.roundToInt import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel @@ -121,6 +149,8 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Surface +import androidx.compose.material3.TextButton +import androidx.compose.foundation.layout.Row import androidx.compose.ui.unit.sp private const val TAG = "PlayerScreen" @@ -132,6 +162,18 @@ private const val TrackSelectionSettleMs = 1_500L internal fun shouldClearPlaybackOnControllerDispose(isChangingConfigurations: Boolean): Boolean = !isChangingConfigurations +/** A Watch Party player never casts: it sees no Cast session at all. */ +private val NoCastInWatchParty = PrairieCastState() + +/** How long a Watch Party player waits for its room before giving up. */ +private const val ROOM_PRESENCE_TIMEOUT_MS = 5_000L + +/** How long a one-time room event ("The party kept playing") stays on screen. */ +private const val ROOM_EVENT_NOTICE_MS = 4_000L + +/** Room position samples: fresh enough that drift decisions (0.35 s deadband) are not skewed. */ +private const val ROOM_ENGINE_SAMPLE_MS = 100L + @Composable private fun PlayerClockScope( viewModel: PlayerViewModel, @@ -203,10 +245,12 @@ fun PlayerScreen( initialAudioTrackIndex: Int? = null, initialSubtitleTrackIndex: Int? = null, resumePositionOverride: Double? = null, - // Watch Together room id, present when this player was launched into a - // synchronized room. When set, a RoomSyncController binds this player to the - // room (clock sync, transport mirroring, gating, room_closed exit). + // Watch Party room id, present when this player was launched into a + // party. When set, a WatchPartyPlayback binds this player to the room and + // the room's playback context, not the route, decides what plays. roomId: String? = null, + // The running shuffle this item is a pick of (see PlayerViewModel.attachShuffle). + shuffleId: String? = null, navController: NavHostController, viewModel: PlayerViewModel = koinViewModel(), ) { @@ -296,7 +340,11 @@ fun PlayerScreen( // "casting to " overlay takes over; on disconnect, local playback // resumes at the remote position (Fix 6). val castManager: org.prairieserver.prairie.android.cast.PrairieCastSessionManager = koinInject() - val castState by castManager.castState.collectAsState() + val inRoom = !roomId.isNullOrBlank() + val liveCastState by castManager.castState.collectAsState() + // Cast stays out of a Watch Party (D7): no button, no pause or resume on + // connect, no takeover overlay. + val castState = if (inRoom) NoCastInWatchParty else liveCastState val castScope = rememberCoroutineScope() var wasCasting by remember { mutableStateOf(false) } val tabletopPaneLayout = remember(tabletopPosture, playerRootBounds, density.density) { @@ -313,19 +361,21 @@ fun PlayerScreen( val useTabletopPlayerLayout = tabletopPaneLayout != null && !isInPictureInPictureMode && !castState.isConnected - // Watch Together binding. Built once per roomId; null for solo playback. - // The process RoomSession owns the WS; this controller owns only the - // screen's RoomSyncEngine and requests durable teardown on explicit leave. - val watchTogetherRepository: org.prairieserver.prairie.repository.WatchTogetherRepository = koinInject() - val roomSession: org.prairieserver.prairie.watchtogether.RoomSession = koinInject() + // Watch Party binding. Built once per roomId; null for solo playback. The + // process-wide RoomSession owns the room socket; this screen owns only the + // playback binding, and disposing it never leaves the party. + val watchTogetherRepository: WatchTogetherRepository = koinInject() + val roomSession: RoomSession = koinInject() + val watchPartyAvailability: WatchPartyAvailabilityRepository = koinInject() val roomScope = rememberCoroutineScope() - val roomController = remember(roomId) { + val watchParty = remember(roomId) { roomId?.takeIf { it.isNotBlank() }?.let { id -> - RoomSyncController( + WatchPartyPlayback( roomId = id, repository = watchTogetherRepository, roomSession = roomSession, - viewModel = viewModel, + availability = watchPartyAvailability, + player = MobileRoomPlayerPort(viewModel), scope = roomScope, ) } @@ -341,20 +391,207 @@ fun PlayerScreen( subtitle = subtitle, ) } - DisposableEffect(roomController) { - roomController?.start() + DisposableEffect(watchParty) { + watchParty?.start() onDispose { - // Cancel only this replaceable controller's collectors. The - // application RoomSession persists until explicit leave. - roomController?.dispose() + // Stops only this screen's binding. The application RoomSession + // keeps the party until an explicit leave. + watchParty?.dispose() + } + } + val roomSnapshot by watchParty?.room.collectOrDefault(null) + val roomCatchingUp by watchParty?.catchingUp.collectOrDefault(false) + val roomOfferLowerQuality by watchParty?.offerLowerQuality.collectOrDefault(false) + val roomSuspended by viewModel.roomSuspended.collectAsState() + + // Leaves the player the way Back does. With nothing behind the player + // (launcher, deep link, or notification open) popBackStack can't land + // anywhere and leaves a blank NavHost, so finish cleanly instead. Runs once. + fun exitPlayer() { + if (exitRequested) return + exitRequested = true + viewModel.onExit() + if (!navController.popBackStack()) activity?.finish() + } + + // Replaces the player with the Watch Party hub: after process death (the + // room token was memory-only, so nothing is re-adopted; the hub offers + // Rejoin) and when the party ended (the hub explains why). + fun replacePlayerWithHub() { + if (exitRequested) return + exitRequested = true + viewModel.onExit() + if (!navController.popBackStack(Route.WatchPartyHub.ROUTE, inclusive = false)) { + navController.navigate(Route.WatchPartyHub().route) { + popUpTo(Route.Player.ROUTE) { inclusive = true } + } + } + } + + // Every play/pause input in a party. While playback is held locally + // (audio focus, sleep timer, background) and the room plays, Play resumes + // this device alone and the binding re-syncs it; no room request is sent. + fun requestRoomPlayPause(party: WatchPartyPlayback, pause: Boolean) { + if (!pause && viewModel.roomSuspended.value) { + val snapshot = party.room.value + val roomPlaying = snapshot != null && + !snapshot.isPaused && + snapshot.playbackState == RoomPlaybackState.Playing + viewModel.endRoomSuspensions(resumeLocally = roomPlaying) + if (snapshot == null || !snapshot.isPaused) return + } + party.requestPlayPause(pause) + } + + // The room, not the route, decides what plays: each new playback epoch + // (Start, Select, Promote, source fallback) loads the room's exact file, + // paused at the room position. + LaunchedEffect(watchParty) { + val party = watchParty ?: return@LaunchedEffect + party.playbackContext.collect { playback -> + if (playback != null && !exitRequested) viewModel.startRoomPlayback(playback) + } + } + + // Host Stop returns everyone to the retained lobby. An engagement that + // ends or moves elsewhere (another room, sign-out) closes the player. + LaunchedEffect(watchParty) { + val party = watchParty ?: return@LaunchedEffect + val id = roomId ?: return@LaunchedEffect + // A restored party route with no live membership (process death): + // open the hub, which offers Rejoin, instead of re-adopting silently. + if (party.room.value == null && party.ended.value == null) { + replacePlayerWithHub() + return@LaunchedEffect + } + val present = withTimeoutOrNull(ROOM_PRESENCE_TIMEOUT_MS) { + party.room.first { it?.roomId == id } + } + if (present == null) { + if (!exitRequested && party.ended.value == null) { + Toast.makeText(context, "This Watch Party isn't available anymore.", Toast.LENGTH_SHORT).show() + exitPlayer() + } + return@LaunchedEffect + } + party.room.collect { snapshot -> + if (exitRequested) return@collect + when { + // An ended party is explained by the effect below. + snapshot == null || snapshot.roomId != id -> if (party.ended.value == null) exitPlayer() + snapshot.phase == RoomPhase.Lobby -> { + if (snapshot.selfRole != MemberRole.Host) { + Toast.makeText(context, "The host stopped playback", Toast.LENGTH_SHORT).show() + } + exitRequested = true + viewModel.onExit() + navController.navigate(Route.WatchPartyLobby(id).route) { + popUpTo(Route.Player.ROUTE) { inclusive = true } + } + } + } } } - val roomSnapshot by produceRoomSnapshotState(roomController) - val roomClosedReason by produceRoomClosedState(roomController) + + // The party ended (the host ended it or left, or this profile joined on + // another device): leave the player for the hub, which says why and + // offers Rejoin when the party itself goes on. + LaunchedEffect(watchParty) { + val party = watchParty ?: return@LaunchedEffect + party.ended.filterNotNull().first() + replacePlayerWithHub() + } + + // In-playback status the room implies: who it waits for, whether this + // viewer is catching up, whether the host is away, and a reconnect that + // has lasted two seconds. Events that happen once (the party kept + // playing, it went on without someone) show briefly above it. + val roomConnection by watchTogetherRepository.connectionState.collectAsState() + val showRoomReconnect = rememberWatchPartyReconnectNotice( + disconnected = inRoom && roomSnapshot != null && !roomConnection.writable, + since = roomConnection.disconnectedAtMs, + ) + val roomStatus: String? = roomSnapshot?.let { room -> + val waitingFor = watchPartyWaitingNames(room) + when { + showRoomReconnect -> if (roomConnection.disconnectedAtMs == null) { + "Connecting to the party…" + } else { + "Reconnecting to the party…" + } + !room.hostConnected && room.selfRole != MemberRole.Host -> + "The host lost connection. The party ends in two minutes unless the host returns." + roomCatchingUp -> "Catching up to the party" + room.playbackState == RoomPlaybackState.Waiting -> if (waitingFor.isEmpty()) { + "Syncing playback" + } else { + "Syncing playback · Waiting for ${formatWatchPartyNames(waitingFor)}" + } + else -> null + } + } + var roomEventNotice by remember { mutableStateOf?>(null) } + LaunchedEffect(watchParty) { + val party = watchParty ?: return@LaunchedEffect + var previous = party.room.value + party.room.collect { next -> + val leftBehind = watchPartyLeftBehindNames(previous, next) + if (leftBehind.isNotEmpty()) { + roomEventNotice = SystemClock.elapsedRealtime() to + "Continuing without ${formatWatchPartyNames(leftBehind)}" + } + previous = next + } + } + LaunchedEffect(watchParty) { + val party = watchParty ?: return@LaunchedEffect + var wasCatchingUp = party.catchingUp.value + party.catchingUp.collect { catchingUp -> + if (catchingUp && !wasCatchingUp) { + roomEventNotice = SystemClock.elapsedRealtime() to "The party kept playing" + } + wasCatchingUp = catchingUp + } + } + LaunchedEffect(roomEventNotice?.first) { + val shown = roomEventNotice ?: return@LaunchedEffect + delay(ROOM_EVENT_NOTICE_MS) + if (roomEventNotice?.first == shown.first) roomEventNotice = null + } + + // Back in a party opens the party panel over the retained player; the + // panel's own actions decide whether the player goes away. + var partyPanelVisible by rememberSaveable { mutableStateOf(false) } + BackHandler(enabled = inRoom && !partyPanelVisible) { partyPanelVisible = true } + + // Denied, reconnecting, and undelivered inputs each show, repeats included. + LaunchedEffect(watchParty) { + val party = watchParty ?: return@LaunchedEffect + party.notices.collect { notice -> + Toast.makeText(context, watchPartyNoticeText(notice), Toast.LENGTH_SHORT).show() + } + } + + // A server refusal of the room's file goes to the room, which may move + // everyone to another source; the context then changes and playback + // reloads. Otherwise the error stays: this device never picks a file. + LaunchedEffect(watchParty) { + val party = watchParty ?: return@LaunchedEffect + viewModel.roomRefusal.filterNotNull().collect { refusal -> + viewModel.consumeRoomRefusal(refusal) + party.reportRefusal(refusal.context, refusal.fileId, refusal.reason) + } + } + + // A quality change restarts the binding's reload pacing and its offer. + LaunchedEffect(watchParty) { + val party = watchParty ?: return@LaunchedEffect + viewModel.roomQualityChanges.collect { party.binding.onQualityChanged() } + } // Per-session playback control socket (admin remote control). Bound for the // lifetime of a sessionId; the loop reconnects on its own and never - // interrupts playback. Separate from the Watch Together socket. + // interrupts playback. Separate from the Watch Party socket. val playbackRealtimeClient: org.prairieserver.prairie.network.PlaybackRealtimeClient = koinInject() LaunchedEffect(uiState.sessionId) { val id = uiState.sessionId ?: return@LaunchedEffect @@ -363,45 +600,32 @@ fun PlayerScreen( client = playbackRealtimeClient, viewModel = viewModel, scope = this, // cancelled when sessionId changes / screen leaves + // In a party, remote transport asks the room like any other input. + roomTransport = watchParty?.let { party -> + { action -> routeRemoteTransportToRoom(action, party, viewModel.uiState.value.isPaused) } + }, ).start() } - // Tell the VM about WT membership so the control socket gates transport - // (the room is authoritative); the VM's start request always has roomId=null. + // Mark party membership for the ViewModel's gates before the room's + // playback context arrives. LaunchedEffect(roomId) { - viewModel.setInWatchTogetherRoom(!roomId.isNullOrBlank()) + viewModel.setInWatchTogetherRoom(inRoom) } - // A remote "stop"/"terminate" command tears the screen down like a back press. + // A remote "stop"/"terminate" command tears the screen down like a back + // press. In a party it ends only this device's engagement; the room goes + // on (a host's leave starts the two-minute grace). LaunchedEffect(Unit) { viewModel.remoteStopRequests.collect { - exitRequested = true - roomController?.leave(closeRoom = false) - viewModel.onExit() - // Nothing behind the player (launcher/deep-link/notification open) → - // popBackStack can't land anywhere and leaves a blank NavHost, then - // system back exits from a grey screen. Finish cleanly instead. - if (!navController.popBackStack()) activity?.finish() + watchParty?.leave() + exitPlayer() } } - // room_closed (or server error) → leave the player back to detail. - LaunchedEffect(roomClosedReason) { - if (roomClosedReason != null && roomController != null) { - exitRequested = true - roomController.leave(closeRoom = false) - viewModel.onExit() - // Nothing behind the player (launcher/deep-link/notification open) → - // popBackStack can't land anywhere and leaves a blank NavHost, then - // system back exits from a grey screen. Finish cleanly instead. - if (!navController.popBackStack()) activity?.finish() - } - } - - // Surface transient Watch Together server rejections (e.g. a guest seek the - // server refuses) as a brief Toast. These flow on the repo errors stream and - // do NOT eject the user (room_closed is the only terminal signal). Only + // Surface transient Watch Party server rejections as a brief Toast. These + // flow on the repository's errors stream and never eject the user. Only // collected while bound to a room. - LaunchedEffect(roomController) { - if (roomController != null) { + LaunchedEffect(watchParty) { + if (watchParty != null) { watchTogetherRepository.errors.collect { message -> if (message.isNotBlank()) { Toast.makeText(context, message, Toast.LENGTH_SHORT).show() @@ -417,6 +641,18 @@ fun PlayerScreen( val playWhenReadyReconciliationGate = remember(mediaController, roomId) { PlayWhenReadyReconciliationGate() } + // Seeks this screen sends to Media3, so a party can tell a notification, + // headset, or Assistant seek from its own. + val issuedSeeks = remember { IssuedSeekTracker() } + val sampleRoomEngine: (Player) -> Unit = { player -> + viewModel.onRoomEngineSample( + positionMs = player.currentPosition, + bufferedPositionMs = player.bufferedPosition, + playWhenReady = player.playWhenReady, + isPlaying = player.isPlaying, + playbackState = player.playbackState, + ) + } // service. The video PlayerView binds its SurfaceView directly to this (NOT the // MediaController) so the engine receives proper surface-lifecycle callbacks // (surfaceCreated/Changed/Destroyed) and recovers across seek/recreate/rotation @@ -587,9 +823,14 @@ fun PlayerScreen( // with hold-to-2x as a transient override that never writes the setting. // Re-runs whenever the controller binds, the setting changes, or the user // presses/releases the phone player's fast-forward hold gesture. - LaunchedEffect(mediaController, preferredPlaybackSpeed, fastForwardHoldActive) { + // A party plays at 1x apart from the binding's temporary catch-up rate: the + // saved speed and hold-to-2x do not apply, and nothing is saved. + val roomCorrectionRate by viewModel.roomCorrectionRate.collectAsState() + LaunchedEffect(mediaController, preferredPlaybackSpeed, fastForwardHoldActive, inRoom, roomCorrectionRate) { val controller = mediaController ?: return@LaunchedEffect - if (fastForwardHoldActive) { + if (inRoom) { + controller.setPlaybackSpeed((roomCorrectionRate ?: 1.0).toFloat()) + } else if (fastForwardHoldActive) { controller.setPlaybackSpeed(2.0f) } else { controller.setPlaybackSpeed(preferredPlaybackSpeed.toFloat()) @@ -598,7 +839,11 @@ fun PlayerScreen( // Load content on first composition LaunchedEffect(contentId, initialFileId, initialQuality, initialAudioTrackIndex, initialSubtitleTrackIndex, resumePositionOverride) { + // A party starts from the room's playback context (above), never from + // the route, so nothing plays solo first. + if (inRoom) return@LaunchedEffect if (!viewModel.claimInitialRouteLoad()) return@LaunchedEffect + viewModel.attachShuffle(shuffleId) viewModel.loadContent( libraryId = libraryId, contentId = contentId, @@ -608,8 +853,6 @@ fun PlayerScreen( initialSubtitleTrackIndex = initialSubtitleTrackIndex, resumePositionOverride = resumePositionOverride, routeResumePositionSeconds = resumePositionOverride, - // Watch Together's synced anchor must land exactly — don't nudge it back. - suppressResumeRewind = !roomId.isNullOrBlank(), ) } @@ -777,6 +1020,7 @@ fun PlayerScreen( viewModel.onSubtitleMediaMountChanging() backend.mount(mediaSpec, playWhenReady = !viewModel.uiState.value.isPaused) viewModel.onSubtitleMediaMountApplied(MobileSubtitleMount(uiState.mediaMountGeneration, uiState.subtitleRefreshNonce)) + viewModel.onSubtitleCuesMounted(uiState.subtitleCueRevisions) viewModel.onMediaMountApplied(uiState.mediaMountGeneration) } @@ -845,6 +1089,7 @@ fun PlayerScreen( viewModel.onSubtitleMediaMountChanging() backend.refresh(mediaSpec) viewModel.onSubtitleMediaMountApplied(MobileSubtitleMount(uiState.mediaMountGeneration, uiState.subtitleRefreshNonce)) + viewModel.onSubtitleCuesMounted(uiState.subtitleCueRevisions) } // Sync play/pause from ViewModel to player without reclassifying this @@ -939,23 +1184,51 @@ fun PlayerScreen( val provenance = playWhenReadyReconciliationGate .onPlayWhenReadyChanged(playWhenReady, reason) provenance.followUpProgrammaticValue?.let { controller.playWhenReady = it } - if (!provenance.shouldReconcile) return - roomController - ?.onExternalPlayWhenReadyChanged(playWhenReady) - ?.let { authoritative -> - if (controller.playWhenReady != authoritative) { - if ( - playWhenReadyReconciliationGate - .requestProgrammaticChange(authoritative) - ) { - controller.playWhenReady = authoritative - } - } - } + val party = watchParty ?: return + // Audio focus loss and a noisy route pause this device + // only. The room hears nothing until the viewer resumes. + if (!playWhenReady && + (reason == Player.PLAY_WHEN_READY_CHANGE_REASON_AUDIO_FOCUS_LOSS || + reason == Player.PLAY_WHEN_READY_CHANGE_REASON_AUDIO_BECOMING_NOISY) + ) { + sampleRoomEngine(controller) + viewModel.beginRoomSuspension(PlayerViewModel.RoomSuspensionReason.AudioFocus) + return + } + if (!provenance.shouldReconcile) { + sampleRoomEngine(controller) + return + } + // A notification, headset, or Assistant play/pause: ask + // the room, then restore the room's state locally until + // its command arrives. + requestRoomPlayPause(party, pause = !playWhenReady) + val authoritative = !viewModel.uiState.value.isPaused + if (controller.playWhenReady != authoritative && + playWhenReadyReconciliationGate.requestProgrammaticChange(authoritative) + ) { + controller.playWhenReady = authoritative + } + // Sample only after the restore (the controller masks it + // at once), so a report never carries the outside change. + sampleRoomEngine(controller) + } + + override fun onPlaybackSuppressionReasonChanged(playbackSuppressionReason: Int) { + if (watchParty == null) return + val reason = PlayerViewModel.RoomSuspensionReason.TransientAudioFocus + if (playbackSuppressionReason == + Player.PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS + ) { + viewModel.beginRoomSuspension(reason) + } else { + viewModel.endRoomSuspension(reason) + } } override fun onIsPlayingChanged(isPlaying: Boolean) { viewModel.onPlayingChanged(isPlaying) + if (watchParty != null) sampleRoomEngine(controller) val live = viewModel.uiState.value val key = live.sessionId?.let { sessionId -> "$sessionId:${live.streamUrl}:${live.playbackPlan?.planId.orEmpty()}:" + @@ -976,6 +1249,7 @@ fun PlayerScreen( override fun onPlaybackStateChanged(playbackState: Int) { viewModel.onBufferingChanged(playbackState == Player.STATE_BUFFERING) + if (watchParty != null) sampleRoomEngine(controller) if (playbackState == Player.STATE_ENDED) { viewModel.onPlayingChanged(false) // F2 fallback: surface (or upgrade) the Up Next card if @@ -989,6 +1263,24 @@ fun PlayerScreen( newPosition: Player.PositionInfo, reason: Int, ) { + // Undo outside room seeks before publishing the engine + // position. applyRoomSeek marks the restore pending + // synchronously, so a host cannot report the unaccepted + // target while the Media3 seek travels back to the service. + val party = watchParty + if (party != null && + reason == Player.DISCONTINUITY_REASON_SEEK && + issuedSeeks.isExternal(newPosition.positionMs) && + !viewModel.isRoomMediaMounting() + ) { + party.onExternalSeek( + fromSeconds = viewModel.sourceSecondsForPlayerMs(oldPosition.positionMs), + toSeconds = viewModel.sourceSecondsForPlayerMs(newPosition.positionMs), + ) { restoreSeconds -> viewModel.applyRoomSeek(restoreSeconds) } + } + // Publish the actual engine position before the display + // update can clear seekPending for a landed restore. + if (party != null) sampleRoomEngine(controller) // Media3 emits this for completed seeks even while paused, // when onIsPlayingChanged and playback-state callbacks can // remain silent. Publish the settled engine position now. @@ -1053,6 +1345,7 @@ fun PlayerScreen( val targetIdentity = pendingIdentity ?: liveState.committedSubtitleIdentity val accepted = backend.selectMountedSubtitle(identity = targetIdentity) val selected = isSubtitleSelected(currentTracks, targetIdentity) + if (selected) viewModel.onMountedSubtitleSelected() if (pendingIdentity != null) { viewModel.onPendingSubtitleMountResult( mount = mount, @@ -1145,10 +1438,10 @@ fun PlayerScreen( renderedOutputBufferCount = decoderCounters?.renderedOutputBufferCount, )) { PostResumeVideoStallDetector.Signal.SeekBack -> { - controller.seekTo( - (controller.currentPosition - PostResumeVideoStallDetector.SEEK_BACK_MS) - .coerceAtLeast(0L), - ) + val target = (controller.currentPosition - PostResumeVideoStallDetector.SEEK_BACK_MS) + .coerceAtLeast(0L) + issuedSeeks.note(target) + controller.seekTo(target) viewModel.onRuntimeCorrection( "runtime_correction_applied", "client_post_resume_video_recovery_v1", @@ -1160,6 +1453,7 @@ fun PlayerScreen( val resume = controller.playWhenReady controller.stop() controller.prepare() + issuedSeeks.note(position) controller.seekTo(position) if (resume) controller.play() viewModel.onRuntimeCorrection( @@ -1211,16 +1505,62 @@ fun PlayerScreen( LaunchedEffect(mediaController) { val controller = mediaController ?: return@LaunchedEffect viewModel.seekRequests.collect { posSec -> - controller.seekTo((posSec * 1000).toLong()) + val targetMs = (posSec * 1000).toLong() + issuedSeeks.note(targetMs) + controller.seekTo(targetMs) } } - // Room-driven corrective seeks remain on a separate channel so sync - // corrections can stay unconditional and easy to reason about. + // Room and remote seeks remain on a separate channel so they stay + // unconditional and easy to reason about. LaunchedEffect(mediaController) { val controller = mediaController ?: return@LaunchedEffect viewModel.immediateSeeks.collect { posSec -> - controller.seekTo((posSec * 1000).toLong()) + val targetMs = (posSec * 1000).toLong() + issuedSeeks.note(targetMs) + controller.seekTo(targetMs) + } + } + + // Party only: sample the engine often enough that the room sees where + // the player really is. The display ticker above is too coarse for the + // binding's 0.35 s drift decisions and stays as it is. + LaunchedEffect(mediaController, lifecycleOwner, watchParty) { + val controller = mediaController ?: return@LaunchedEffect + if (watchParty == null) return@LaunchedEffect + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + while (isActive) { + if (controller.mediaItemCount > 0) sampleRoomEngine(controller) + delay(ROOM_ENGINE_SAMPLE_MS) + } + } + } + + // Party only: leaving the app is a local suspension. Playback stops, the + // room hears nothing, and returning re-syncs to the room. + DisposableEffect(lifecycleOwner, watchParty, mediaController, playWhenReadyReconciliationGate) { + if (watchParty == null) { + onDispose { } + } else { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_STOP -> if (activity?.isChangingConfigurations != true) { + viewModel.beginRoomSuspension(PlayerViewModel.RoomSuspensionReason.Background) + mediaController?.let { controller -> + if (controller.playWhenReady && + playWhenReadyReconciliationGate.requestProgrammaticChange(false) + ) { + controller.playWhenReady = false + } + } + } + Lifecycle.Event.ON_START -> + viewModel.endRoomSuspension(PlayerViewModel.RoomSuspensionReason.Background) + else -> Unit + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } } @@ -1283,7 +1623,8 @@ fun PlayerScreen( activity = activity, surface = PrairiePictureInPictureSurface.Mobile, state = PrairiePictureInPicturePlaybackState( - enabled = pictureInPictureEnabled, + // A party never enters picture-in-picture (D7). + enabled = pictureInPictureEnabled && !inRoom, videoActive = uiState.streamUrl != null && uiState.error == null, isPlaying = uiState.isPlaying && !uiState.isPaused, videoWidth = pictureInPictureVideoWidth, @@ -1355,6 +1696,24 @@ fun PlayerScreen( val controller = mediaController val videoGravity by viewModel.videoGravity.collectAsState() var playerViewRef by remember { mutableStateOf(null) } + // Left edge of a docked player menu (root px), null when none is + // open. The subtitle canvas pads its right side by the overlap so + // cues re-center in the picture that is still visible. + var dockedMenuEdgePx by remember { mutableStateOf(null) } + val latestDockedMenuEdgePx by rememberUpdatedState(dockedMenuEdgePx) + LaunchedEffect(playerViewRef, dockedMenuEdgePx) { + playerViewRef?.subtitleView?.let { padSubtitlesClearOfDock(it, dockedMenuEdgePx) } + } + // The subtitle manager resizes and moves the canvas on its own + // (video size, aspect, letterbox), so re-pad after each layout too. + DisposableEffect(playerViewRef) { + val subtitleView = playerViewRef?.subtitleView ?: return@DisposableEffect onDispose { } + val listener = android.view.View.OnLayoutChangeListener { view, _, _, _, _, _, _, _, _ -> + view.post { padSubtitlesClearOfDock(view, latestDockedMenuEdgePx) } + } + subtitleView.addOnLayoutChangeListener(listener) + onDispose { subtitleView.removeOnLayoutChangeListener(listener) } + } val letterboxExpansion by viewModel.letterboxExpansion.collectAsState() // The camera only reaches the picture once expansion pushes it out // to the edges — at FIT's 2560px the pillarbox already swallows it. @@ -1545,12 +1904,7 @@ fun PlayerScreen( onSelectSubtitle = { castManager.selectSubtitleTrack(it) }, onStopCasting = { castManager.disconnect() }, onSeek = { castManager.seekTo(it) }, - onBack = { - exitRequested = true - roomController?.leave(closeRoom = roomSnapshot?.isHost == true) - viewModel.onExit() - if (!navController.popBackStack()) activity?.finish() - }, + onBack = { exitPlayer() }, ) } @@ -1568,6 +1922,9 @@ fun PlayerScreen( state = uiState.withPlaybackClock(clock), viewModel = viewModel, roomSnapshot = roomSnapshot, + inRoom = inRoom, + roomSuspended = roomSuspended, + roomStatus = roomStatus, orientationLockSupported = orientationLockSupported && activeTabletopPaneLayout == null, alwaysShowControls = activeTabletopPaneLayout != null, @@ -1587,58 +1944,129 @@ fun PlayerScreen( } }, showBufferingIndicator = activeTabletopPaneLayout == null, - castSlot = { - PrairieCastButton( - castManager = castManager, - onStartCast = { - castScope.launch { - val spec = viewModel.prepareGoogleCastMedia() - if (spec != null) castManager.prepareMedia(spec) - } - }, - ) + castSlot = { discModifier -> + // No Cast in a party (D7). + if (!inRoom) { + PrairieCastButton( + modifier = discModifier, + castManager = castManager, + onStartCast = { + castScope.launch { + val spec = viewModel.prepareGoogleCastMedia() + if (spec != null) castManager.prepareMedia(spec) + } + }, + ) + } }, isFastForwardHoldActive = fastForwardHoldActive, onBack = { - // In-room exit: leave the room (host close confirm is handled - // by the overlay before this fires). The controller resets the - // repo + engine; solo playback just pops. - exitRequested = true - roomController?.leave(closeRoom = roomSnapshot?.isHost == true) - viewModel.onExit() - // Nothing behind the player (launcher/deep-link/notification - // open) → popBackStack can't land anywhere and leaves a blank - // NavHost, then system back exits from a grey screen. Finish - // cleanly instead. - if (!navController.popBackStack()) activity?.finish() + // In a party, Back opens the party panel over the + // retained player instead of leaving. + if (inRoom) partyPanelVisible = true else exitPlayer() }, onPlayPause = { - // In a room, route through transport_request (gated to - // controllers); solo playback toggles locally. - if (roomController != null) roomController.onUserPlayPause() - else viewModel.onPlayPause() - }, - onSeek = { position -> - if (roomController != null) { - // Guest seeks are no-ops in the controller; host seeks - // round-trip through the room and re-apply via a command. - roomController.onUserSeek(position) + // In a party every play/pause asks the room; the player + // changes when the room's command arrives. + val party = watchParty + if (party != null) { + requestRoomPlayPause(party, pause = !viewModel.uiState.value.isPaused) } else { - viewModel.onSeek(position) + viewModel.onPlayPause() } }, + onSeek = { position -> + // Host seeks round-trip through the room and land with + // its command; guests' seek controls are disabled. + val party = watchParty + if (party != null) party.requestSeek(position) else viewModel.onSeek(position) + }, onToggleControls = { viewModel.onToggleControls() }, onFastForwardHold = { active -> fastForwardHoldActive = active }, onSelectSubtitle = { viewModel.onSelectSubtitle(it) }, onSelectAudio = { viewModel.onSelectAudio(it) }, onSelectVersion = { viewModel.onSelectVersion(it) }, onSelectQuality = { viewModel.switchQuality(it) }, + pictureInPictureAvailable = activity?.let { + pictureInPictureCoordinator.canOfferPictureInPicture(it, PrairiePictureInPictureSurface.Mobile) + } == true, + onEnterPictureInPicture = { + activity?.let { + pictureInPictureCoordinator.enterPictureInPictureOnRequest( + it, + PrairiePictureInPictureSurface.Mobile, + ) + } + }, + onDockedMenuEdgeChanged = { edge -> dockedMenuEdgePx = edge }, modifier = playerOverlayModifier, ) } } } + // Party only: after repeated stalls, offer one step down the same + // file's quality ladder. Hidden when no lower rung exists. + var lowerQualityDeclined by remember { mutableStateOf(false) } + LaunchedEffect(roomOfferLowerQuality) { + if (!roomOfferLowerQuality) lowerQualityDeclined = false + } + if (roomOfferLowerQuality && + !lowerQualityDeclined && + uiState.versionSwitchMessage == null && + viewModel.lowerRoomQualityLabel() != null + ) { + Surface( + color = Color.Black.copy(alpha = 0.85f), + shape = RoundedCornerShape(12.dp), + modifier = Modifier + .align(Alignment.TopCenter) + .statusBarsPadding() + .padding(top = 16.dp) + .widthIn(max = 420.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 14.dp, end = 4.dp), + ) { + Text( + text = "Playback keeps stopping. Lower the quality?", + color = Color.White, + fontSize = 13.sp, + modifier = Modifier.weight(1f, fill = false), + ) + TextButton(onClick = { + lowerQualityDeclined = true + viewModel.lowerRoomQuality() + }) { Text("Lower") } + TextButton(onClick = { lowerQualityDeclined = true }) { Text("Not now") } + } + } + } + + // Party only: a one-time room event (the party kept playing, or went + // on without someone), shown briefly below the room status. + roomEventNotice?.let { (_, text) -> + if (!isInPictureInPictureMode) { + Surface( + color = Color.Black.copy(alpha = 0.78f), + shape = RoundedCornerShape(12.dp), + modifier = Modifier + .align(Alignment.TopCenter) + .statusBarsPadding() + .padding(top = 64.dp, start = 16.dp, end = 16.dp) + .widthIn(max = 420.dp), + ) { + Text( + text = text, + color = Color.White, + fontSize = 13.sp, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + ) + } + } + } + // Non-fatal quality/version-switch message: a dismissable pill over the // still-playing video, not a fatal full-screen error. uiState.versionSwitchMessage?.let { message -> @@ -1662,27 +2090,65 @@ fun PlayerScreen( } } } + + // The party panel (D2) over the retained player. Dismissing it never + // touches playback; only Leave and End take the player away. + val party = watchParty + if (party != null && partyPanelVisible) { + val availability by watchPartyAvailability.availability.collectAsState() + val serverRegistry: ServerRegistry = koinInject() + val activeServer by serverRegistry.activeEntry.collectAsState() + // Actions are not hidden while another request finishes; the room + // owner runs them one at a time. + val eligibility = watchPartyEligibility( + room = roomSnapshot, + features = (availability as? WatchPartyAvailability.Available)?.features, + busy = false, + personalVotesKnown = true, + ) + WatchPartyPanelSheet( + room = roomSnapshot, + eligibility = eligibility, + inviteUrl = roomSnapshot + ?.takeIf { it.selfRole == MemberRole.Host } + ?.let { room -> activeServer?.url?.let { watchPartyInviteUrl(it, room.invitePath) } }, + onReturnToLobby = { + partyPanelVisible = false + roomScope.launch { + val result = watchTogetherRepository.stopPlayback() + if (result !is ApiResult.Success) { + Toast.makeText( + context, + watchPartyErrorMessage(result, "Couldn't return everyone to the lobby."), + Toast.LENGTH_SHORT, + ).show() + } + } + }, + onEndForEveryone = { + partyPanelVisible = false + party.endForEveryone() + exitPlayer() + }, + onLeave = { + partyPanelVisible = false + party.leave() + exitPlayer() + }, + onDismiss = { partyPanelVisible = false }, + ) + } } /** - * Collects the room snapshot from [controller] when present, else holds null. - * A nullable StateFlow can't be `collectAsState`d directly, so this bridges to - * a stable [State] for both the in-room and solo cases. + * Collects a party-only flow, or holds [default] for solo playback. A nullable + * StateFlow can't be `collectAsState`d directly, so this bridges both cases to + * a stable [State]. */ @Composable -private fun produceRoomSnapshotState( - controller: RoomSyncController?, -): State { - val soloRoom = remember { MutableStateFlow(null) } - return (controller?.room ?: soloRoom).collectAsState() -} - -@Composable -private fun produceRoomClosedState( - controller: RoomSyncController?, -): State { - val soloClosedReason = remember { MutableStateFlow(null) } - return (controller?.closedReason ?: soloClosedReason).collectAsState() +private fun StateFlow?.collectOrDefault(default: T): State { + val solo = remember { MutableStateFlow(default) } + return (this ?: solo).collectAsState() } private fun PlaybackExecutionPlan?.validatedPassthroughCodecs(): List { @@ -1692,3 +2158,20 @@ private fun PlaybackExecutionPlan?.validatedPassthroughCodecs(): List { ?.let { listOf(it) } .orEmpty() } + +/** + * Pads the subtitle canvas's right side by its overlap with a docked player + * menu whose left edge is [dockedMenuEdgePx] (root px), so cues re-center in + * the picture that is still visible. Null clears the padding. + */ +private fun padSubtitlesClearOfDock(subtitleView: android.view.View, dockedMenuEdgePx: Float?) { + val location = IntArray(2) + subtitleView.getLocationInWindow(location) + val viewRight = location[0] + subtitleView.width + val overlap = dockedMenuEdgePx + ?.let { edge -> (viewRight - edge).toInt().coerceIn(0, subtitleView.width / 2) } + ?: 0 + if (subtitleView.paddingRight != overlap) { + subtitleView.setPadding(subtitleView.paddingLeft, subtitleView.paddingTop, overlap, subtitleView.paddingBottom) + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerSettingsSheet.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerSettingsSheet.kt index 764f3c7574..7a8a63058f 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerSettingsSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerSettingsSheet.kt @@ -1,70 +1,31 @@ package org.prairieserver.prairie.android.ui.screens.player -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.selection.toggleable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.filled.Check -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.res.stringResource import org.prairieserver.prairie.android.R import org.prairieserver.prairie.common.player.PlayerStatsSnapshot -import org.prairieserver.prairie.domain.player.IntroSkipMode -import org.prairieserver.prairie.common.settings.LetterboxExpansion import org.prairieserver.prairie.common.player.SleepTimerState +import org.prairieserver.prairie.common.settings.LetterboxExpansion +import org.prairieserver.prairie.domain.player.IntroSkipMode /** - * Where the sheet currently is. The gear opens [Root] — a flat list of the - * four things worth reading at a glance (quality, subtitles, audio) plus one - * door to everything else, the shape Plex uses. Sub-screens replace the sheet - * content in place rather than stacking new sheets, so there is only ever one - * surface over the video. - * - * Back targets are fixed rather than a stack: [PlaybackOptions] returns to - * [Root], every leaf returns to [PlaybackOptions]. + * Where the settings menu currently is. [Root] is one grouped list — Picture, + * Audio & Subtitles, This session, Advanced — with every current value in a + * right-aligned column, so the menu answers "what am I watching with?" without + * opening anything. Value pickers replace the list in place; menus with their + * own surface (quality, version, tracks, style, sleep timer, stats) hand over to it. */ private enum class SettingsRoute { Root, - PlaybackOptions, Speed, PictureSize, FillScreen, @@ -72,17 +33,9 @@ private enum class SettingsRoute { Sync, ; - val parent: SettingsRoute? - get() = when (this) { - Root -> null - PlaybackOptions -> Root - else -> PlaybackOptions - } - val title: String get() = when (this) { Root -> "Settings" - PlaybackOptions -> "Playback Options" Speed -> "Speed" PictureSize -> "Picture size" FillScreen -> "Fill the screen" @@ -92,7 +45,6 @@ private enum class SettingsRoute { } /** Playback settings menu shared by regular phones and tabletop mode. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun PlayerSettingsSheet( isVisible: Boolean, @@ -111,14 +63,21 @@ fun PlayerSettingsSheet( onSetHdrEnabled: (Boolean) -> Unit, dolbyVisionEnabled: Boolean, onSetDolbyVisionEnabled: (Boolean) -> Unit, - // Root-list values. The gear now reports the same three things the - // toolbar buttons open, so the menu answers "what am I watching with?" - // without opening anything. + // False in a Watch Party: speed is a session-only 1x. + showPlaybackSpeed: Boolean = true, + // Shown whenever there is a playback plan, in a Watch Party too. + showQuality: Boolean = true, + // Shown for a title with several files, except in a Watch Party, which + // plays exactly the room's file. + showVersion: Boolean = false, qualityLabel: String = "", onOpenQuality: () -> Unit = {}, + versionLabel: String = "", + onOpenVersion: () -> Unit = {}, audioLabel: String = "", subtitleLabel: String = "", - onOpenTracks: () -> Unit = {}, + subtitleStyleLabel: String = "", + onOpenTracks: (TracksTab) -> Unit = {}, onOpenSubtitleStyle: () -> Unit = {}, onOpenSleepTimer: () -> Unit = {}, stats: PlayerStatsSnapshot = PlayerStatsSnapshot(), @@ -133,418 +92,225 @@ fun PlayerSettingsSheet( ) { if (!isVisible) return - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val scope = rememberCoroutineScope() + val controller = rememberPlayerMenuController(onDismiss) var routeOrdinal by rememberSaveable { mutableIntStateOf(SettingsRoute.Root.ordinal) } val route = SettingsRoute.entries[routeOrdinal] - val dismissSheet = { scope.dismissPlayerSheet(sheetState, onDismiss) } - val leaveFor = { next: () -> Unit -> - scope.dismissPlayerSheet(sheetState, onDismiss, next) - } - - LaunchedEffect(isVisible) { - if (isVisible) sheetState.show() - } - - PlayerModalBottomSheet( - onDismissRequest = dismissSheet, - sheetState = sheetState, - tabletopPaneHeight = tabletopPaneHeight, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .playerSheetContent(tabletopPaneHeight) - .nestedScroll(PlayerSheetFlingGuard), - ) { - PlayerSheetHeader( - title = route.title, - onBack = route.parent?.let { parent -> { routeOrdinal = parent.ordinal } }, - onDismiss = dismissSheet, - ) - PlayerSheetDivider() + val toRoot = { routeOrdinal = SettingsRoute.Root.ordinal } + val handOff = { next: () -> Unit -> controller.dismiss(then = next) } - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(top = 2.dp, bottom = 18.dp), - ) { - when (route) { - SettingsRoute.Root -> { - ValueRow( - label = "Quality", - value = qualityLabel, - onClick = { leaveFor(onOpenQuality) }, - ) - ValueRow( - label = "Subtitles", - value = subtitleLabel, - onClick = { leaveFor(onOpenTracks) }, - ) - ValueRow( - label = "Audio", - value = audioLabel, - onClick = { leaveFor(onOpenTracks) }, - ) - ValueRow( - label = "Playback Options", - value = null, - onClick = { routeOrdinal = SettingsRoute.PlaybackOptions.ordinal }, - ) - } - - SettingsRoute.PlaybackOptions -> { - ValueRow( - label = "Speed", - value = "${formatPlaybackSpeed(playbackSpeed)}×", - onClick = { routeOrdinal = SettingsRoute.Speed.ordinal }, - ) - ValueRow( + PlayerMenu(controller = controller, tabletopPaneHeight = tabletopPaneHeight) { + PlayerPanelHeader( + title = route.title, + onClose = { controller.dismiss() }, + onBack = if (route == SettingsRoute.Root) null else toRoot, + backDescription = "Back to settings", + ) + PlayerMenuScrollColumn(horizontalPadding = 12.dp) { + when (route) { + SettingsRoute.Root -> { + PlayerSectionHeader("Picture") + PlayerGroup { + if (showQuality) { + PlayerValueRow("Quality", qualityLabel, onClick = { handOff(onOpenQuality) }) + } + if (showVersion) { + PlayerValueRow( + label = "Version", + value = versionLabel, + onClick = { handOff(onOpenVersion) }, + separator = showQuality, + ) + } + if (showPlaybackSpeed) { + PlayerValueRow( + label = "Speed", + value = speedLabel(playbackSpeed), + onClick = { routeOrdinal = SettingsRoute.Speed.ordinal }, + separator = showQuality || showVersion, + ) + } + PlayerValueRow( label = "Picture size", value = pictureSizeLabel(videoGravity), onClick = { routeOrdinal = SettingsRoute.PictureSize.ordinal }, + separator = showQuality || showVersion || showPlaybackSpeed, ) - ValueRow( + PlayerValueRow( label = "Fill the screen", value = letterboxExpansionLabel(letterboxExpansion), onClick = { routeOrdinal = SettingsRoute.FillScreen.ordinal }, + separator = true, + ) + } + + PlayerSectionHeader("Audio & subtitles") + PlayerGroup { + PlayerValueRow("Subtitles", subtitleLabel, onClick = { handOff { onOpenTracks(TracksTab.Subtitles) } }) + PlayerValueRow( + label = "Audio", + value = audioLabel, + onClick = { handOff { onOpenTracks(TracksTab.Audio) } }, + separator = true, + ) + PlayerValueRow( + label = "Subtitle style", + value = subtitleStyleLabel, + onClick = { handOff(onOpenSubtitleStyle) }, + separator = true, + ) + PlayerValueRow( + label = "Sync", + value = "${formatDelayMs(audioDelayMs)} · ${formatDelayMs(subtitleDelayMs)}", + onClick = { routeOrdinal = SettingsRoute.Sync.ordinal }, + separator = true, ) - ValueRow( + } + + PlayerSectionHeader("This session") + PlayerGroup { + PlayerValueRow( label = stringResource(R.string.settings_intro_skip_title), value = introSkipLabel(introSkipMode), onClick = { routeOrdinal = SettingsRoute.SkipIntros.ordinal }, ) - SwitchRow( - label = "Auto-play next episode", + PlayerSwitchRow( + label = "Auto-play next", checked = autoPlayNextEnabled, onCheckedChange = onSetAutoPlayNext, + separator = true, ) - ValueRow( - label = "Audio & subtitle sync", - value = "${formatDelayMs(audioDelayMs)} · ${formatDelayMs(subtitleDelayMs)}", - onClick = { routeOrdinal = SettingsRoute.Sync.ordinal }, - ) - ValueRow( - label = "Subtitle style", - value = null, - onClick = { leaveFor(onOpenSubtitleStyle) }, - ) - ValueRow( + PlayerValueRow( label = "Sleep timer", value = formatSleepTimerSubtitle(sleepTimerState), - onClick = { leaveFor(onOpenSleepTimer) }, + onClick = { handOff(onOpenSleepTimer) }, + separator = true, ) - SwitchRow( - label = "HDR", - checked = hdrEnabled, - onCheckedChange = onSetHdrEnabled, - ) - SwitchRow( + } + + PlayerSectionHeader("Advanced") + PlayerGroup { + PlayerSwitchRow(label = "HDR", checked = hdrEnabled, onCheckedChange = onSetHdrEnabled) + PlayerSwitchRow( label = "Dolby Vision", checked = dolbyVisionEnabled, onCheckedChange = onSetDolbyVisionEnabled, + separator = true, ) - ValueRow( + PlayerValueRow( label = "Stats for nerds", value = stats.summaryLabel(), - onClick = { leaveFor(onOpenPlaybackStats) }, + onClick = { handOff(onOpenPlaybackStats) }, + separator = true, ) } + } - SettingsRoute.Speed -> { - listOf(0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0).forEach { value -> - CheckRow( - label = "${formatPlaybackSpeed(value)}×", - isSelected = isSameSpeed(value, playbackSpeed), - onClick = { onSetPlaybackSpeed(value) }, - ) - } - } + SettingsRoute.Speed -> if (showPlaybackSpeed) { + ChoiceGroup( + options = SPEEDS, + isSelected = { isSameSpeed(it, playbackSpeed) }, + label = { speedLabel(it) }, + detail = { if (it == 1.0) "Normal" else null }, + onSelect = onSetPlaybackSpeed, + ) + } - SettingsRoute.PictureSize -> { - listOf("fit", "fill", "stretch").forEach { value -> - CheckRow( - label = pictureSizeLabel(value), - isSelected = videoGravity == value, - onClick = { onSetVideoGravity(value) }, - ) - } - } + SettingsRoute.PictureSize -> ChoiceGroup( + options = listOf("fit", "fill", "stretch"), + isSelected = { videoGravity == it }, + label = ::pictureSizeLabel, + onSelect = onSetVideoGravity, + ) - SettingsRoute.FillScreen -> { - listOf( + SettingsRoute.FillScreen -> { + ChoiceGroup( + options = listOf( LetterboxExpansion.ClearOfCamera, LetterboxExpansion.FullWidth, LetterboxExpansion.Off, - ).forEach { value -> - CheckRow( - label = letterboxExpansionLabel(value), - isSelected = letterboxExpansion == value, - onClick = { onSetLetterboxExpansion(value) }, - ) - } - FootnoteText( - "Widescreen films are expanded past the black bars stored in the " + - "file, never into the picture itself. Full width uses the whole " + - "display and lets the camera sit on the image. Video without " + - "stored bars already fits and does not change.", - ) - } + ), + isSelected = { letterboxExpansion == it }, + label = ::letterboxExpansionLabel, + onSelect = onSetLetterboxExpansion, + ) + PlayerFootnote( + "Widescreen films are expanded past the black bars stored in the " + + "file, never into the picture itself. Full width uses the whole " + + "display and lets the camera sit on the image. Video without " + + "stored bars already fits and does not change.", + ) + } - SettingsRoute.SkipIntros -> { - listOf( - IntroSkipMode.NEVER, - IntroSkipMode.ASK, - IntroSkipMode.ALWAYS, - ).forEach { value -> - CheckRow( - label = introSkipLabel(value), - isSelected = introSkipMode == value, - onClick = { onSetIntroSkipMode(value) }, - ) - } - FootnoteText( - "What happens when a detected intro starts: leave it alone, " + - "offer a Skip Intro button, or skip it and offer an undo.", - ) - } + SettingsRoute.SkipIntros -> { + val labels = listOf( + IntroSkipMode.NEVER to stringResource(R.string.settings_intro_skip_never), + IntroSkipMode.ASK to stringResource(R.string.settings_intro_skip_ask), + IntroSkipMode.ALWAYS to stringResource(R.string.settings_intro_skip_always), + ) + ChoiceGroup( + options = labels, + isSelected = { introSkipMode == it.first }, + label = { it.second }, + onSelect = { onSetIntroSkipMode(it.first) }, + ) + PlayerFootnote( + "What happens when a detected intro starts: leave it alone, " + + "offer a Skip Intro button, or skip it and offer an undo.", + ) + } - SettingsRoute.Sync -> { - DelaySpinnerRow( + SettingsRoute.Sync -> { + Spacer(Modifier.height(8.dp)) + PlayerGroup { + PlayerStepperRow( label = "Audio delay", - subtitle = "PCM audio only", - valueMs = audioDelayMs, + detail = if (audioDelayEnabled) "PCM audio only" else "Off for passthrough audio", + value = formatDelayMs(audioDelayMs), enabled = audioDelayEnabled, - stepMs = 50, - minMs = -5000, - maxMs = 5000, - onChange = onSetAudioDelay, + onDecrease = { onSetAudioDelay((audioDelayMs - 50).coerceIn(-5000, 5000)) }, + onIncrease = { onSetAudioDelay((audioDelayMs + 50).coerceIn(-5000, 5000)) }, ) - DelaySpinnerRow( + PlayerStepperRow( label = "Subtitle delay", - subtitle = "Move captions earlier or later", - valueMs = subtitleDelayMs, - stepMs = 50, - minMs = -10000, - maxMs = 10000, - onChange = onSetSubtitleDelay, + detail = "Move captions earlier or later", + value = formatDelayMs(subtitleDelayMs), + onDecrease = { onSetSubtitleDelay((subtitleDelayMs - 50).coerceIn(-10000, 10000)) }, + onIncrease = { onSetSubtitleDelay((subtitleDelayMs + 50).coerceIn(-10000, 10000)) }, + separator = true, ) } } } + Spacer(Modifier.height(10.dp)) } } } -/** Label · value · chevron. The single row shape the whole menu is built from. */ @Composable -private fun ValueRow( - label: String, - value: String?, - onClick: () -> Unit, +private fun ChoiceGroup( + options: List, + isSelected: (T) -> Boolean, + label: (T) -> String, + onSelect: (T) -> Unit, + detail: (T) -> String? = { null }, ) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .heightIn(min = 48.dp) - .padding(horizontal = 20.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = label, - color = Color.White, - fontSize = 15.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - // The value carries the slack so every chevron lands on the same right - // edge; an over-long track name truncates instead of shoving the label. - Text( - text = value.orEmpty(), - color = Color.White.copy(alpha = 0.55f), - fontSize = 15.sp, - textAlign = TextAlign.End, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - tint = Color.White.copy(alpha = 0.34f), - modifier = Modifier.size(20.dp), - ) - } -} - -@Composable -private fun SwitchRow( - label: String, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .toggleable( - value = checked, - onValueChange = onCheckedChange, - role = Role.Switch, - ) - .heightIn(min = 48.dp) - .padding(horizontal = 20.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = label, - color = Color.White, - fontSize = 15.sp, - modifier = Modifier.weight(1f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Switch( - checked = checked, - onCheckedChange = null, - colors = SwitchDefaults.colors( - checkedThumbColor = Color.Black, - checkedTrackColor = MaterialTheme.colorScheme.primary, - uncheckedThumbColor = Color.White.copy(alpha = 0.72f), - uncheckedTrackColor = Color.White.copy(alpha = 0.15f), - uncheckedBorderColor = Color.Transparent, - ), - ) - } -} - -/** Leaf-screen option: label on the left, a check on the selected one. */ -@Composable -private fun CheckRow( - label: String, - isSelected: Boolean, - onClick: () -> Unit, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .heightIn(min = 46.dp) - .padding(horizontal = 20.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = label, - color = Color.White, - fontSize = 15.sp, - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, - modifier = Modifier.weight(1f), - ) - if (isSelected) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = "Selected", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp), + Spacer(Modifier.height(8.dp)) + PlayerGroup { + options.forEachIndexed { index, option -> + PlayerCheckRow( + label = label(option), + detail = detail(option), + selected = isSelected(option), + onClick = { onSelect(option) }, + separator = index > 0, ) } } } -@Composable -private fun FootnoteText(text: String) { - Text( - text = text, - color = Color.White.copy(alpha = 0.5f), - fontSize = 12.sp, - lineHeight = 16.sp, - modifier = Modifier.padding(start = 20.dp, end = 20.dp, top = 12.dp), - ) -} +private val SPEEDS = listOf(0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0) -@Composable -private fun DelaySpinnerRow( - label: String, - subtitle: String, - valueMs: Int, - stepMs: Int, - minMs: Int, - maxMs: Int, - enabled: Boolean = true, - onChange: (Int) -> Unit, -) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 14.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Column { - Text( - text = label, - color = Color.White, - fontSize = 15.sp, - fontWeight = FontWeight.Medium, - ) - Text( - text = subtitle, - color = Color.White.copy(alpha = 0.48f), - fontSize = 12.sp, - ) - } - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.End, - ) { - SpinnerButton( - label = "−", - enabled = enabled, - onClick = { onChange((valueMs - stepMs).coerceIn(minMs, maxMs)) }, - ) - Text( - text = formatDelayMs(valueMs), - color = if (enabled) Color.White else Color.White.copy(alpha = 0.38f), - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - textAlign = TextAlign.Center, - modifier = Modifier.width(96.dp), - ) - SpinnerButton( - label = "+", - enabled = enabled, - onClick = { onChange((valueMs + stepMs).coerceIn(minMs, maxMs)) }, - ) - } - } -} - -@Composable -private fun SpinnerButton( - label: String, - enabled: Boolean = true, - onClick: () -> Unit, -) { - Box( - modifier = Modifier - .size(44.dp) - .clip(CircleShape) - .background(Color.White.copy(alpha = if (enabled) 0.10f else 0.04f)) - .clickable(enabled = enabled, onClick = onClick), - contentAlignment = Alignment.Center, - ) { - Text( - text = label, - color = if (enabled) Color.White else Color.White.copy(alpha = 0.30f), - fontSize = 20.sp, - fontWeight = FontWeight.Medium, - ) - } -} +private fun speedLabel(speed: Double): String = "${formatPlaybackSpeed(speed)}×" private fun pictureSizeLabel(value: String): String = when (value) { "fill" -> "Fill" @@ -574,7 +340,7 @@ private fun PlayerStatsSnapshot.summaryLabel(): String { val route = backendRoute ?: backendDisplayName return listOfNotNull(resolution, route, bitrateBps?.let(::formatStatsBitrate)) .take(2) - .joinToString(" - ") + .joinToString(" · ") .ifBlank { "Waiting for player data" } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerSheetSupport.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerSheetSupport.kt index a6aeb62366..9bfe1df608 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerSheetSupport.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerSheetSupport.kt @@ -116,14 +116,17 @@ internal fun Modifier.playerSheetContent(tabletopPaneHeight: Dp?): Modifier = internal fun playerSheetScrimColor(tabletopPaneHeight: Dp?): Color = if (tabletopPaneHeight == null) Color.Black.copy(alpha = 0.32f) else Color.Transparent -internal val PlayerSheetBackground = Color(0xFF090D12) -internal val PlayerSheetCardColor = Color(0xFF151B24) -internal val PlayerSheetSelectedColor = Color(0xFF10333D) -internal val PlayerSheetDividerColor = Color.White.copy(alpha = 0.08f) +// Opaque equivalents of the docked panel's smoke and its raised washes, for +// sheets: a portrait sheet covers the picture, so there is nothing to show +// through it. +internal val PlayerSheetBackground = Color(0xFF131417) +internal val PlayerSheetCardColor = Color(0xFF1C1D20) +internal val PlayerSheetSelectedColor = Color(0xFF26272B) +internal val PlayerSheetDividerColor = Color.White.copy(alpha = 0.07f) internal fun playerSheetShape(tabletopPaneHeight: Dp?): Shape = if (tabletopPaneHeight == null) { - RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp) + RoundedCornerShape(topStart = PlayerChrome.PanelRadius, topEnd = PlayerChrome.PanelRadius) } else { RectangleShape } @@ -213,9 +216,9 @@ internal val PlayerSheetFlingGuard = object : NestedScrollConnection { } /** - * Header row for the glass-style sheets. When [onBack] is provided (gear - * submenus), a leading chevron returns to the parent settings sheet — QA: - * submenus previously had no way back. + * Header for a player menu that carries a subtitle line (subtitle search, AI + * subtitles). Same chrome as [PlayerPanelHeader]: back chevron when there is a + * parent menu, close disc on the right. */ @Composable internal fun PlayerSheetHeader( @@ -224,54 +227,20 @@ internal fun PlayerSheetHeader( onDismiss: (() -> Unit)? = null, subtitle: String? = null, ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 52.dp) - .padding( - start = if (onBack != null) 4.dp else 20.dp, - end = if (onDismiss != null) 4.dp else 20.dp, - top = 2.dp, - bottom = 2.dp, - ), - ) { - if (onBack != null) { - IconButton(onClick = onBack) { - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowLeft, - contentDescription = "Back to playback settings", - tint = Color.White, - ) - } - } - Column(modifier = Modifier.weight(1f)) { + Column(modifier = Modifier.fillMaxWidth()) { + PlayerPanelHeader( + title = title, + onClose = onDismiss ?: {}, + onBack = onBack, + ) + if (subtitle != null) { Text( - text = title, - color = Color.White, - fontSize = 20.sp, - fontWeight = FontWeight.SemiBold, + text = subtitle, + style = PlayerType.RowDetail, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = if (onBack != null) 52.dp else 18.dp, end = 18.dp), ) - if (subtitle != null) { - Text( - text = subtitle, - color = Color.White.copy(alpha = 0.56f), - fontSize = 12.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - if (onDismiss != null) { - IconButton(onClick = onDismiss) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = "Close menu", - tint = Color.White.copy(alpha = 0.82f), - ) - } } } } @@ -289,21 +258,6 @@ internal fun PlayerSheetCard( ) } -@Composable -internal fun PlayerSheetSectionLabel( - text: String, - modifier: Modifier = Modifier, -) { - Text( - text = text.uppercase(), - color = Color.White.copy(alpha = 0.52f), - fontSize = 11.sp, - fontWeight = FontWeight.SemiBold, - letterSpacing = 0.8.sp, - modifier = modifier.padding(start = 16.dp, end = 16.dp, top = 14.dp, bottom = 8.dp), - ) -} - @Composable internal fun PlayerSheetDivider(modifier: Modifier = Modifier) { Box( diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerTrackLabels.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerTrackLabels.kt new file mode 100644 index 0000000000..0c702197e1 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerTrackLabels.kt @@ -0,0 +1,114 @@ +package org.prairieserver.prairie.android.ui.screens.player + +import java.util.Locale +import org.prairieserver.prairie.model.catalog.AudioTrack +import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo +import org.prairieserver.prairie.player.subtitleTrackLabelParts +import org.prairieserver.prairie.player.trackLanguageDisplayName + +/** A track laid out for a picker row: title, chips beside it, detail below. */ +internal data class TrackPresentation( + val title: String, + val chips: List = emptyList(), + val detail: String? = null, +) + +/** + * Audio tracks are titled by language, not by whatever the muxer wrote into + * the track title. Container titles are very often a codec description + * ("ATSC A/52B (AC-3, E-AC-3)"), which says nothing a viewer can choose by; + * the codec is shown in plain words in the detail line instead. A title that + * does describe the track ("Director's commentary") is kept as the first + * detail. + */ +internal fun audioTrackPresentation(track: AudioTrack, index: Int): TrackPresentation { + val language = trackLanguageDisplayName(track.language) + val descriptor = track.title + ?.trim() + ?.takeIf { it.isNotBlank() && !isCodecDescription(it) && !it.equals(language, ignoreCase = true) } + val title = language ?: descriptor ?: "Audio ${index + 1}" + val detail = listOfNotNull( + descriptor.takeIf { language != null }, + audioCodecDisplayName(track.codec), + audioChannelsDisplayName(track.channels), + "Default".takeIf { track.isDefault }, + ).joinToString(" · ").ifBlank { null } + return TrackPresentation(title = title, detail = detail) +} + +internal fun subtitleTrackPresentation( + sub: PlayerSubtitleInfo, + index: Int, + status: String? = null, +): TrackPresentation { + val parts = subtitleTrackLabelParts( + rawLabel = sub.label, + language = sub.language, + codecOrMime = sub.codec, + isForced = sub.forced == true, + ) + val title = parts.languageName ?: parts.descriptor ?: "Subtitle ${index + 1}" + val chips = buildList { + if (parts.aiGenerated) add("AI") + if (parts.forced) add("Forced") + if (parts.sdh) add("SDH") + } + val detail = listOfNotNull( + parts.descriptor.takeIf { parts.languageName != null }, + parts.format, + parts.provider ?: subtitleSourceLabel(sub), + status, + ).distinctBy { it.lowercase(Locale.US) }.joinToString(" · ").ifBlank { null } + return TrackPresentation(title = title, chips = chips, detail = detail) +} + +/** The short value for the controls: "English", "English · SDH". */ +internal fun subtitleTrackShortLabel(sub: PlayerSubtitleInfo?, index: Int): String? { + sub ?: return null + val presentation = subtitleTrackPresentation(sub, index) + return (listOf(presentation.title) + presentation.chips).joinToString(" · ") +} + +private fun subtitleSourceLabel(sub: PlayerSubtitleInfo): String? = + when ((sub.source ?: sub.catalogSource)?.lowercase(Locale.US)) { + "embedded" -> "Embedded" + "external" -> "External" + "downloaded" -> "Downloaded" + "server_artifact" -> "Server" + else -> null + } + +internal fun audioCodecDisplayName(codec: String?): String? { + val clean = codec?.trim()?.lowercase(Locale.US)?.takeIf { it.isNotBlank() } ?: return null + return when { + clean == "eac3" || clean == "e-ac-3" || clean.contains("ec-3") -> "Dolby Digital Plus" + clean == "ac3" || clean == "ac-3" -> "Dolby Digital" + clean.contains("truehd") || clean == "mlp" -> "Dolby TrueHD" + clean.startsWith("dts") -> "DTS" + clean == "aac" || clean.startsWith("mp4a") -> "AAC" + clean == "flac" -> "FLAC" + clean == "opus" -> "Opus" + clean == "vorbis" -> "Vorbis" + clean == "alac" -> "ALAC" + clean.startsWith("pcm") -> "PCM" + else -> clean.uppercase(Locale.US) + } +} + +internal fun audioChannelsDisplayName(channels: Int?): String? = when (channels) { + null, 0 -> null + 1 -> "Mono" + 2 -> "Stereo" + 6 -> "5.1" + 7 -> "6.1" + 8 -> "7.1" + else -> "$channels ch" +} + +private val codecDescription = Regex( + """(?i)\b(atsc|a/52|e-?ac-?3|ac-?3|aac|dts(-hd)?|truehd|flac|pcm|lpcm|opus|mp3|vorbis|alac|dolby|surround|stereo|mono|atmos|\d\.\d|\d+\s?ch)\b""", +) +private val descriptiveWords = Regex("""(?i)\b(commentary|description|descriptive|director|dub|dubbed|original|isolated|score|karaoke)\b""") + +private fun isCodecDescription(title: String): Boolean = + codecDescription.containsMatchIn(title) && !descriptiveWords.containsMatchIn(title) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerViewModel.kt index 0ad31c068d..4fc8c0077e 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerViewModel.kt @@ -23,6 +23,7 @@ import org.prairieserver.prairie.common.player.PlaybackSessionLifecycle import org.prairieserver.prairie.common.player.PlaybackSessionManager import org.prairieserver.prairie.common.player.PlaybackTeardownGate import org.prairieserver.prairie.common.player.VideoSessionStartV3 +import org.prairieserver.prairie.common.player.subtitlesForVideoMediaMount import org.prairieserver.prairie.common.player.cast.CastMediaSpec import org.prairieserver.prairie.common.player.cast.CastPrepareRequest import org.prairieserver.prairie.common.player.PlayerNotice @@ -46,6 +47,7 @@ import org.prairieserver.prairie.common.player.seek.replanMountPositionForSource import org.prairieserver.prairie.common.player.seek.sourcePositionForPlayer import org.prairieserver.prairie.common.player.video.VideoPlaybackSessionCoordinator import org.prairieserver.prairie.common.player.video.VideoPlaybackStartRequest +import org.prairieserver.prairie.common.player.video.MarkerReconciliationFence import org.prairieserver.prairie.common.player.video.NextUpTransitionGate import org.prairieserver.prairie.common.player.video.VideoPlayerRouteArgs import org.prairieserver.prairie.common.player.video.VideoPlayerUiState @@ -65,6 +67,7 @@ import org.prairieserver.prairie.model.settings.SubtitleAppearance import org.prairieserver.prairie.model.playback.PlayMethod import org.prairieserver.prairie.model.playback.PlaybackDelivery import org.prairieserver.prairie.model.playback.PlaybackExecutionPlan +import org.prairieserver.prairie.model.playback.playbackQualityMenu import org.prairieserver.prairie.model.playback.executableMedia3ClientTransformations import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo import org.prairieserver.prairie.model.playback.CommittedSubtitle @@ -75,7 +78,19 @@ import org.prairieserver.prairie.model.playback.mergeDownloadedSubtitles import org.prairieserver.prairie.model.playback.rebaseDownloadedSubtitleUrl import org.prairieserver.prairie.model.playback.resolvedSelectedSubtitleIndex import org.prairieserver.prairie.model.playback.resolvePlaybackStartPosition +import org.prairieserver.prairie.playback.PlaybackMarkersUpdate import org.prairieserver.prairie.playback.PlaybackSubtitleReady +import org.prairieserver.prairie.playback.PlaybackSubtitleTimingChanged +import org.prairieserver.prairie.playback.PlaybackSubtitleSyncUpdated +import org.prairieserver.prairie.playback.SubtitleSyncController +import org.prairieserver.prairie.playback.SubtitleSyncFeedbackTracker +import org.prairieserver.prairie.playback.SubtitleSyncNotice +import org.prairieserver.prairie.playback.SubtitleSyncUiState +import org.prairieserver.prairie.playback.feedbackInput +import org.prairieserver.prairie.playback.includesSyncKey +import org.prairieserver.prairie.playback.activeSyncKey +import org.prairieserver.prairie.model.subtitles.SubtitleSyncState +import org.prairieserver.prairie.common.player.subtitleSyncName import org.prairieserver.prairie.playback.applyAuthoritativeSubtitleReadyTrack import org.prairieserver.prairie.model.subtitles.SubtitleAiJob import org.prairieserver.prairie.model.subtitles.SubtitleAiQuota @@ -110,6 +125,9 @@ import org.prairieserver.prairie.repository.ProfileRepository import org.prairieserver.prairie.repository.SubtitlesRepository import org.prairieserver.prairie.repository.port.PlaybackWriteScope import org.prairieserver.prairie.repository.port.TrackSelectionFingerprintUpdate +import org.prairieserver.prairie.watchtogether.RoomPlayerObservation +import org.prairieserver.prairie.watchtogether.RoomPlayerState +import org.prairieserver.prairie.watchtogether.WatchPartyPlaybackContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job @@ -129,8 +147,10 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn @@ -254,16 +274,6 @@ internal fun selectedAudioTrackOrdinal( audioTracks: List, ): Int = selectedServerIndex.takeIf { it in audioTracks.indices } ?: 0 -private fun SubtitleIdentity.serverTrackIndexForMobile(): Int = when (this) { - SubtitleIdentity.Off -> -1 - is SubtitleIdentity.ServerSidecar -> serverIndex - is SubtitleIdentity.ServerBurnIn -> serverIndex - is SubtitleIdentity.Embedded -> serverIndex - is SubtitleIdentity.Downloaded, - is SubtitleIdentity.LocalMedia3, - -> -1 -} - class PlayerViewModel( private val videoPlaybackCoordinator: VideoPlaybackSessionCoordinator, private val catalogRepository: CatalogRepository, @@ -299,6 +309,8 @@ class PlayerViewModel( // Cached active profile; offline playback reads subtitle preferences from // it instead of waiting on the server. Optional for the same reason. private val activeProfileStore: org.prairieserver.prairie.model.profile.ActiveProfileStore? = null, + // Shuffle: the server picks what plays next. Optional for the same reason. + private val shuffleFeatureStore: org.prairieserver.prairie.model.feature.ShuffleFeatureStore? = null, ) : ViewModel() { // Last load request, replayed by the "Can't reach server" Retry / Try Anyway. @@ -318,6 +330,7 @@ class PlayerViewModel( val initialSubtitleTrackIndex: Int?, val resumePositionOverride: Double?, val suppressResumeRewind: Boolean, + val room: WatchPartyPlaybackContext? = null, ) internal fun currentExternalRouteTarget(): MobilePlayerRouteTarget? = @@ -335,6 +348,8 @@ class PlayerViewModel( private const val SEEK_SETTLE_DEADLINE_MS = 15_000L // Up-next auto-play countdown length (matches TV's NEXT_UP_COUNTDOWN_SECONDS). const val UP_NEXT_COUNTDOWN_SECONDS = 10 + const val SHUFFLE_CONTINUE_FAILED = "Couldn't continue the shuffle." + const val SHUFFLE_PICK_FAILED = "Couldn't pick another." /** iOS resolveOnDeckItems: section pools feeding the On Deck carousel. */ // Stored orientation-mode values — raw-value parity with iOS // `PlayerOrientationMode` so the device-scoped setting round-trips. @@ -360,6 +375,9 @@ class PlayerViewModel( val artUrl: String?, val artThumbhash: String?, val progressFraction: Float?, + /** An episode the profile has not started (spoiler protection). */ + val isUnwatchedEpisode: Boolean = false, + val imageIsEpisodeStill: Boolean? = null, ) data class NextEpisodeInfo( @@ -372,11 +390,32 @@ class PlayerViewModel( val runtimeMinutes: Int, val seriesTitle: String? = null, val overview: String? = null, + /** Not started by this profile; spoiler protection may hide [overview]. */ + val isUnwatched: Boolean = true, + /** False for a shuffled movie, which has no season or episode line. */ + val isEpisode: Boolean = true, ) { val label: String - get() = "S$seasonNumber·E$episodeNumber" + (title?.let { " — $it" } ?: "") + get() = if (!isEpisode) { + title.orEmpty() + } else { + "S$seasonNumber·E$episodeNumber" + (title?.let { " — $it" } ?: "") + } } + /** + * Set while a shuffle plays: the Up Next card announces the server's + * random pick from the scope [scopeLabel] names, and the player offers no + * sequential next episode. + */ + data class ShuffleUiState( + /** Null until the shuffle has been read. */ + val scopeLabel: String? = null, + val pickingAnother: Boolean = false, + /** A transient failure to continue or pick another. */ + val message: String? = null, + ) + data class PlayerUiState( val isLoading: Boolean = true, val error: String? = null, @@ -397,6 +436,8 @@ class PlayerViewModel( val title: String = "", val subtitle: String = "", val seriesTitle: String? = null, + /** Catalog item type ("movie", "episode", …), for the controls' "MOVIE · 2026" line. */ + val contentType: String? = null, /** * Artwork URL used for the Now Playing lock-screen / Bluetooth / * notification surface. Sourced from `WatchDetail.backdropUrl` with @@ -408,6 +449,8 @@ class PlayerViewModel( val sessionId: String? = null, val playMethod: PlayMethod? = null, val playbackPlan: PlaybackExecutionPlan? = null, + /** The quality preference the playing plan was built for; null is Auto. */ + val committedQualityPreference: String? = null, val requestHeaders: Map = emptyMap(), val delivery: PlaybackDelivery? = null, val streamUrl: String? = null, @@ -473,6 +516,7 @@ class PlayerViewModel( val upNextVideoEnded: Boolean = false, /** Remaining auto-play countdown seconds; null = no countdown (gated / auto-play off). */ val upNextCountdownSeconds: Int? = null, + val shuffle: ShuffleUiState? = null, val preferredAudioLanguage: String? = null, val preferredTextLanguage: String? = null, /** @@ -482,6 +526,12 @@ class PlayerViewModel( * current position. */ val subtitleRefreshNonce: Int = 0, + /** + * Grows per sync key each time a syncable subtitle's timing changed + * and its cues must be fetched again. The mount that carries a + * revision reports it back, so sync feedback knows the new cues show. + */ + val subtitleCueRevisions: Map = emptyMap(), /** * True when [audioTracks] come from a download's offline manifest and * list the local file's own audio tracks in file order, so an audio @@ -570,7 +620,7 @@ class PlayerViewModel( /** * Unconditional seek channel for room-driven corrective seeks. The normal * position mirror in PlayerScreen applies a 2.0s deadband (to avoid feedback - * loops between playback-progress updates and user scrubs), but Watch Together + * loops between playback-progress updates and user scrubs), but Watch Party * corrective seeks can be as small as the engine's 0.35s drift threshold and * MUST always reach the player. PlayerScreen collects this and calls * `mediaController.seekTo` with no deadband. See [seekImmediate]. @@ -657,7 +707,7 @@ class PlayerViewModel( /** * Video relative-seek intervals every phone surface uses (buttons, - * double-tap, Watch Together skips): the profile-wide values on a + * double-tap, Watch Party skips): the profile-wide values on a * revision-9 server, otherwise the phone's pre-revision-9 10s/10s. Read at * press time, so a settings change applies to the next skip mid-playback. */ @@ -692,6 +742,15 @@ class PlayerViewModel( private var pendingApproachingEndVideoEnded: Boolean? = null private var upNextCountdownJob: Job? = null private val nextUpTransitionGate = NextUpTransitionGate() + // The running shuffle this player plays picks from; null outside a shuffle. + private var shufflePlayback: org.prairieserver.prairie.playback.ShufflePlayback? = null + // The later part of a multi-part pick that plays before the shuffle moves on. + private var shuffleNextPartFileId: Int? = null + // Once per item: the server re-checks the next pick when the card opens. + private var shuffleRefreshedForPostRoll = false + private var shuffleAdvanceJob: Job? = null + private var shufflePickJob: Job? = null + private var shuffleMessageJob: Job? = null // Auto-dismiss timer for the transient version-switch failure pill. A new // failure cancels the prior job so a stale one can't clear the fresh // message early (repeated identical failures used to be dismissed within a @@ -775,6 +834,9 @@ class PlayerViewModel( // (then the OS captioning style, tvOS parity). val subtitleAppearance: StateFlow = playerSettingsStore.effectiveSubtitleAppearanceFlow .stateIn(viewModelScope, SharingStarted.Eagerly, SubtitleAppearance.DEFAULT) + /** False when the server is known to discard subtitle text opacity. */ + val subtitleTextOpacitySupported: StateFlow = playerSettingsStore.subtitleTextOpacitySupportedFlow + .stateIn(viewModelScope, SharingStarted.Eagerly, true) /** * Per-device audio/subtitle delay in ms. Mirrors iOS phone's `audioSyncMs` / * `subtitleSyncMs` (`iosApp/Screens/Player/Sheets/PlayerSettingsSheet.swift:265-285`). @@ -822,11 +884,51 @@ class PlayerViewModel( private val _subtitleTools = MutableStateFlow(SubtitleToolsUiState()) val subtitleTools: StateFlow = _subtitleTools.asStateFlow() + // Timing and sync state of the playing file's syncable subtitles, and + // the card that follows a sync this viewer started. + private val subtitleSync = SubtitleSyncController( + subtitlesRepository, + viewModelScope, + onTimingChanged = ::onSubtitleSyncTimingChanged, + ) + val subtitleSyncState: StateFlow = subtitleSync.state + private val subtitleSyncFeedback = SubtitleSyncFeedbackTracker(viewModelScope) + val subtitleSyncNotice: StateFlow = subtitleSyncFeedback.notice + /** The cue revision of each sync key whose cues the player has in use. */ + private val loadedSubtitleCueRevisions = MutableStateFlow>(emptyMap()) + /** Cue revisions of the last subtitle mount, until Media3 selects its subtitle track. */ + private var mountedSubtitleCueRevisions: Map = emptyMap() + + fun requestSubtitleSync(key: String) = subtitleSync.requestSync(key) + + fun resetSubtitleTiming(key: String) = subtitleSync.resetTiming(key) + + fun dismissSubtitleSyncNotice() = subtitleSyncFeedback.dismiss() + + /** + * The player mounted subtitles built from a state carrying [revisions]. + * Media3 fetches them after the mount returns, so they count as loaded + * only once [onMountedSubtitleSelected] reports the track in use. + */ + internal fun onSubtitleCuesMounted(revisions: Map) { + mountedSubtitleCueRevisions = revisions + } + + /** Media3 selected the mounted subtitle track on the live player: its cues are in use. */ + internal fun onMountedSubtitleSelected() { + val revisions = mountedSubtitleCueRevisions + if (revisions.isEmpty()) return + mountedSubtitleCueRevisions = emptyMap() + loadedSubtitleCueRevisions.update { it + revisions } + } + private var aiStatusFetched = false private var searchJob: Job? = null private var aiJobHandle: Job? = null private var controlsHideJob: Job? = null + // True while the seek bar is dragged; see onScrubbingChanged. + private var scrubbing = false private var introObserverJob: Job? = null private var lifecycleObserverJob: Job? = null private var resolveNextEpisodeJob: Job? = null @@ -879,6 +981,38 @@ class PlayerViewModel( .distinctUntilChanged() .collect { org.prairieserver.prairie.common.player.ActivePlaybackFile.set(it) } } + // Subtitle sync applies to server playback only; offline and local + // files have no inventory sync keys. + viewModelScope.launch { + _uiState + .map { state -> + state.mediaFileId?.takeIf { state.sessionId != null } to + state.subtitleTracks.mapNotNullTo(mutableSetOf()) { it.syncKey } + } + .distinctUntilChanged() + .collect { (mediaFileId, syncKeys) -> subtitleSync.bind(mediaFileId, syncKeys) } + } + viewModelScope.launch { + val screen = _uiState + .map { state -> + val activeKey = state.sessionId?.let { + val selected = state.localSubtitleMountIdentity ?: state.committedSubtitleIdentity + state.subtitleTracks.activeSyncKey( + selected, + mounted = subtitlesForVideoMediaMount( + subtitles = state.subtitleTracks, + playbackPlan = state.playbackPlan, + subtitleIdentity = selected, + ), + ) + } + Triple(activeKey, state.subtitleCueRevisions, state.subtitleTracks) + } + .distinctUntilChanged() + combine(subtitleSync.state, screen, loadedSubtitleCueRevisions) { sync, (activeKey, revisions, tracks), loaded -> + sync.feedbackInput(activeKey, revisions, loaded) { subtitleSyncName(tracks, it) } + }.collect(subtitleSyncFeedback::update) + } // Mirror the screen error into the adb test hook — screen-level // failures (terminal server plans) never reach the Media3 player, so // scripted tests can't see them through player state alone. @@ -940,6 +1074,7 @@ class PlayerViewModel( suppressResumeRewind = true, preserveRouteIntent = true, recoveryStartParams = params, + room = roomRestartContext(renewal.positionSeconds), ) } } @@ -963,6 +1098,9 @@ class PlayerViewModel( // (which is a *toggle* and would inadvertently resume a paused player). sleepTimer.configure { _uiState.update { it.copy(isPaused = true) } + // In a Watch Party the timer pauses only this device; the room + // keeps playing and hears nothing until the viewer resumes. + if (inRoom) beginRoomSuspension(RoomSuspensionReason.SleepTimer) } viewModelScope.launch { @@ -1031,7 +1169,7 @@ class PlayerViewModel( // internal auto-advance and recovery positions must not become route // intent. routeResumePositionSeconds: Double? = null, - // True for Watch Together (the synced anchor must land exactly — no + // True for Watch Party (the synced anchor must land exactly — no // skip-back nudge). The request's roomId is always null on mobile, so WT // can't be inferred from it the way the TV starter does. suppressResumeRewind: Boolean = false, @@ -1044,7 +1182,32 @@ class PlayerViewModel( libraryId: Int? = browseLibraryId, // Exact capability/context snapshot used only for a 404 renewal. recoveryStartParams: StartParams? = null, + // Watch Party: the room's exact file and position. Defaults to the + // current room context so every in-place restart keeps the room's + // file, never plays a download, and starts paused. + room: WatchPartyPlaybackContext? = currentRoomContext, ) { + if (room != null && + (contentId != room.contentId || preferredFileId != room.fileId || libraryId != room.libraryId) + ) { + // A room plays exactly its own content and file; no caller can override that. + loadContent( + contentId = room.contentId, + preferredFileId = room.fileId, + preferredQuality = preferredQuality, + initialAudioTrackIndex = initialAudioTrackIndex, + initialSubtitleTrackIndex = initialSubtitleTrackIndex, + resumePositionOverride = resumePositionOverride, + routeResumePositionSeconds = routeResumePositionSeconds, + suppressResumeRewind = suppressResumeRewind, + force = force, + preserveRouteIntent = preserveRouteIntent, + libraryId = room.libraryId, + recoveryStartParams = recoveryStartParams, + room = room, + ) + return + } browseLibraryId = libraryId aiPlaybackGeneration++ val normalizedPreferredQuality = VideoPlayerRouteArgs.normalizeQuality(preferredQuality) @@ -1064,11 +1227,23 @@ class PlayerViewModel( normalizedRequestedQuality = normalizedPreferredQuality, preserveCurrent = preserveRouteIntent, ) + // A quality picked during this title's playback, or this room epoch, + // outlives reloads of it (recovery, retry, the next shuffle part), so + // they start with it rather than the route's quality. + val carriesPickedQuality = keepsPickedQuality( + contentId = contentId, + previousContentId = lastLoadArgs?.contentId, + routeNamesQuality = !preserveRouteIntent && normalizedPreferredQuality != null, + room = room, + currentRoom = currentRoomContext, + ) + if (room == null && !carriesPickedQuality) sessionQualityOverride = null + val loadQuality = sessionQualityOverride.takeIf { carriesPickedQuality } ?: effectivePreferredQuality loadJob?.cancel() val loadOwner = loadOwners.begin( contentId = contentId, preferredFileId = preferredFileId, - preferredQuality = effectivePreferredQuality, + preferredQuality = loadQuality, ) // Remember the exact request so a "Can't reach server" Retry / Try Anyway // can replay it faithfully (this screen has no other retry entry point). @@ -1080,13 +1255,17 @@ class PlayerViewModel( initialSubtitleTrackIndex = initialSubtitleTrackIndex, resumePositionOverride = resumePositionOverride, suppressResumeRewind = suppressResumeRewind, + room = room, ) + if (room != null) beginRoomLoad(room) // A fresh load resets any in-flight intro countdown / cancellation memory. introAutoSkipController.reset() // New item: re-arm the once-per-episode auto-advance trigger. (The // AutoPlayGuard streak intentionally PERSISTS across episodes.) autoAdvanceHandled = false pendingApproachingEndVideoEnded = null + shuffleNextPartFileId = null + shuffleRefreshedForPostRoll = false resetPlaybackRecoveryState() upNextCountdownJob?.cancel() upNextCountdownJob = null @@ -1114,8 +1293,9 @@ class PlayerViewModel( // this contentId AND its bytes are still on disk, hand the // player a file:// URI without touching the server at all. // Title + duration are best-effort — we attempt the watch - // detail fetch but tolerate failure. - val localPlaybackStarted = tryLocalPlayback( + // detail fetch but tolerate failure. A Watch Party always + // streams the room's file, so it never plays a download. + val localPlaybackStarted = room == null && tryLocalPlayback( contentId = contentId, preferredFileId = preferredFileId, resumePositionOverride = resumePositionOverride, @@ -1141,14 +1321,15 @@ class PlayerViewModel( libraryId = libraryId, contentId = contentId, preferredFileId = preferredFileId, - preferredQualityOverride = effectivePreferredQuality, - roomId = null, + preferredQualityOverride = loadQuality, + roomId = room?.roomId, resumePositionOverride = resumePositionOverride, audioTrackIndex = initialAudioTrackIndex, subtitleTrackIndex = initialSubtitleTrackIndex, suppressResumeRewind = suppressResumeRewind, force = force, recoveryStartParams = recoveryStartParams, + room = room, ), )) { is VideoPlayerUiState.Ready -> { @@ -1174,6 +1355,7 @@ class PlayerViewModel( isSessionRenewal = recoveryStartParams != null, loadOwner = loadOwner, watchOwner = readyWatchOwner, + room = room, ) unpublishedReadySessionId = null } @@ -1188,6 +1370,11 @@ class PlayerViewModel( isNextUpTransitioning = false, ) } + // The room decides whether another file is tried; + // this device never picks one itself. + if (room != null) { + publishRoomRefusal(room, room.fileId, playbackState.terminalReason) + } } } is VideoPlayerUiState.ServerUnreachable -> { @@ -1229,6 +1416,7 @@ class PlayerViewModel( loadJob = newLoadJob newLoadJob.invokeOnCompletion { if (loadJob === newLoadJob) loadJob = null + refreshRoomObservation() } } @@ -1263,9 +1451,22 @@ class PlayerViewModel( suppressResumeRewind = args.suppressResumeRewind, force = force, preserveRouteIntent = true, + room = args.room?.let(::roomRetryContext), ) } + /** + * The room context for a retry of [original]. Within the same epoch it + * resumes where this player was, like an in-place restart; before + * anything played it keeps the room position. A retry never carries an + * older epoch's position. + */ + private fun roomRetryContext(original: WatchPartyPlaybackContext): WatchPartyPlaybackContext { + val current = currentRoomContext ?: return original + if (current.roomId != original.roomId || current.selectionRevision != original.selectionRevision) return original + return roomRestartContext(_uiState.value.position.takeIf { it > 0.0 }) ?: original + } + private fun ownsLoad(owner: MobilePlayerLoadOwner): Boolean = loadOwners.owns(owner) private suspend fun stopStaleReadySession(sessionId: String?) { @@ -1287,6 +1488,7 @@ class PlayerViewModel( isSessionRenewal: Boolean, loadOwner: MobilePlayerLoadOwner, watchOwner: org.prairieserver.prairie.network.AuthScopeSnapshot?, + room: WatchPartyPlaybackContext? = null, ) { val watchMetadata = ReadyWatchMetadata( catalogRepository, watchOwner, playbackState.contentId, playbackState.serverUrl, browseLibraryId, @@ -1436,6 +1638,7 @@ class PlayerViewModel( error = null, title = watchDetail?.title ?: playbackState.title, seriesTitle = watchDetail?.seriesTitle, + contentType = watchDetail?.type ?: playbackState.contentType, subtitle = watchDetail?.let { detail -> buildSubtitle(detail) } ?: playbackState.subtitle.orEmpty(), artworkUrl = playbackState.artworkUrl, sessionId = playbackState.sessionId @@ -1455,8 +1658,9 @@ class PlayerViewModel( // not declare one; neither catalog nor Media3 may substitute it. duration = playbackState.durationSeconds?.takeIf { it > 0.0 } ?: 0.0, serverDuration = playbackState.durationSeconds?.takeIf { it > 0.0 } ?: 0.0, - isPlaying = true, - isPaused = false, + // A Watch Party prepares paused; only an accepted room command plays. + isPlaying = room == null, + isPaused = room != null, subtitleTracks = mountedSubtitles, audioTracks = version?.audioTracks ?: emptyList(), selectedAudioIndex = selectedAudioOrdinal, @@ -1549,6 +1753,10 @@ class PlayerViewModel( } private fun startIntroAutoSkipObserver() { + // In a Watch Party an intro never skips on its own: the mode is pinned + // to Ask, so the controller only offers the pill and the screen sends + // its Skip as a room seek (or hides it from members who cannot seek). + val introMode = if (inRoom) flowOf(IntroSkipMode.ASK) else playerSettingsStore.introSkipModeFlow introObserverJob?.cancel() introObserverJob = introAutoSkipController.observe( position = _uiState @@ -1557,7 +1765,7 @@ class PlayerViewModel( introRange = _uiState .map { it.intro } .distinctUntilChanged(), - mode = playerSettingsStore.introSkipModeFlow, + mode = introMode, introKey = _uiState .map { state -> state.intro?.let { intro -> @@ -1928,6 +2136,21 @@ class PlayerViewModel( val effectiveFileId = decision.session.mediaFileId.takeIf { it > 0 } ?: decision.plan.effectiveMediaFileId ?: fileId + val roomFileId = currentRoomContext?.fileId + if (roomFileId != null && effectiveFileId != roomFileId) { + // The room pins its file through every replan; a plan + // for another file is refused, never played. + playbackSessionManager.abandonActiveVideoSessionAsync(decision.session.sessionId) + nextUpTransitionGate.cancel() + _uiState.update { + it.copy( + error = "The server offered a different version than the Watch Party's.", + isLoading = false, + isBuffering = false, + ) + } + return@launch + } val catalogVersionIndex = state.versions .indexOfFirst { it.fileId == effectiveFileId } val effectiveVersions = if (catalogVersionIndex >= 0) { @@ -2029,6 +2252,7 @@ class PlayerViewModel( contentId = _uiState.value.contentId, mountToken = mountGeneration, ) + val previousFileId = _uiState.value.mediaFileId _uiState.update { current -> current.copy( error = null, @@ -2060,6 +2284,7 @@ class PlayerViewModel( position = remountPosition.sourcePositionSeconds, ) } + adoptFileMarkersAfterReplan(previousFileId, effectiveVersion) Log.i( TAG, "replan_mount restored_source_seconds=${remountPosition.sourcePositionSeconds} " + @@ -2103,6 +2328,7 @@ class PlayerViewModel( streamUrl = null, ) } + currentRoomContext?.let { room -> publishRoomRefusal(room, fileId, decision.reason) } } VideoSessionStartV3.ServerUpgradeRequired -> { nextUpTransitionGate.cancel() @@ -2126,6 +2352,7 @@ class PlayerViewModel( // queue; only a completed flight re-drives a queued user selection. job.invokeOnCompletion { cause -> if (cause == null) redriveQueuedInvalidationReplan() + refreshRoomObservation() } } } @@ -2259,6 +2486,7 @@ class PlayerViewModel( awaitingMediaMountGeneration = null subtitleRefreshGate.reset() positionReportsBlockedForPendingLoad = false + if (inRoom) resetRoomEngineToMount() pendingNativeSeekAfterMount?.let { (targetSeconds, immediate) -> pendingNativeSeekAfterMount = null // The load may have resolved to an online V3 plan after the @@ -2384,6 +2612,7 @@ class PlayerViewModel( // reporter does nothing on the offline-download path (no session). Throttled // by content-time delta so it fires ~every 10s of playback, not per tick. maybeRecordPosition(positionSec, _uiState.value.duration) + refreshRoomObservation() } private var lastRecordedKey: String? = null @@ -2422,6 +2651,7 @@ class PlayerViewModel( * otherwise a buffering glitch flips the pause icon and defeats scheduleControlsHide. */ fun onPlayingChanged(isPlaying: Boolean) { + if (inRoom) roomEngineIsPlaying = isPlaying _uiState.update { it.copy(isPlaying = isPlaying) } if (isPlaying && !_uiState.value.isPaused && _uiState.value.showControls) { scheduleControlsHide() @@ -2539,11 +2769,10 @@ class PlayerViewModel( } /** - * Immediate, deadband-free seek for room-driven corrective seeks - * (RoomSyncController.applyDecision). Updates `uiState.position` like - * [onSeek] AND emits on [immediateSeeks] so PlayerScreen drives the - * MediaController unconditionally — bypassing the 2.0s position-mirror - * deadband that would otherwise swallow sub-2s sync corrections. + * Immediate, deadband-free seek for remote and Watch Party seeks + * ([applyRoomSeek]). Updates `uiState.position` like [onSeek] AND emits on + * [immediateSeeks] so PlayerScreen drives the MediaController + * unconditionally, without counting as the viewer's own interaction. */ fun seekImmediate(position: Double) { cancelPendingQuickSkip() @@ -2874,11 +3103,16 @@ class PlayerViewModel( request = request, recoveryGeneration = recoveryGeneration, ) - is VideoSessionStartV3.Terminal -> publishSeekFailure( - request, - recoveryGeneration, - "Unable to seek (${decision.reason}): ${decision.message}", - ) + is VideoSessionStartV3.Terminal -> { + publishSeekFailure( + request, + recoveryGeneration, + "Unable to seek (${decision.reason}): ${decision.message}", + ) + currentRoomContext?.let { room -> + publishRoomRefusal(room, room.fileId, decision.reason) + } + } VideoSessionStartV3.ServerUpgradeRequired -> publishSeekFailure( request, recoveryGeneration, @@ -2918,6 +3152,7 @@ class PlayerViewModel( // can run now that the in-flight guard is released. redriveQueuedInvalidationReplan() } + refreshRoomObservation() } } @@ -3071,12 +3306,319 @@ class PlayerViewModel( // ---- Remote-control adapters (PlaybackRealtimeController calls these) ---- // Thin wrappers over existing transport; no new playback logic. - // The VM's start request always carries roomId=null, so WT membership is - // set by the screen (which owns roomId) for remote-transport gating. + // The screen marks Watch Party membership from its route, so the room + // gates hold before the room's playback context has arrived. private var inWatchTogetherRoom = false fun setInWatchTogetherRoom(value: Boolean) { inWatchTogetherRoom = value } - /** True while in a Watch Together room — remote transport is gated (the room is authoritative). */ - val remoteTransportSuppressed: Boolean get() = inWatchTogetherRoom + /** True in a Watch Party: the room, not this device, decides transport. */ + val remoteTransportSuppressed: Boolean get() = inRoom + + // ---- Watch Party ------------------------------------------------------------ + // + // The room binding (WatchPartyPlayback / RoomPlaybackBinding) decides + // attach, reports, readiness, corrections and pacing. This ViewModel only + // observes the player truthfully and applies what the binding asks, + // through paths that never count as the viewer's own intent. + + /** A server refusal of the room's file, for the room to decide on a fallback. */ + data class RoomRefusal( + val context: WatchPartyPlaybackContext, + val fileId: Int, + val reason: String, + ) + + /** A local reason playback is held. Nothing is reported to the room while any lasts. */ + enum class RoomSuspensionReason { AudioFocus, TransientAudioFocus, SleepTimer, Background } + + private var currentRoomContext: WatchPartyPlaybackContext? = null + private val inRoom: Boolean get() = inWatchTogetherRoom || currentRoomContext != null + private val roomSuspensions = mutableSetOf() + private val _roomSuspended = MutableStateFlow(false) + + /** Playback is held for a local reason; tapping play resumes this device only. */ + val roomSuspended: StateFlow = _roomSuspended.asStateFlow() + private val _roomCorrectionRate = MutableStateFlow(null) + + /** The binding's temporary catch-up rate; null is exactly 1x. Never a user speed. */ + val roomCorrectionRate: StateFlow = _roomCorrectionRate.asStateFlow() + private val _roomRefusal = MutableStateFlow(null) + + /** The latest server refusal of the room's file, until the screen takes it to the room. */ + val roomRefusal: StateFlow = _roomRefusal.asStateFlow() + private val _roomQualityChanges = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + /** Emits when this viewer's quality changed, so the binding restarts its reload pacing. */ + val roomQualityChanges: SharedFlow = _roomQualityChanges.asSharedFlow() + private val _roomObservation = MutableStateFlow(RoomPlayerObservation()) + + /** What the player is actually doing, for the room binding. */ + val roomObservation: StateFlow = _roomObservation.asStateFlow() + private var roomObservationJob: Job? = null + + // Media3 facts. Player-timeline seconds unless the name says source. + private var roomEnginePlayerSec = 0.0 + private var roomEngineBufferedPlayerSec = 0.0 + private var roomEngineSourceSec = 0.0 + private var roomEnginePlayWhenReady = false + private var roomEngineIsPlaying = false + private var roomEngineState = RoomPlayerState.Idle + + // The session the current room epoch committed. Cleared at every room + // load so the binding never attaches the outgoing session under the new + // selection revision; set again when a session is published. + private var roomObservedSessionId: String? = null + private var roomLastUiSessionId: String? = null + + // Quality chosen during this title's playback, or this room epoch's (the + // Quality menu or the lower-quality offer). + private var sessionQualityOverride: String? = null + + /** + * Prepares the room's playback epoch. A context for the epoch already + * loading or playing (the screen collecting again after recreation) is a + * no-op; a new selection revision or file restarts playback. + */ + fun startRoomPlayback(context: WatchPartyPlaybackContext) { + val current = currentRoomContext + if (current != null && + current.roomId == context.roomId && + current.selectionRevision == context.selectionRevision && + current.fileId == context.fileId + ) { + return + } + loadContent( + contentId = context.contentId, + preferredFileId = context.fileId, + libraryId = context.libraryId, + room = context, + ) + } + + private fun beginRoomLoad(room: WatchPartyPlaybackContext) { + val previous = currentRoomContext + if (previous == null || + previous.roomId != room.roomId || + previous.selectionRevision != room.selectionRevision + ) { + sessionQualityOverride = null + } + currentRoomContext = room + _roomRefusal.value = null + roomObservedSessionId = null + roomLastUiSessionId = _uiState.value.sessionId + // Room playback always prepares paused, and the outgoing media stops + // now rather than playing on while the new file loads. + _uiState.update { it.copy(isPaused = true) } + if (roomObservationJob == null) { + roomObservationJob = viewModelScope.launch { + _uiState.collect { refreshRoomObservation() } + } + } + refreshRoomObservation() + } + + /** The room context for an in-place restart at [sourcePositionSeconds]: same epoch and file, paused. */ + private fun roomRestartContext(sourcePositionSeconds: Double?): WatchPartyPlaybackContext? { + val room = currentRoomContext ?: return null + val position = sourcePositionSeconds?.takeIf { it.isFinite() && it >= 0.0 } ?: return room + return room.copy(positionSeconds = position, paused = true) + } + + private fun publishRoomRefusal(room: WatchPartyPlaybackContext, fileId: Int, reason: String?) { + val normalized = reason?.trim()?.takeIf { it.isNotEmpty() } ?: return + _roomRefusal.value = RoomRefusal(room, fileId, normalized) + } + + /** The screen has taken [refusal] to the room. */ + fun consumeRoomRefusal(refusal: RoomRefusal) { + _roomRefusal.compareAndSet(refusal, null) + } + + /** Applies a room seek. Never user intent: no pass-out reset, no Up Next dismissal. */ + fun applyRoomSeek(sourceSeconds: Double) { + if (!inRoom || !sourceSeconds.isFinite() || sourceSeconds < 0.0) return + // A restart in flight starts at the room position and re-attaches, + // which brings a fresh command. Seeking the outgoing session now would + // only re-anchor media that is being replaced. + if (loadJob?.isActive == true) { + Log.i(TAG, "room_seek action=ignore_during_load target_source_seconds=$sourceSeconds") + return + } + seekImmediate(sourceSeconds) + } + + /** Applies the room's play or pause. A local suspension holds a Play until the viewer resumes. */ + fun applyRoomPlaying(playing: Boolean) { + if (!inRoom) return + if (playing && roomSuspensions.any { it != RoomSuspensionReason.TransientAudioFocus }) return + _uiState.update { it.copy(isPaused = !playing) } + if (playing && _uiState.value.showControls) scheduleControlsHide() + } + + fun setRoomCorrectionRate(rate: Double?) { + _roomCorrectionRate.value = rate?.takeIf { it.isFinite() && it > 0.0 } + } + + /** Whether reaching [sourceSeconds] needs no new media. False whenever that is uncertain. */ + fun isRoomPositionBuffered(sourceSeconds: Double): Boolean { + if (!inRoom) return false + val state = _uiState.value + if (roomMediaTransitioning(state) || roomEngineState == RoomPlayerState.Idle) return false + return roomTargetBufferedLocally( + timeline = state.playbackPlan?.timeline, + targetSourceSeconds = sourceSeconds, + playerPositionSeconds = roomEnginePlayerSec, + bufferedPlayerSeconds = roomEngineBufferedPlayerSec, + ) + } + + /** A Media3 sample while in a room: position, buffer, and play state as the engine reports them. */ + fun onRoomEngineSample( + positionMs: Long, + bufferedPositionMs: Long, + playWhenReady: Boolean, + isPlaying: Boolean, + playbackState: Int, + ) { + if (!inRoom) return + roomEnginePlayWhenReady = playWhenReady + roomEngineIsPlaying = isPlaying + roomEngineState = roomPlayerState(playbackState) + // Samples from media that is being replaced say nothing about the + // stream the room will see. + if (positionMs >= 0L && !positionReportsBlockedForPendingLoad && awaitingMediaMountGeneration == null) { + val state = _uiState.value + val playerSec = positionMs / 1000.0 + val serverDuration = state.serverDuration.takeIf { it > 0.0 } + roomEnginePlayerSec = playerSec + roomEngineBufferedPlayerSec = bufferedPositionMs.coerceAtLeast(0L) / 1000.0 + // onPositionChanged's source mapping, without the presentation + // guard that holds the scrubber on a pending seek target. + roomEngineSourceSec = (state.playbackPlan?.timeline?.sourcePositionForPlayer(playerSec) ?: playerSec) + .let { position -> serverDuration?.let { position.coerceAtMost(it) } ?: position } + } + refreshRoomObservation() + } + + private fun resetRoomEngineToMount() { + val state = _uiState.value + roomEnginePlayerSec = state.startPosition + roomEngineBufferedPlayerSec = state.startPosition + roomEngineSourceSec = state.position + refreshRoomObservation() + } + + /** Source seconds for a position on the mounted player's timeline. */ + fun sourceSecondsForPlayerMs(positionMs: Long): Double { + val playerSec = positionMs.coerceAtLeast(0L) / 1000.0 + return _uiState.value.playbackPlan?.timeline?.sourcePositionForPlayer(playerSec) ?: playerSec + } + + /** True while media is loading or mounting, when Media3 callbacks describe outgoing media. */ + fun isRoomMediaMounting(): Boolean = + loadJob?.isActive == true || awaitingMediaMountGeneration != null || positionReportsBlockedForPendingLoad + + fun beginRoomSuspension(reason: RoomSuspensionReason) { + if (!inRoom) return + roomSuspensions += reason + // Media3 lifts a transient focus loss by itself, so only the other + // reasons hold the local play state. + if (reason != RoomSuspensionReason.TransientAudioFocus) { + _uiState.update { it.copy(isPaused = true) } + } + refreshRoomObservation() + } + + fun endRoomSuspension(reason: RoomSuspensionReason) { + if (!roomSuspensions.remove(reason)) return + refreshRoomObservation() + } + + /** + * The viewer asked to play while playback was held locally. The held + * reasons clear. With [resumeLocally] this device plays again at once and + * the binding re-syncs it to the room; no room request is sent. + */ + fun endRoomSuspensions(resumeLocally: Boolean) { + roomSuspensions.removeAll { it != RoomSuspensionReason.TransientAudioFocus } + if (resumeLocally) _uiState.update { it.copy(isPaused = false) } + refreshRoomObservation() + } + + /** One rung down the current file's quality ladder, or null when there is none. */ + fun lowerRoomQualityLabel(): String? { + if (!inRoom) return null + return lowerQualityRung( + available = _uiState.value.playbackPlan?.availableQualities.orEmpty(), + selectedLabel = currentMobileQualityPreference(), + )?.label + } + + /** Accepts the lower-quality offer: replans the same file one rung down. */ + fun lowerRoomQuality(): Boolean { + val label = lowerRoomQualityLabel() ?: return false + mobileSubtitleTransactions.updatePlaybackContext(mobileSubtitleContext(_uiState.value)) + mobileSubtitleTransactions.selectQuality(label) + return true + } + + /** + * Quality menu pick: replans at [id], a plan entry's label sent verbatim, + * or auto to hand the choice back to the server. The active row is a no-op. + */ + fun onSelectQuality(id: String) { + val state = _uiState.value + val menu = playbackQualityMenu(state.playbackPlan?.availableQualities.orEmpty()) + if (menu.none { it.id == id }) return + if (id == state.activeQualityId(menu)) return + mobileSubtitleTransactions.updatePlaybackContext(mobileSubtitleContext(state)) + mobileSubtitleTransactions.selectQuality(id) + } + + /** + * Keeps a committed quality for later replans, seek recoveries and + * restarts of this title, which otherwise fall back to the route's. + */ + private fun noteQualityCommitted(committed: String?) { + if (committed == null || committed == currentMobileQualityPreference()) return + sessionQualityOverride = committed + if (inRoom) _roomQualityChanges.tryEmit(Unit) + } + + private fun roomMediaTransitioning(state: PlayerUiState): Boolean = + state.isLoading || + loadJob?.isActive == true || + recoveryJob?.isActive == true || + serverSeekRecoveryInFlight || + activeSeekTargetSec != null || + awaitingMediaMountGeneration != null || + positionReportsBlockedForPendingLoad + + private fun refreshRoomObservation() { + if (!inRoom) return + _roomSuspended.value = roomSuspensions.isNotEmpty() + val room = currentRoomContext ?: return + val state = _uiState.value + if (state.sessionId != roomLastUiSessionId) { + roomLastUiSessionId = state.sessionId + roomObservedSessionId = state.sessionId + } + _roomObservation.value = RoomPlayerObservation( + sessionId = roomObservedSessionId, + selectionRevision = room.selectionRevision, + sourcePositionSeconds = roomEngineSourceSec, + durationSeconds = state.duration, + playWhenReady = roomEnginePlayWhenReady, + isPlaying = roomEngineIsPlaying, + state = roomEngineState, + seekPending = roomMediaTransitioning(state), + suspended = roomSuspensions.isNotEmpty(), + ) + } fun remotePause() { _uiState.update { it.copy(isPaused = true) } } fun remoteUnpause() { _uiState.update { it.copy(isPaused = false) } } @@ -3162,13 +3704,60 @@ class PlayerViewModel( if (index == -1 || index in _uiState.value.subtitleTracks.indices) onSelectSubtitle(index) } + private val markerReconciliationFence = MarkerReconciliationFence() + private var markerReconcileJob: Job? = null + + /** + * Read the playing file's markers again: after every control connection, + * including on-demand mode, and after a replan lands on another file. + * Naming the file makes the server fill that version's on-demand markers. + */ + fun reconcileMarkers(expectedSessionId: String) { + val state = _uiState.value + val fileId = state.mediaFileId ?: return + if (state.sessionId != expectedSessionId) return + val ticket = markerReconciliationFence.begin() + markerReconcileJob?.cancel() + markerReconcileJob = viewModelScope.launch { + val owner = catalogRepository.captureWatchAuthority() ?: return@launch + val detail = (catalogRepository.getWatchDetail(state.contentId, owner, browseLibraryId, fileId) as? ApiResult.Success) + ?.data ?: return@launch + if (!catalogRepository.isWatchAuthorityCurrent(owner)) return@launch + val current = _uiState.value + if (!markerReconciliationFence.isCurrent(ticket) || current.sessionId != expectedSessionId || + current.contentId != state.contentId || current.mediaFileId != fileId) return@launch + val version = detail.versions.firstOrNull { it.fileId == fileId } ?: return@launch + applyUpdatedMarkers(version.intro, version.credits, + version.recap, version.preview) + } + } + + /** + * A `markers_updated` event. The server also sends a file's update to + * sessions that requested that file but play another, so check the file. + */ + fun applyMarkersUpdate(update: PlaybackMarkersUpdate) { + if (update.fileId != null && update.fileId != _uiState.value.mediaFileId) return + applyUpdatedMarkers(update.intro, update.credits, update.recap, update.preview) + } + + /** A replan that lands on another file takes that file's markers, then reads them again. */ + private fun adoptFileMarkersAfterReplan(previousFileId: Int?, version: FileVersion?) { + val state = _uiState.value + if (state.mediaFileId == previousFileId) return + applyUpdatedMarkers(version?.intro, version?.credits, version?.recap, version?.preview) + state.sessionId?.let(::reconcileMarkers) + } + /** - * Adopt server-recomputed marker ranges (a `markers_updated` event). - * The intro auto-skip observer and the credits-based F2 trigger read these - * from UiState, so updating them takes effect immediately. Passing `null` - * clears a marker the server says no longer applies. + * Adopt the playing file's marker ranges (a `markers_updated` event or a + * reconcile read). The intro auto-skip observer and the credits-based F2 + * trigger read these from UiState, so updating them takes effect + * immediately. Passing `null` clears a marker the server says no longer + * applies. */ - fun applyUpdatedMarkers(intro: TimeRange?, credits: TimeRange?, recap: TimeRange?, preview: TimeRange?) { + private fun applyUpdatedMarkers(intro: TimeRange?, credits: TimeRange?, recap: TimeRange?, preview: TimeRange?) { + markerReconciliationFence.invalidate() _uiState.update { it.copy(intro = intro, credits = credits, recap = recap, preview = preview) } } @@ -3196,7 +3785,7 @@ class PlayerViewModel( ) private fun currentMobileQualityPreference(): String? = - routeIntentState.current?.quality ?: lastLoadArgs?.preferredQuality + sessionQualityOverride ?: routeIntentState.current?.quality ?: lastLoadArgs?.preferredQuality private fun applyMobileSubtitleSnapshot(snapshot: MobileSubtitleTransactionSnapshot) { _uiState.update { state -> @@ -3212,6 +3801,7 @@ class PlayerViewModel( pendingSubtitleIdentity = snapshot.pendingIdentity, localSubtitleMountIdentity = snapshot.localMountIdentity, subtitleApplying = snapshot.subtitleApplying, + committedQualityPreference = snapshot.transition.committed.qualityPreference, ) } val state = _uiState.value @@ -3223,8 +3813,15 @@ class PlayerViewModel( transactionActive = mobileSubtitleTransactions.hasActiveTransaction, ) snapshot.failureMessage?.let { - showVersionSwitchMessage("Couldn't apply subtitles — playback continues unchanged.") + showVersionSwitchMessage( + if (snapshot.qualityChangeFailed) { + "Couldn't change quality. Try again." + } else { + "Couldn't apply subtitles — playback continues unchanged." + }, + ) } + noteQualityCommitted(snapshot.transition.committed.qualityPreference) if (!mobileSubtitleTransactions.hasActiveTransaction) { redriveQueuedInvalidationReplan() } @@ -3242,6 +3839,10 @@ class PlayerViewModel( val effectiveFileId = ready.session.mediaFileId.takeIf { it > 0 } ?: ready.plan.effectiveMediaFileId ?: predecessorFileId + // A Watch Party pins its file; a replan onto another one is abandoned. + if (currentRoomContext?.fileId?.let { it != effectiveFileId } == true) { + return MobileSubtitleAdoptionResult.Superseded + } val catalogVersionIndex = before.versions.indexOfFirst { it.fileId == effectiveFileId } val effectiveVersions = if (catalogVersionIndex >= 0) { before.versions @@ -3342,6 +3943,7 @@ class PlayerViewModel( subtitleRefreshNonce = 0, ) } + adoptFileMarkersAfterReplan(predecessorFileId, effectiveVersion) Log.i( TAG, "subtitle_replan_mount restored_source_seconds=$sourcePosition " + @@ -3363,6 +3965,7 @@ class PlayerViewModel( resumePositionOverride = state.position, suppressResumeRewind = true, preserveRouteIntent = true, + room = roomRestartContext(state.position), ) Log.w(TAG, "Subtitle committed-playback adoption failed: $detail") } @@ -3691,6 +4294,8 @@ class PlayerViewModel( * "Translate with AI…" row is hidden (no error surfaced). */ fun onTracksSheetOpened() { + // Opening re-reads sync state so a job that finished meanwhile shows. + subtitleSync.reload() if (aiStatusFetched) return aiStatusFetched = true viewModelScope.launch { @@ -3759,6 +4364,8 @@ class PlayerViewModel( if (generation != subtitleDownloadGeneration || _uiState.value.mediaFileId != mediaFileId || _uiState.value.sessionId != sessionId) return@launch when (r) { is ApiResult.Success -> { + // Follows the automatic sync the server starts for a new download. + subtitleSync.remember(r.data.subtitle) doRefreshSubtitles(autoSelectSubtitleId = r.data.subtitle.id) if (generation != subtitleDownloadGeneration || _uiState.value.mediaFileId != mediaFileId || _uiState.value.sessionId != sessionId) return@launch _subtitleTools.update { it.copy(downloadingKey = null, downloadCompleted = true) } @@ -3822,6 +4429,50 @@ class PlayerViewModel( } } + /** + * The server retimed a subtitle of the file, stored or a sidecar (a sync, + * or a timing set or reset). The sync controller re-reads it and reports + * the change through [onSubtitleSyncTimingChanged]. + */ + fun applySubtitleTimingChanged(update: PlaybackSubtitleTimingChanged) { + val state = _uiState.value + val sessionId = state.sessionId ?: return + if (update.sessionId != null && update.sessionId != sessionId) return + if (update.mediaFileId != null && update.mediaFileId != state.mediaFileId) return + val key = update.syncKey ?: update.subtitleId?.let(SubtitleSyncState::storedKey) ?: return + subtitleSync.timingChanged(key) + } + + /** A step of a sync job of the file (realtime): progress, then the outcome. */ + fun applySubtitleSyncUpdated(update: PlaybackSubtitleSyncUpdated?) { + update ?: return + val state = _uiState.value + val sessionId = state.sessionId ?: return + if (update.sessionId != null && update.sessionId != sessionId) return + if (update.mediaFileId != null && update.mediaFileId != state.mediaFileId) return + subtitleSync.syncUpdated(update) + } + + /** + * A syncable subtitle's timing changed. Media3 keeps the cues it already + * parsed, so when that subtitle is the mounted sidecar, remount the same + * item at the same position to fetch it again. Its cue revision tells the + * sync feedback when the new cues show. + */ + private fun onSubtitleSyncTimingChanged(key: String) { + _uiState.update { state -> + val mounted = state.sessionId != null && subtitlesForVideoMediaMount( + subtitles = state.subtitleTracks, + playbackPlan = state.playbackPlan, + subtitleIdentity = state.localSubtitleMountIdentity ?: state.committedSubtitleIdentity, + ).includesSyncKey(key) + state.copy( + subtitleCueRevisions = state.subtitleCueRevisions + (key to (state.subtitleCueRevisions[key] ?: 0) + 1), + subtitleRefreshNonce = if (mounted) state.subtitleRefreshNonce + 1 else state.subtitleRefreshNonce, + ) + } + } + private suspend fun doRefreshSubtitles(autoSelectSubtitleId: Int?) { val state = _uiState.value val mediaFileId = state.mediaFileId ?: return @@ -4000,6 +4651,13 @@ class PlayerViewModel( onSeek(target) } + /** + * The intro pill's Select in a Watch Party: resolves the pill and returns + * where to go, so the screen can send it as a room seek instead of moving + * this player alone. + */ + fun selectIntroPromptTarget(): Double? = introAutoSkipController.select() + /** * System back while the intro pill is showing: take it down and resolve the * intro without moving playback. True when a pill was actually dismissed, @@ -4045,10 +4703,23 @@ class PlayerViewModel( /** On Deck tap — an explicit choice: reset the pass-out streak and load * the picked item in place (resuming its saved position, iOS parity). */ fun playOnDeckItemNow(contentId: String) { + // Only the room chooses what a Watch Party plays. + if (inRoom) return autoPlayGuard.recordUserAction() upNextCountdownJob?.cancel() upNextCountdownJob = null - _uiState.update { it.copy(showUpNext = false, upNextCountdownSeconds = null) } + // An On Deck item is outside the shuffle: playback leaves it, but the + // shuffle stays on the server for another device to continue. An + // advance, part change, or Pick Another still running must not move + // the shuffle or load over the On Deck item. + if (shuffleAdvanceJob?.isActive == true) { + shuffleAdvanceJob?.cancel() + // An advance may already hold the transition for its pick. + nextUpTransitionGate.cancel() + } + shufflePickJob?.cancel() + shufflePlayback = null + _uiState.update { it.copy(showUpNext = false, upNextCountdownSeconds = null, shuffle = null) } viewModelScope.launch { val outgoingSession = retainedOwnedSessionId ?: _uiState.value.sessionId if (!sessionLifecycle.stop(expectedSessionId = outgoingSession)) return@launch @@ -4057,6 +4728,10 @@ class PlayerViewModel( } private fun resolveNextEpisode() { + if (shufflePlayback != null) { + resolveShuffleNext() + return + } val state = _uiState.value val seriesId = state.seriesId ?: return val curSeason = state.seasonNumber ?: return @@ -4089,6 +4764,7 @@ class PlayerViewModel( runtimeMinutes = next.runtime, seriesTitle = state.seriesTitle, overview = next.overview, + isUnwatched = org.prairieserver.prairie.model.settings.EpisodeSpoilers.isUnwatched(next.userData), ) _uiState.update { // Drop the result if the player has since moved to another item. @@ -4121,8 +4797,19 @@ class PlayerViewModel( */ fun onApproachingEnd(videoEnded: Boolean = false) { if (nextUpTransitionGate.isActive) return - // Watch Together is authoritative — never auto-advance a room member. + // Watch Party is authoritative — never auto-advance a room member. if (remoteTransportSuppressed) return + if (shufflePlayback != null) { + // A multi-part pick plays its later parts before the shuffle moves on. + shuffleNextPartFileId?.let { fileId -> + if (videoEnded) playNextShufflePart(fileId) + return + } + if (!shuffleRefreshedForPostRoll) { + shuffleRefreshedForPostRoll = true + refreshShuffleNext() + } + } if (autoAdvanceHandled) { if (videoEnded) { // Keep Watching dismisses the credits prompt, but finishing the @@ -4271,6 +4958,10 @@ class PlayerViewModel( _uiState.update { it.copy(showUpNext = false, upNextCountdownSeconds = null) } return } + if (shufflePlayback != null) { + advanceShuffle() + return + } val nextContentId = _uiState.value.nextEpisode?.contentId ?: return if (!nextUpTransitionGate.begin(nextContentId)) return _uiState.update { @@ -4296,6 +4987,205 @@ class PlayerViewModel( } } + // ---- Shuffle ---------------------------------------------------------------- + + /** + * Plays this player's items as picks of the running shuffle [shuffleId]: + * the Up Next card announces the server's next pick, and no sequential + * next episode is offered. Called once, before the route's first load. + */ + fun attachShuffle(shuffleId: String?) { + if (shuffleId.isNullOrBlank() || shufflePlayback?.shuffleId == shuffleId) return + val store = shuffleFeatureStore ?: return + shufflePlayback = store.playback(shuffleId) + _uiState.update { it.copy(shuffle = ShuffleUiState()) } + } + + private fun resolveShuffleNext() { + val shuffle = shufflePlayback ?: return + val state = _uiState.value + val forContentId = state.contentId + // The post-roll waits for the last part of a multi-part pick. + shuffleNextPartFileId = org.prairieserver.prairie.playback.nextPlaybackPartFileId( + versions = state.versions, + currentFileId = state.versions.getOrNull(state.selectedVersionIndex)?.fileId, + ) + if (shuffleNextPartFileId != null) return + resolveNextEpisodeJob?.cancel() + // The advance that started this item already returned its next pick. + if (shuffle.latest?.current?.contentId == forContentId) { + publishShuffleNext(forContentId) + return + } + resolveNextEpisodeJob = viewModelScope.launch { + shuffle.refresh() + publishShuffleNext(forContentId) + } + } + + /** Re-reads the shuffle as the card opens: the server replaces a next pick that can no longer play. */ + private fun refreshShuffleNext() { + val shuffle = shufflePlayback ?: return + val forContentId = _uiState.value.contentId + viewModelScope.launch { + shuffle.refresh() + publishShuffleNext(forContentId) + } + } + + /** Shows the shuffle's pick after [forContentId], or the finished state when there is none. */ + private fun publishShuffleNext(forContentId: String) { + val shuffle = shufflePlayback ?: return + if (_uiState.value.contentId != forContentId || nextUpTransitionGate.isActive) return + val info = shuffle.nextPickAfter(forContentId)?.let { pick -> + NextEpisodeInfo( + contentId = pick.contentId, + seasonNumber = pick.seasonNumber, + episodeNumber = pick.episodeNumber, + title = pick.title, + stillUrl = pick.stillUrl, + stillThumbhash = pick.stillThumbhash, + runtimeMinutes = pick.runtimeMinutes, + seriesTitle = pick.seriesTitle, + overview = pick.overview, + isUnwatched = pick.isUnwatched, + isEpisode = pick.isEpisode, + ) + } + val previous = _uiState.value.nextEpisode + _uiState.update { + it.copy( + nextEpisode = info, + shuffle = it.shuffle?.copy(scopeLabel = shuffle.scopeLabel ?: it.shuffle.scopeLabel), + ) + } + val countingDownAtEnd = upNextCountdownJob?.isActive == true && _uiState.value.upNextVideoEnded + when { + info == null -> cancelUpNextCountdown() + // A different pick, such as Pick Another, gets the full countdown + // rather than whatever was left of the previous one. + previous != null && previous.contentId != info.contentId && countingDownAtEnd -> { + _uiState.update { it.copy(upNextCountdownSeconds = UP_NEXT_COUNTDOWN_SECONDS) } + startUpNextCountdown() + } + previous == null && !autoAdvanceHandled -> + pendingApproachingEndVideoEnded?.let { videoEnded -> commitApproachingEnd(info, videoEnded) } + } + } + + /** Plays the next part of a multi-part pick from its start. */ + private fun playNextShufflePart(fileId: Int) { + if (shuffleAdvanceJob?.isActive == true) return + shuffleNextPartFileId = null + val contentId = _uiState.value.contentId + val shuffle = shufflePlayback + // Every load moves this counter, so a reload of the same item during the stop also ends the part change. + val loadGeneration = aiPlaybackGeneration + fun stillCurrent() = loadGeneration == aiPlaybackGeneration && + shufflePlayback === shuffle && _uiState.value.contentId == contentId + shuffleAdvanceJob = viewModelScope.launch { + val outgoingSession = retainedOwnedSessionId ?: _uiState.value.sessionId + if (!sessionLifecycle.stop(expectedSessionId = outgoingSession)) { + // Nothing played, so the part is still next when playback ends. + if (stillCurrent() && shuffleNextPartFileId == null) shuffleNextPartFileId = fileId + return@launch + } + if (!stillCurrent()) return@launch + loadContent( + contentId = contentId, + preferredFileId = fileId, + resumePositionOverride = 0.0, + suppressResumeRewind = true, + preserveRouteIntent = true, + ) + } + } + + /** + * Moves the shuffle past the item that played and plays the returned + * pick from the beginning. The server moves on only while that item is + * still current, so a repeated press or a retry plays the same pick. + */ + private fun advanceShuffle() { + val shuffle = shufflePlayback ?: return + if (_uiState.value.nextEpisode == null) return + if (shuffleAdvanceJob?.isActive == true || nextUpTransitionGate.isActive) return + val fromContentId = _uiState.value.contentId + _uiState.update { it.copy(upNextCountdownSeconds = null) } + val pendingPick = shufflePickJob + shuffleAdvanceJob = viewModelScope.launch { + // A Pick Another still in flight decides what plays; wait for it. + pendingPick?.join() + if (shufflePlayback !== shuffle) return@launch + val result = shuffle.advance(fromContentId) + if (_uiState.value.contentId != fromContentId || shufflePlayback !== shuffle) return@launch + val target = (result as? ApiResult.Success)?.data?.current?.contentId + if (target == null) { + if (shuffle.exhausted) publishShuffleNext(fromContentId) else showShuffleMessage(SHUFFLE_CONTINUE_FAILED) + return@launch + } + if (!nextUpTransitionGate.begin(target)) return@launch + _uiState.update { + it.copy(showUpNext = true, isNextUpTransitioning = true, upNextCountdownSeconds = null) + } + val outgoingSession = retainedOwnedSessionId ?: _uiState.value.sessionId + if (!sessionLifecycle.stop(expectedSessionId = outgoingSession)) { + nextUpTransitionGate.cancel() + _uiState.update { it.copy(showUpNext = false, isNextUpTransitioning = false) } + return@launch + } + if (!nextUpTransitionGate.isActive) return@launch + loadContent( + contentId = target, + // Like the first pick, every shuffled item plays from the start. + resumePositionOverride = 0.0, + suppressResumeRewind = true, + ) + } + } + + /** Up Next "Pick Another": the server replaces the announced pick. */ + fun pickAnotherShuffle() { + val shuffle = shufflePlayback ?: return + if (shufflePickJob?.isActive == true || shuffleAdvanceJob?.isActive == true || nextUpTransitionGate.isActive) return + val forContentId = _uiState.value.contentId + _uiState.update { it.copy(shuffle = it.shuffle?.copy(pickingAnother = true)) } + shufflePickJob = viewModelScope.launch { + val result = shuffle.pickAnother() + _uiState.update { it.copy(shuffle = it.shuffle?.copy(pickingAnother = false)) } + if (_uiState.value.contentId != forContentId || shufflePlayback !== shuffle) return@launch + if (result !is ApiResult.Success && !shuffle.exhausted) { + showShuffleMessage(SHUFFLE_PICK_FAILED) + return@launch + } + publishShuffleNext(forContentId) + } + } + + /** + * Up Next "Stop shuffling": ends the shuffle on the server. The screen + * leaves the player right after, back to where the shuffle started. + */ + fun stopShuffling() { + val shuffle = shufflePlayback ?: return + shufflePlayback = null + shuffleAdvanceJob?.cancel() + shufflePickJob?.cancel() + upNextCountdownJob?.cancel() + upNextCountdownJob = null + shuffle.stopInBackground() + _uiState.update { it.copy(shuffle = null, nextEpisode = null, upNextCountdownSeconds = null) } + } + + private fun showShuffleMessage(message: String) { + shuffleMessageJob?.cancel() + _uiState.update { it.copy(shuffle = it.shuffle?.copy(message = message)) } + shuffleMessageJob = viewModelScope.launch { + delay(4_000) + _uiState.update { it.copy(shuffle = it.shuffle?.copy(message = null)) } + } + } + // ---- Settings setters (forward to per-profile DataStore) ------------------- fun onSetPlaybackSpeed(value: Double) { viewModelScope.launch { playerSettingsStore.setPlaybackSpeed(value) } @@ -4336,8 +5226,16 @@ class PlayerViewModel( viewModelScope.launch { playerSettingsStore.setDolbyVisionEnabled(value) } } - fun onSetSubtitleAppearance(value: SubtitleAppearance) { - viewModelScope.launch { playerSettingsStore.setSubtitleAppearance(value) } + /** + * Commits a subtitle-appearance change via a transform rather than a + * precomputed value (replaced the former `onSetSubtitleAppearance`). + * [PlayerSettingsStore.updateSubtitleAppearance] applies it atomically + * inside the store's own write transaction, so two edits committing + * around the same time (e.g. two opacity fields as the sheet is + * dismissed) can't race on a snapshot read before either writes. + */ + fun onEditSubtitleAppearance(transform: (SubtitleAppearance) -> SubtitleAppearance) { + viewModelScope.launch { playerSettingsStore.updateSubtitleAppearance(transform) } } /** @@ -4459,6 +5357,8 @@ class PlayerViewModel( * flag, not by a sentinel. */ private fun startVersionPlayback(index: Int, isRecovery: Boolean = false) { + // A Watch Party plays exactly the room's file; switching versions is refused. + if (inRoom) return val state = _uiState.value val version = state.versions.getOrNull(index) ?: return if (!isRecovery && index == state.selectedVersionIndex) return @@ -4466,6 +5366,9 @@ class PlayerViewModel( routeIntentState.recoverVersionSelection(state.contentId) } else { routeIntentState.beginVersionSelection(state.contentId, version.fileId) + // Choosing a file drops a picked quality: a quality served by + // another version would move playback straight off this one. + sessionQualityOverride = null } val currentVersion = state.versions.getOrNull(state.selectedVersionIndex) val carriedAudioIndex = desiredAudio @@ -4569,6 +5472,16 @@ class PlayerViewModel( } } + /** + * Holds the controls on screen while the seek bar is dragged: an auto-hide + * mid-drag would remove the bar and drop the seek. The timer restarts when + * the drag ends. + */ + fun onScrubbingChanged(active: Boolean) { + scrubbing = active + scheduleControlsHide() + } + /** Called when the user exits the player. */ fun onExit() { if (!exitPrepared.compareAndSet(false, true)) return @@ -4641,6 +5554,7 @@ class PlayerViewModel( private fun scheduleControlsHide() { controlsHideJob?.cancel() + if (scrubbing) return controlsHideJob = viewModelScope.launch { delay(CONTROLS_AUTO_HIDE_MS) val state = _uiState.value @@ -4720,6 +5634,7 @@ class PlayerViewModel( val watchDetail = loadLocalWatchMetadata( catalogRepository, watchOwner, media.serverId, media.profileId, contentId, libraryId = browseLibraryId, + fileId = fileId, ) { ownsLoad(loadOwner) } if (!ownsLoad(loadOwner)) return false val title = watchDetail?.title ?: sidecar.title @@ -4730,6 +5645,8 @@ class PlayerViewModel( ) val selectedIndex = versions.indexOfFirst { it.fileId == fileId } .coerceAtLeast(0) + // Markers belong to one file; never borrow another version's. + val localVersion = versions.firstOrNull { it.fileId == fileId } // Offline-safe resume: the server's watchDetail may be stale or absent in // airplane mode, so fold in the locally-recorded position and take the // furthest of the two (matches the server's GREATEST semantics). @@ -4779,6 +5696,7 @@ class PlayerViewModel( error = null, title = title, subtitle = subtitle, + contentType = watchDetail?.type, artworkUrl = artworkUrl, // Playback fields — file:// is read directly by Media3, no // server session needed. @@ -4818,10 +5736,10 @@ class PlayerViewModel( localSubtitleMountIdentity = null, subtitleApplying = false, offlineAudioByPosition = offlineAudioByPosition, - intro = watchDetail?.intro, - credits = watchDetail?.credits, - recap = watchDetail?.recap, - preview = watchDetail?.preview, + intro = localVersion?.intro, + credits = localVersion?.credits, + recap = localVersion?.recap, + preview = localVersion?.preview, chapters = versions[selectedIndex].chapters.orEmpty().ifEmpty { sidecar.chapters.orEmpty() }, seriesId = watchDetail?.seriesId, seasonNumber = watchDetail?.seasonNumber, @@ -5035,3 +5953,23 @@ internal fun authoritativePlaybackSubtitleOrdinal( else -> playbackTracks.indexOfFirst { it.index == serverIndex } .takeIf { it >= 0 } } + +/** + * Whether a quality picked earlier carries into this load. It does for a + * reload of the same title outside a room, unless the route itself names a + * quality, and for a reload inside the same room epoch. Any other title or + * room epoch starts from the route's quality. + */ +internal fun keepsPickedQuality( + contentId: String, + previousContentId: String?, + routeNamesQuality: Boolean, + room: WatchPartyPlaybackContext?, + currentRoom: WatchPartyPlaybackContext?, +): Boolean = if (room == null) { + contentId == previousContentId && !routeNamesQuality +} else { + currentRoom != null && + currentRoom.roomId == room.roomId && + currentRoom.selectionRevision == room.selectionRevision +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/QualitySelector.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/QualitySelector.kt deleted file mode 100644 index 231af954be..0000000000 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/QualitySelector.kt +++ /dev/null @@ -1,204 +0,0 @@ -package org.prairieserver.prairie.android.ui.screens.player - -import org.prairieserver.prairie.model.catalog.editionLabel -import org.prairieserver.prairie.android.ui.util.formatBytes -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import org.prairieserver.prairie.model.catalog.FileVersion -import org.prairieserver.prairie.playback.QualityMenuOption - -/** - * Bottom sheet for selecting encode quality (Auto / Original / ladder rungs) - * and optionally a file version when multiple encodes exist. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun QualitySelector( - versions: List, - selectedIndex: Int, - onSelect: (Int) -> Unit, - onDismiss: () -> Unit, - qualityOptions: List = emptyList(), - selectedQualityId: String = "auto", - onSelectQuality: (String) -> Unit = {}, - tabletopPaneHeight: Dp? = null, -) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - - PlayerModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - tabletopPaneHeight = tabletopPaneHeight, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - // Cap below the top edge + keep content flings from - // dismissing the sheet — see PlayerSheetSupport. - .playerSheetContent(tabletopPaneHeight) - .nestedScroll(PlayerSheetFlingGuard) - .padding(bottom = 32.dp), - ) { - PlayerSheetHeader( - title = if (qualityOptions.isNotEmpty() && versions.size > 1) { - "Quality & Version" - } else if (qualityOptions.isNotEmpty()) { - "Quality" - } else { - "Quality" - }, - subtitle = when { - qualityOptions.isNotEmpty() && versions.size > 1 -> - "Choose a transcode rung or source version" - qualityOptions.isNotEmpty() -> - "Choose a transcode rung" - versions.size > 1 -> - "Choose a source version" - else -> - null - }, - onDismiss = onDismiss, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - if (qualityOptions.isNotEmpty()) { - LazyColumn { - items(qualityOptions, key = { it.id }) { option -> - QualityOptionRow( - label = option.label, - detail = option.sublabel.ifBlank { null }, - isSelected = option.id.equals(selectedQualityId, ignoreCase = true), - onClick = { - onSelectQuality(option.id) - onDismiss() - }, - ) - } - } - } - - if (versions.size > 1) { - if (qualityOptions.isNotEmpty()) { - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - Text( - text = "Version", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), - ) - Spacer(modifier = Modifier.height(8.dp)) - } - LazyColumn { - itemsIndexed( - versions, - contentType = { _, _ -> "quality-version" }, - ) { index, version -> - val label = buildString { - version.editionLabel?.let { append(it).append(" · ") } - version.resolution?.let { append(it) } ?: append("Unknown") - if (version.hdr) append(" HDR") - } - val detail = buildString { - version.codecVideo?.uppercase()?.let { append(it) } - version.codecAudio?.uppercase()?.let { - if (isNotEmpty()) append(" + ") - append(it) - } - if (version.fileSize > 0) { - if (isNotEmpty()) append(" - ") - append(formatBytes(version.fileSize)) - } - }.ifEmpty { null } - QualityOptionRow( - label = label, - detail = detail, - isSelected = selectedIndex == index, - onClick = { - onSelect(index) - onDismiss() - }, - ) - } - } - } else if (qualityOptions.isEmpty()) { - Text( - text = "No alternate qualities available", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp), - ) - } - } - } -} - -@Composable -private fun QualityOptionRow( - label: String, - detail: String?, - isSelected: Boolean, - onClick: () -> Unit, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 24.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (isSelected) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = "Selected", - tint = MaterialTheme.colorScheme.primary, - ) - } else { - Spacer(modifier = Modifier.width(24.dp)) - } - - Spacer(modifier = Modifier.width(16.dp)) - - Column { - Text( - text = label, - style = MaterialTheme.typography.bodyLarge, - color = if (isSelected) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface - }, - ) - if (detail != null) { - Text( - text = detail, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/RoomSyncController.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/RoomSyncController.kt deleted file mode 100644 index 3d9b198f14..0000000000 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/RoomSyncController.kt +++ /dev/null @@ -1,423 +0,0 @@ -package org.prairieserver.prairie.android.ui.screens.player - -import android.os.SystemClock -import org.prairieserver.prairie.RoomSyncEngine -import org.prairieserver.prairie.SyncDecision -import org.prairieserver.prairie.model.watchtogether.MemberRole -import org.prairieserver.prairie.model.watchtogether.RoomPlaybackState -import org.prairieserver.prairie.model.watchtogether.RoomSnapshot -import org.prairieserver.prairie.model.watchtogether.TransportAction -import org.prairieserver.prairie.repository.PongSample -import org.prairieserver.prairie.repository.ScheduledTransportCommand -import org.prairieserver.prairie.repository.WatchTogetherRepository -import org.prairieserver.prairie.watchtogether.RoomTransportIntent -import org.prairieserver.prairie.watchtogether.RoomSession -import org.prairieserver.prairie.watchtogether.RoomCommandDispatcher -import org.prairieserver.prairie.watchtogether.RoomControllerLifetime -import org.prairieserver.prairie.watchtogether.RoomPendingExecutions -import org.prairieserver.prairie.watchtogether.externalPlayPauseDecision -import org.prairieserver.prairie.watchtogether.roomTransportAuthorized -import org.prairieserver.prairie.watchtogether.scheduledCommandStillCurrent -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import java.time.Instant - -/** - * Binds a Watch Together room to the mobile [PlayerViewModel] for the lifetime - * of a synced-player screen. Active ONLY when the player route carries a roomId. - * - * The controller OWNS a [RoomSyncEngine] (the shared engine is a pure class that - * lives in the binding, not the repo). It: - * - adopts the application-scoped room session and attaches the player's - * playback session id once it resolves; - * - drives a ping loop so pongs flow, stamps `clientReceivedMs` on each pong and - * feeds [RoomSyncEngine.recordPongSample] to keep the clock offset fresh; - * - feeds each [ScheduledTransportCommand] into [RoomSyncEngine.decide] and - * applies the resulting [SyncDecision] (seek / setPlaying) at the engine- - * scheduled local delay, auto-emitting `ready` on the waiting barrier; - * - emits `state_report` on a ~1.5s cadence, suppressed around a pending execute - * (the only unit-tested decision — see [shouldEmitStateReport]); - * - emits `ready`/`buffering` on buffer transitions while the room is waiting; - * - routes user play/pause/seek through `transport_request`, gated on - * `self_can_control_transport`; - * - surfaces `room_closed` so the screen exits. - * - * ## Clock domains (critical) - * - WALL CLOCK ([System.currentTimeMillis]) is used for everything the engine - * sees: the ping `client_sent_at` RFC3339 string, the `clientReceivedMs` - * pong stamp, and `nowLocalMs` passed to [RoomSyncEngine.decide]. The engine - * derives its offset as `server - clientWallClock`, and the server timestamps - * are wall-clock epoch millis, so these MUST share the same clock. - * - MONOTONIC ([SystemClock.elapsedRealtime]) is used only for the local - * state-report cadence/suppression gate — pure local timing that must not - * jump if the wall clock is adjusted. - */ -class RoomSyncController( - private val roomId: String, - private val repository: WatchTogetherRepository, - private val roomSession: RoomSession, - private val viewModel: PlayerViewModel, - private val scope: CoroutineScope, - private val engine: RoomSyncEngine = RoomSyncEngine(), -) { - companion object { - private const val REPORT_CADENCE_MS = 1_500L - private const val SUPPRESS_WINDOW_MS = 250L - private const val REPORT_TICK_MS = 250L - private const val PING_INTERVAL_MS = 15_000L - } - - /** Live room snapshot for the overlay. */ - val room: StateFlow get() = repository.roomSnapshot - - /** - * Non-null once the server CLOSES the room (host left / explicit close) — the - * screen observes and exits. Transient server `error` frames do NOT set this - * (they would wrongly eject the user); they flow on [WatchTogetherRepository.errors]. - */ - val closedReason: StateFlow get() = repository.roomClosedReason - - // Monotonic-domain state-report cadence bookkeeping. - @Volatile private var lastReportMs = 0L - private val lifetime = RoomControllerLifetime(scope) - private val controllerScope = lifetime.scope - private val deliveryLatch = repository.roomDeliveryLatch - private val commandDispatcher = RoomCommandDispatcher(controllerScope) - private val pendingExecutions = RoomPendingExecutions() - - fun start() { - // Install every inbound/outbound collector before adopting the socket. - // UNDISTPATCHED closes the initial-snapshot race when adopt opens - // synchronously on this same UI turn. - controllerScope.launch(start = CoroutineStart.UNDISPATCHED) { - combine( - viewModel.uiState.map { it.sessionId }.distinctUntilChanged(), - repository.connectionState, - ) { sessionId, connection -> deliveryLatch.keyOrNull(connection, sessionId) } - .distinctUntilChanged() - .collectLatest { key -> - key ?: return@collectLatest - while (isActive && deliveryLatch.needsAttach(key)) { - val delivered = repository.attachSession(key.playbackSessionId) - deliveryLatch.recordAttach(key, delivered) - if (!delivered) { - delay(500) - } - } - } - } - - // Clock-sync: drive pings (once on start + every PING_INTERVAL_MS) and - // fold pongs into the engine's offset estimate. - controllerScope.launch(start = CoroutineStart.UNDISPATCHED) { - combine( - viewModel.uiState.map { it.sessionId }.distinctUntilChanged(), - repository.connectionState, - ) { sessionId, connection -> - deliveryLatch.keyOrNull(connection, sessionId) to connection.writable - } - .distinctUntilChanged() - .collectLatest { (key, writable) -> - if (!writable || key == null) return@collectLatest - while (!deliveryLatch.isAttached(key)) delay(10) - while (isActive) { - repository.ping(clientSentAt = nowWallClockRfc3339()) - delay(PING_INTERVAL_MS) - } - } - } - controllerScope.launch(start = CoroutineStart.UNDISPATCHED) { - repository.pongs.collect { pong -> recordPong(pong) } - } - - // Apply engine decisions for each scheduled transport command. - controllerScope.launch(start = CoroutineStart.UNDISPATCHED) { - repository.transportCommands.collect { scheduled -> handleCommand(scheduled) } - } - - // Drift reporting loop (suppressed around a pending execute). - controllerScope.launch { - while (isActive) { - val state = viewModel.uiState.value - val sessionId = state.sessionId - val deliveryKey = deliveryLatch.keyOrNull( - repository.connectionState.value, - sessionId, - ) - val now = monotonicMs() - if (deliveryKey != null && - deliveryLatch.isServerAttached( - deliveryKey, - repository.roomDeliveryEcho.value, - ) && - shouldEmitStateReport( - now, - lastReportMs, - REPORT_CADENCE_MS, - pendingExecutions.nearestTo(now), - SUPPRESS_WINDOW_MS, - ) - ) { - lastReportMs = now - repository.stateReport( - sessionId = deliveryKey.playbackSessionId, - positionSeconds = state.position, - isPaused = state.isPaused, - ) - } - delay(REPORT_TICK_MS) - } - } - - // ready / buffering during the waiting barrier. - controllerScope.launch(start = CoroutineStart.UNDISPATCHED) { - combine( - viewModel.uiState - .map { it.sessionId to it.isBuffering } - .distinctUntilChanged(), - repository.roomSnapshot - .map { it?.playbackState } - .distinctUntilChanged(), - repository.connectionState, - ) { readiness, playbackState, connection -> Triple(readiness, playbackState, connection) } - .collectLatest { (readiness, playbackState, connection) -> - val (sessionId, buffering) = readiness - val key = deliveryLatch.keyOrNull(connection, sessionId) - if (playbackState != RoomPlaybackState.Waiting || key == null) { - return@collectLatest - } - while ( - !deliveryLatch.isServerAttached( - key, - repository.roomDeliveryEcho.value, - ) - ) { - delay(10) - } - while (isActive && deliveryLatch.needsReadiness(key, buffering)) { - val currentState = viewModel.uiState.value - val delivered = if (buffering) { - repository.buffering( - key.playbackSessionId, - currentState.position, - currentState.isPaused, - ) - } else { - repository.ready( - key.playbackSessionId, - currentState.position, - currentState.isPaused, - ) - } - deliveryLatch.recordReadiness(key, buffering, delivered) - if (!delivered) { - delay(500) - } - } - } - } - - // Lobby and player adopt the same application-scoped connection; route - // disposal does not tear down or duplicate the socket. - roomSession.adopt(roomId) - } - - /** - * Stamp `clientReceivedMs` (wall clock) at collection time and feed the NTP - * sample to the engine. Null guards: a sample with any missing server - * timestamp can't update the offset, so we drop it. - */ - private fun recordPong(pong: PongSample) { - val clientSent = pong.clientSentMs ?: return - val serverReceived = pong.serverReceivedMs ?: return - val serverSent = pong.serverSentMs ?: return - engine.recordPongSample( - clientSentMs = clientSent, - serverReceivedMs = serverReceived, - serverSentMs = serverSent, - clientReceivedMs = System.currentTimeMillis(), - ) - } - - private fun handleCommand(scheduled: ScheduledTransportCommand) { - if (!scheduled.connection.writable || - scheduled.connection != repository.connectionState.value - ) { - return - } - val command = scheduled.command - val state = viewModel.uiState.value - val snapshot = repository.roomSnapshot.value - // executeAtMs null (malformed wire timestamp) → apply immediately; pass - // nowWallClock so the engine computes a 0 delay. - val nowWall = System.currentTimeMillis() - val serverExecuteAtMs = scheduled.executeAtMs ?: nowWall - val decision = engine.decide( - command = command, - serverExecuteAtMs = serverExecuteAtMs, - currentLocalPositionMs = (state.position * 1000.0).toLong(), - currentIsPlaying = !state.isPaused, - nowLocalMs = nowWall, - currentRevision = snapshot?.selectionRevision ?: command.selectionRevision, - attachedSessionId = snapshot?.attachedSessionId ?: state.sessionId, - ) ?: return // duplicate / revision / session gate → ignore - - commandDispatcher.dispatch { - applyDecision( - decision = decision, - sessionId = state.sessionId, - command = command, - acceptedConnection = scheduled.connection, - ) - } - } - - private suspend fun applyDecision( - decision: SyncDecision, - sessionId: String?, - command: org.prairieserver.prairie.model.watchtogether.TransportCommand, - acceptedConnection: org.prairieserver.prairie.repository.WatchTogetherConnectionState, - ) { - val delayMs = decision.localExecuteDelayMs.coerceAtLeast(0L) - // Record the pending execute against the MONOTONIC clock for the - // state-report suppression gate, then wait the engine-computed delay. - val pendingToken = pendingExecutions.record(monotonicMs() + delayMs) - try { - if (delayMs > 0) delay(delayMs) - if ( - !scheduledCommandStillCurrent( - command = command, - acceptedPlaybackSessionId = sessionId, - acceptedRoomId = roomId, - acceptedConnection = acceptedConnection, - currentPlaybackSessionId = viewModel.uiState.value.sessionId, - currentRoom = repository.roomSnapshot.value, - currentConnection = repository.connectionState.value, - ) - ) { - return - } - - // Use the deadband-free immediate-seek path: room corrective seeks can be - // as small as the engine's 0.35s DRIFT_THRESHOLD_MS, well under the 2.0s - // position-mirror deadband in PlayerScreen, so onSeek() alone would never - // reach the MediaController. - decision.seekToMs?.let { ms -> viewModel.seekImmediate(ms / 1000.0) } - // onPlayPause() is a TOGGLE — only flip when the desired state differs - // from current intent. - if (viewModel.uiState.value.isPaused == decision.setPlaying) { - viewModel.onPlayPause() - } - } finally { - pendingExecutions.finish(pendingToken) - } - - // The epoch/session-keyed readiness collector owns ready delivery and - // retries. Do not advance an untracked one-shot latch here. - } - - // ---- User-initiated transport: route through the room instead of local apply ---- - - fun onUserPlayPause() { - val authorization = repository.currentTransportAuthorization() ?: return - val snapshot = authorization.snapshot - if (!roomTransportAuthorized(snapshot, RoomTransportIntent.PlayPause)) return - val state = viewModel.uiState.value - // isPaused is current intent; tapping toggles it. The request carries the - // target action and the new paused state. - val willPause = !state.isPaused - controllerScope.launch { - val delivered = repository.transportRequestForAuthorization( - authorization = authorization, - intent = RoomTransportIntent.PlayPause, - action = if (willPause) TransportAction.Pause.wire else TransportAction.Play.wire, - positionSeconds = state.position, - isPaused = willPause, - ) - if (!delivered) repository.reportDeliveryFailure("room_transport_unavailable") - } - } - - fun onUserSeek(positionSeconds: Double) { - // Seek is host-only (the server rejects guest seeks regardless of policy). - val authorization = repository.currentTransportAuthorization() ?: return - val snapshot = authorization.snapshot - if (!roomTransportAuthorized(snapshot, RoomTransportIntent.Seek)) return - val state = viewModel.uiState.value - controllerScope.launch { - val delivered = repository.transportRequestForAuthorization( - authorization = authorization, - intent = RoomTransportIntent.Seek, - action = TransportAction.Seek.wire, - positionSeconds = positionSeconds, - isPaused = state.isPaused, - ) - if (!delivered) repository.reportDeliveryFailure("room_transport_unavailable") - } - } - - /** - * Reconcile a direct MediaSession play/pause change with room authority. - * - * Returns the authoritative `playWhenReady` value when the caller must - * immediately undo a local-only change. Authorized attempts are also sent - * through the room and take effect later via the scheduled room command. - */ - fun onExternalPlayWhenReadyChanged(localPlayWhenReady: Boolean): Boolean? { - val authorization = repository.currentTransportAuthorization() - val snapshot = authorization?.snapshot - val decision = externalPlayPauseDecision( - snapshot = snapshot, - localPlayWhenReady = localPlayWhenReady, - ) ?: return null - if (authorization == null) return decision.restorePlayWhenReady - val requestIsPaused = decision.requestIsPaused - if (requestIsPaused != null) { - val state = viewModel.uiState.value - controllerScope.launch { - val delivered = repository.transportRequestForAuthorization( - authorization = authorization, - intent = RoomTransportIntent.PlayPause, - action = if (requestIsPaused) { - TransportAction.Pause.wire - } else { - TransportAction.Play.wire - }, - positionSeconds = state.position, - isPaused = requestIsPaused, - ) - if (!delivered) repository.reportDeliveryFailure("room_transport_unavailable") - } - } - return decision.restorePlayWhenReady - } - - /** Leave the room. Host close tears the room down for everyone; both reset local state. */ - fun leave(closeRoom: Boolean) { - engine.reset() - roomSession.depart(closeRoom) - } - - /** Cancel this screen binding without ending the process-owned room lease. */ - fun dispose() { - engine.reset() - lifetime.dispose() - } - - /** Wall-clock RFC3339 string for the ping `client_sent_at`. */ - private fun nowWallClockRfc3339(): String = Instant.ofEpochMilli(System.currentTimeMillis()).toString() - - /** Monotonic clock for local-only cadence/scheduling. */ - private fun monotonicMs(): Long = SystemClock.elapsedRealtime() -} - -/** Convenience: is the local member the room host? */ -val RoomSnapshot.isHost: Boolean get() = selfRole == MemberRole.Host diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/RoomSyncStateReportGate.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/RoomSyncStateReportGate.kt deleted file mode 100644 index 72558d18e5..0000000000 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/RoomSyncStateReportGate.kt +++ /dev/null @@ -1,28 +0,0 @@ -package org.prairieserver.prairie.android.ui.screens.player - -import kotlin.math.abs - -/** - * Pure state-report cadence/suppression decision used by [RoomSyncController]. - * We emit a `state_report` once per [cadenceMs], EXCEPT inside a +/- [suppressWindowMs] - * window around a pending transport command's local execute time — reporting our - * position right as we are about to seek/play would feed the server a stale - * pre-execute sample and fight the sync barrier. - * - * @param nowMs current local (monotonic) clock in ms. - * @param lastReportMs local time the last state_report was emitted. - * @param cadenceMs minimum spacing between reports. - * @param pendingExecuteAtMs local (monotonic) execute time of the next command, or null. - * @param suppressWindowMs half-width of the +/- suppression window around a pending execute. - */ -fun shouldEmitStateReport( - nowMs: Long, - lastReportMs: Long, - cadenceMs: Long, - pendingExecuteAtMs: Long?, - suppressWindowMs: Long, -): Boolean { - if (nowMs - lastReportMs < cadenceMs) return false - if (pendingExecuteAtMs != null && abs(nowMs - pendingExecuteAtMs) <= suppressWindowMs) return false - return true -} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/SleepTimerSheet.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/SleepTimerSheet.kt index f7d126131e..6b850c97ce 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/SleepTimerSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/SleepTimerSheet.kt @@ -1,47 +1,22 @@ package org.prairieserver.prairie.android.ui.screens.player -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.TimerOff import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import org.prairieserver.prairie.common.player.SleepTimerState /** - * Glass-style bottom sheet for arming the sleep timer. Mirrors iOS - * `SleepTimerSheet` semantics: - * - preset rows (15/30/45/60/90) call [onStart] and dismiss the sheet, - * - "Cancel Timer" appears when the timer is currently [SleepTimerState.Active], - * - the row matching [defaultMinutes] is highlighted (white-on-black) so the - * user's last choice is the obvious next pick. - * - * Layout matches `PlayerSettingsSheet` and `SubtitleStyleSheet` for visual parity. + * Arms the sleep timer. Mirrors iOS `SleepTimerSheet` semantics: + * - preset rows (15/30/45/60/90) call [onStart] and close the menu, + * - "Cancel timer" appears while the timer is [SleepTimerState.Active], + * - the preset matching [defaultMinutes] carries the check, so the user's + * last choice is the obvious next pick. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SleepTimerSheet( isVisible: Boolean, @@ -50,92 +25,54 @@ fun SleepTimerSheet( onStart: (Int) -> Unit, onCancel: () -> Unit, onDismiss: () -> Unit, - // Gear-submenu back affordance: dismisses this sheet and reopens the - // parent settings sheet (wired in PlayerOverlay). + // Back affordance: hands over to the settings menu (wired in PlayerOverlay). onBack: (() -> Unit)? = null, tabletopPaneHeight: Dp? = null, ) { if (!isVisible) return - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val scope = rememberCoroutineScope() - val dismissSheet = { scope.dismissPlayerSheet(sheetState, onDismiss) } + val controller = rememberPlayerMenuController(onDismiss) - LaunchedEffect(isVisible) { - if (isVisible) sheetState.show() - } - - PlayerModalBottomSheet( - onDismissRequest = dismissSheet, - sheetState = sheetState, - tabletopPaneHeight = tabletopPaneHeight, - ) { - Box( - modifier = Modifier - .fillMaxWidth() - // Keep the sheet handle below the top screen edge — see - // PlayerSheetSupport. - .playerSheetContent(tabletopPaneHeight) - .nestedScroll(PlayerSheetFlingGuard) - .background( - brush = Brush.verticalGradient( - colors = listOf( - Color(0xFF1F2937).copy(alpha = 0.95f), - Color.Black.copy(alpha = 0.92f), - ), - ), - ), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()), - ) { - PlayerSheetHeader( - title = "Sleep Timer", - onBack = onBack?.let { back -> - { scope.dismissPlayerSheet(sheetState, back) } - }, - onDismiss = dismissSheet, - ) - - if (activeState is SleepTimerState.Active) { - Text( - text = "Pausing in ${formatRemainingDetailed(activeState.remainingSeconds)}", - color = Color.White.copy(alpha = 0.7f), - fontSize = 13.sp, - modifier = Modifier.padding(start = 20.dp, end = 20.dp, bottom = 12.dp), - ) - } else { - Spacer(modifier = Modifier.height(12.dp)) - } - - PRESETS.forEach { preset -> - PresetRow( + PlayerMenu(controller = controller, tabletopPaneHeight = tabletopPaneHeight) { + PlayerPanelHeader( + title = "Sleep timer", + onClose = { controller.dismiss() }, + onBack = onBack?.let { back -> { controller.dismiss(then = back) } }, + backDescription = "Back to settings", + ) + PlayerMenuScrollColumn(horizontalPadding = 12.dp) { + if (activeState is SleepTimerState.Active) { + PlayerFootnote("Pausing in ${formatRemainingDetailed(activeState.remainingSeconds)}") + } + Spacer(Modifier.height(8.dp)) + PlayerGroup { + PRESETS.forEachIndexed { index, preset -> + PlayerCheckRow( label = preset.label, - isSelected = preset.minutes == defaultMinutes, + selected = preset.minutes == defaultMinutes, onClick = { onStart(preset.minutes) - dismissSheet() + controller.dismiss() }, + separator = index > 0, ) } - - if (activeState is SleepTimerState.Active) { - Spacer(modifier = Modifier.height(8.dp)) - PresetRow( - label = "Cancel Timer", - isSelected = false, - isDestructive = true, + } + if (activeState is SleepTimerState.Active) { + Spacer(Modifier.height(12.dp)) + PlayerGroup { + PlayerActionRow( + icon = Icons.Rounded.TimerOff, + label = "Cancel timer", + tint = PlayerChrome.Stop, onClick = { onCancel() - dismissSheet() + controller.dismiss() }, ) } - - Spacer(modifier = Modifier.height(24.dp)) } + Spacer(Modifier.height(12.dp)) } } } @@ -150,54 +87,6 @@ private val PRESETS = listOf( Preset(90, "1 hour 30 minutes"), ) -@Composable -private fun PresetRow( - label: String, - isSelected: Boolean, - isDestructive: Boolean = false, - onClick: () -> Unit, -) { - val shape = RoundedCornerShape(12.dp) - val rowModifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp) - .clickable(onClick = onClick) - .height(60.dp) - val styledModifier = when { - isSelected -> rowModifier.background(color = Color.White, shape = shape) - isDestructive -> rowModifier - .background(color = Color.Transparent, shape = shape) - .border(width = 1.dp, color = Color(0xFFEF4444), shape = shape) - else -> rowModifier - .background(color = Color.Transparent, shape = shape) - .border(width = 1.dp, color = Color.White.copy(alpha = 0.6f), shape = shape) - } - Row( - modifier = styledModifier.padding(horizontal = 20.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = label, - color = when { - isSelected -> Color.Black - isDestructive -> Color(0xFFEF4444) - else -> Color.White - }, - fontSize = 16.sp, - fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal, - modifier = Modifier.weight(1f), - ) - if (isSelected) { - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "Default", - color = Color.Black.copy(alpha = 0.5f), - fontSize = 12.sp, - ) - } - } -} - /** * Compact remaining-time format for chips: "3m", "45s", "1m 12s". */ @@ -213,7 +102,7 @@ internal fun formatRemaining(seconds: Int): String { } /** - * Verbose form used for the "Pausing in …" subtitle when the sheet is open. + * Verbose form used for the "Pausing in …" line while the menu is open. */ private fun formatRemainingDetailed(seconds: Int): String { val safe = seconds.coerceAtLeast(0) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleSearchSheet.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleSearchSheet.kt index 5ad8ca586b..3de7d44b1a 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleSearchSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleSearchSheet.kt @@ -1,5 +1,7 @@ package org.prairieserver.prairie.android.ui.screens.player +import org.prairieserver.prairie.android.ui.components.PrairieDropdownMenuItem +import org.prairieserver.prairie.android.ui.components.PrairieDropdownMenu import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -18,9 +20,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -41,7 +42,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import org.prairieserver.prairie.android.ui.util.LanguageNames +import org.prairieserver.prairie.common.ui.LanguageNames import org.prairieserver.prairie.model.subtitles.SubtitleResult import androidx.compose.foundation.layout.width import androidx.compose.material.icons.filled.Search @@ -91,35 +92,18 @@ fun SubtitleSearchSheet( // On successful download return to the tracks sheet (via onBack) so the // freshly added track is visible, rather than dropping onto bare player // controls. One-shot flag in VM. + val controller = rememberPlayerMenuController(onDismiss) + val back = onBack?.let { parent -> { controller.dismiss(then = parent) } } + LaunchedEffect(tools.downloadCompleted) { - if (tools.downloadCompleted) (onBack ?: onDismiss)() + if (tools.downloadCompleted) (back ?: { controller.dismiss() })() } - PlayerModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - tabletopPaneHeight = tabletopPaneHeight, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - // Cap below the top edge + keep content flings from - // dismissing the sheet — see PlayerSheetSupport. - .playerSheetContent(tabletopPaneHeight) - .nestedScroll(PlayerSheetFlingGuard) - .background( - brush = Brush.verticalGradient( - colors = listOf( - Color(0xFF1F2937).copy(alpha = 0.95f), - Color.Black.copy(alpha = 0.92f), - ), - ), - ), - ) { + PlayerMenu(controller = controller, tabletopPaneHeight = tabletopPaneHeight) { PlayerSheetHeader( - title = "Search Subtitles", - onBack = onBack, - onDismiss = onDismiss, + title = "Find subtitles", + onBack = back, + onDismiss = { controller.dismiss() }, ) Row( @@ -133,7 +117,7 @@ fun SubtitleSearchSheet( Row( modifier = Modifier .fillMaxWidth() - .background(Color.White.copy(alpha = 0.08f), RoundedCornerShape(8.dp)) + .background(PlayerChrome.Raised, RoundedCornerShape(12.dp)) .clickable { languageMenuExpanded = true } .padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, @@ -149,13 +133,13 @@ fun SubtitleSearchSheet( tint = Color.White.copy(alpha = 0.7f), ) } - DropdownMenu( + PrairieDropdownMenu( expanded = languageMenuExpanded, onDismissRequest = { languageMenuExpanded = false }, modifier = Modifier.heightIn(max = 320.dp), ) { LanguageNames.dropdownOptions.forEach { (code, label) -> - DropdownMenuItem( + PrairieDropdownMenuItem( text = { Text(label) }, onClick = { selectedLanguage = code @@ -168,12 +152,16 @@ fun SubtitleSearchSheet( Button( onClick = { onSearch(selectedLanguage) }, enabled = !tools.searchLoading, + colors = ButtonDefaults.buttonColors( + containerColor = PlayerChrome.Paper, + contentColor = PlayerChrome.Ink, + ), ) { if (tools.searchLoading) { CircularProgressIndicator( modifier = Modifier.size(18.dp), strokeWidth = 2.dp, - color = Color.White, + color = PlayerChrome.Ink, ) } else { Icon( @@ -217,13 +205,7 @@ fun SubtitleSearchSheet( LazyColumn( modifier = Modifier .fillMaxWidth() - .then( - if (tabletopPaneHeight == null) { - Modifier.heightIn(max = 420.dp) - } else { - Modifier.weight(1f) - }, - ), + .weight(1f, fill = false), ) { items(tools.searchResults, key = { "${it.provider}:${it.id}" }) { result -> val key = "${result.provider}:${result.id}" @@ -236,8 +218,7 @@ fun SubtitleSearchSheet( } } - Spacer(modifier = Modifier.height(24.dp)) - } + Spacer(modifier = Modifier.height(12.dp)) } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleStyleSheet.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleStyleSheet.kt index b036713352..0bb7434240 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleStyleSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleStyleSheet.kt @@ -1,515 +1,297 @@ package org.prairieserver.prairie.android.ui.screens.player import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -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 import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Slider -import androidx.compose.material3.SliderDefaults -import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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 -import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import org.prairieserver.prairie.model.settings.SubtitleAppearance import org.prairieserver.prairie.model.settings.SubtitleBackgroundStylePreset import org.prairieserver.prairie.model.settings.SubtitleFontSizePreset import org.prairieserver.prairie.model.settings.SubtitlePositionPreset /** - * Glass-style bottom sheet for editing the user's [SubtitleAppearance]: - * font size/family/color, background style/color/opacity, optional outline, - * and on-screen position. + * Editor for the user's [SubtitleAppearance]: size, font, colors, background, + * outline and position. Docked beside the picture, the cue on screen is the + * preview, so there is no sample box of its own. * - * Each control writes back the full sanitized appearance via [onUpdate]; - * the consuming layer (`PlayerViewModel` / `PlayerSettingsStore`) is - * responsible for persistence and propagating to [org.prairieserver.prairie.common.player.SubtitleManager]. + * Each control writes back a transform via [onUpdate] rather than a + * precomputed value built from [appearance]: that parameter is a + * composable-captured snapshot that can go stale between when a control's + * closure is built and when it actually runs (e.g. two opacity fields + * committing independently as the menu is dismissed), so the caller applies + * the transform against the freshest value it can read instead. The + * consuming layer (`PlayerViewModel` / `PlayerSettingsStore`) is responsible + * for persistence and propagating to [org.prairieserver.prairie.common.player.SubtitleManager]. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SubtitleStyleSheet( isVisible: Boolean, appearance: SubtitleAppearance, - onUpdate: (SubtitleAppearance) -> Unit, + onUpdate: ((SubtitleAppearance) -> SubtitleAppearance) -> Unit, onDismiss: () -> Unit, - // Gear-submenu back affordance: dismisses this sheet and reopens the - // parent settings sheet (wired in PlayerOverlay). + // False when the server is known to discard text opacity; the row is + // hidden rather than offering a value that will not be kept. + showTextOpacity: Boolean = true, + // Back affordance: hands over to the settings menu (wired in PlayerOverlay). onBack: (() -> Unit)? = null, tabletopPaneHeight: Dp? = null, ) { if (!isVisible) return - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val scope = rememberCoroutineScope() - val scrollState = rememberScrollState() - val dismissSheet = { scope.dismissPlayerSheet(sheetState, onDismiss) } + val controller = rememberPlayerMenuController(onDismiss) - LaunchedEffect(isVisible) { - if (isVisible) sheetState.show() - } - - PlayerModalBottomSheet( - onDismissRequest = dismissSheet, - sheetState = sheetState, - tabletopPaneHeight = tabletopPaneHeight, - ) { - Box( - modifier = Modifier - .fillMaxWidth() - // Cap below the top edge + keep content flings from - // dismissing the sheet — see PlayerSheetSupport. - .playerSheetContent(tabletopPaneHeight) - .nestedScroll(PlayerSheetFlingGuard) - .background( - brush = Brush.verticalGradient( - colors = listOf( - Color(0xFF1F2937).copy(alpha = 0.95f), - Color.Black.copy(alpha = 0.92f), - ), - ), - ), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(scrollState), - ) { - PlayerSheetHeader( - title = "Subtitle Style", - onBack = onBack?.let { back -> - { scope.dismissPlayerSheet(sheetState, back) } - }, - onDismiss = dismissSheet, - ) - - // ---- Text section ------------------------------------------------ - SectionHeader("Text") - - FontSizeRow( - selected = appearance.fontSize, - onSelect = { value -> - onUpdate(appearance.copy(fontSize = value).sanitized()) - }, - ) - - FontFamilyRow( - selected = appearance.fontFamily, - onSelect = { value -> - onUpdate(appearance.copy(fontFamily = value).sanitized()) - }, - ) + PlayerMenu(controller = controller, tabletopPaneHeight = tabletopPaneHeight) { + PlayerPanelHeader( + title = "Subtitle style", + onClose = { controller.dismiss() }, + onBack = onBack?.let { back -> { controller.dismiss(then = back) } }, + backDescription = "Back to settings", + ) + PlayerMenuScrollColumn(horizontalPadding = 18.dp) { + val sizeIndex = FONT_SIZES.indexOfFirst { it.first == appearance.fontSize } + PlayerFieldLabel("Size", FONT_SIZES.getOrNull(sizeIndex)?.third) + PlayerChoiceSegments( + options = FONT_SIZES.map { it.second }, + selectedIndex = sizeIndex, + onSelect = { index -> onUpdate { it.copy(fontSize = FONT_SIZES[index].first).sanitized() } }, + ) - ColorSwatchRow( - label = "Text Color", - swatches = TEXT_COLOR_SWATCHES, - selectedHex = appearance.fontColor, - onSelect = { hex -> - onUpdate(appearance.copy(fontColor = hex).sanitized()) - }, - ) + val familyIndex = FONT_FAMILIES.indexOfFirst { it.first == appearance.fontFamily } + PlayerFieldLabel("Font", FONT_FAMILIES.getOrNull(familyIndex)?.second) + PlayerChoiceSegments( + options = FONT_FAMILIES.map { it.second }, + selectedIndex = familyIndex, + onSelect = { index -> onUpdate { it.copy(fontFamily = FONT_FAMILIES[index].first).sanitized() } }, + ) - // ---- Background section ----------------------------------------- - SectionHeader("Background") + val textColorIndex = TEXT_COLORS.indexOfFirst { colorsEqual(it.first, appearance.fontColor) } + PlayerFieldLabel("Text color", TEXT_COLORS.getOrNull(textColorIndex)?.second) + PlayerSwatchRow( + colors = TEXT_COLORS.map { hexToComposeColor(it.first) to it.second }, + selectedIndex = textColorIndex, + onSelect = { index -> onUpdate { it.copy(fontColor = TEXT_COLORS[index].first).sanitized() } }, + ) - BackgroundStyleRow( - selected = appearance.backgroundStyle, - onSelect = { value -> - onUpdate(appearance.copy(backgroundStyle = value).sanitized()) - }, + if (showTextOpacity) { + PercentField( + label = "Text opacity", + value = appearance.textOpacity, + min = 1, + onChange = { value -> onUpdate { it.copy(textOpacity = value).sanitized() } }, ) + } - ColorSwatchRow( - label = "Background Color", - swatches = BACKGROUND_COLOR_SWATCHES, - selectedHex = appearance.backgroundColor, - onSelect = { hex -> - onUpdate(appearance.copy(backgroundColor = hex).sanitized()) - }, - ) + val backgroundIndex = BACKGROUND_STYLES.indexOfFirst { it.first == appearance.backgroundStyle } + PlayerFieldLabel("Background", BACKGROUND_STYLES.getOrNull(backgroundIndex)?.second) + PlayerChoiceSegments( + options = BACKGROUND_STYLES.map { it.second }, + selectedIndex = backgroundIndex, + onSelect = { index -> + onUpdate { it.copy(backgroundStyle = BACKGROUND_STYLES[index].first).sanitized() } + }, + ) - OpacityRow( - opacity = appearance.backgroundOpacity, - onChange = { value -> - onUpdate(appearance.copy(backgroundOpacity = value).sanitized()) - }, - ) + val backgroundColorIndex = BACKGROUND_COLORS.indexOfFirst { + colorsEqual(it.first, appearance.backgroundColor) + } + PlayerFieldLabel("Background color", BACKGROUND_COLORS.getOrNull(backgroundColorIndex)?.second) + PlayerSwatchRow( + colors = BACKGROUND_COLORS.map { hexToComposeColor(it.first) to it.second }, + selectedIndex = backgroundColorIndex, + onSelect = { index -> + onUpdate { it.copy(backgroundColor = BACKGROUND_COLORS[index].first).sanitized() } + }, + ) - // ---- Outline section -------------------------------------------- - SectionHeader("Outline") + PercentField( + label = "Background opacity", + value = appearance.backgroundOpacity, + min = 0, + onChange = { value -> onUpdate { it.copy(backgroundOpacity = value).sanitized() } }, + ) - ToggleRow( - label = "Text Outline", - subtitle = null, + Spacer(Modifier.height(14.dp)) + PlayerGroup { + PlayerSwitchRow( + label = "Text outline", checked = appearance.textOutline, - onCheckedChange = { value -> - onUpdate(appearance.copy(textOutline = value).sanitized()) - }, + onCheckedChange = { value -> onUpdate { it.copy(textOutline = value).sanitized() } }, ) - - if (appearance.textOutline) { - ColorSwatchRow( - label = "Outline Color", - swatches = BACKGROUND_COLOR_SWATCHES, - selectedHex = appearance.textOutlineColor, - onSelect = { hex -> - onUpdate(appearance.copy(textOutlineColor = hex).sanitized()) - }, - ) + } + if (appearance.textOutline) { + val outlineIndex = BACKGROUND_COLORS.indexOfFirst { + colorsEqual(it.first, appearance.textOutlineColor) } - - // ---- Position section ------------------------------------------- - SectionHeader("Position") - - PositionRow( - selected = appearance.position, - onSelect = { value -> - onUpdate(appearance.copy(position = value).sanitized()) + PlayerFieldLabel("Outline color", BACKGROUND_COLORS.getOrNull(outlineIndex)?.second) + PlayerSwatchRow( + colors = BACKGROUND_COLORS.map { hexToComposeColor(it.first) to it.second }, + selectedIndex = outlineIndex, + onSelect = { index -> + onUpdate { it.copy(textOutlineColor = BACKGROUND_COLORS[index].first).sanitized() } }, ) - - Spacer(modifier = Modifier.height(24.dp)) } - } - } -} -@Composable -private fun SectionHeader(text: String) { - Text( - text = text.uppercase(), - color = Color.White.copy(alpha = 0.6f), - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier.padding(start = 20.dp, end = 20.dp, top = 12.dp, bottom = 4.dp), - ) -} - -@Composable -private fun FontSizeRow( - selected: SubtitleFontSizePreset, - onSelect: (SubtitleFontSizePreset) -> Unit, -) { - val options = listOf( - SubtitleFontSizePreset.Small to "Small", - SubtitleFontSizePreset.Medium to "Medium", - SubtitleFontSizePreset.Large to "Large", - SubtitleFontSizePreset.XLarge to "X-Large", - SubtitleFontSizePreset.XXLarge to "XX-Large", - ) - Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp)) { - Text( - text = "Font Size", - color = Color.White, - fontSize = 16.sp, - ) - Spacer(modifier = Modifier.height(8.dp)) - LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - items(options) { (value, label) -> - PillButton( - label = label, - isSelected = selected == value, - onClick = { onSelect(value) }, - ) - } + val positionIndex = POSITIONS.indexOfFirst { it.first == appearance.position } + PlayerFieldLabel("Position", POSITIONS.getOrNull(positionIndex)?.second) + PlayerChoiceSegments( + options = POSITIONS.map { it.second }, + selectedIndex = positionIndex, + onSelect = { index -> onUpdate { it.copy(position = POSITIONS[index].first).sanitized() } }, + ) + Spacer(Modifier.height(16.dp)) } } } +/** + * A typed percentage (`min`-100), for controls where dragging a slider is more + * fiddly than typing the number — opacity wants precision at the low end where + * a few percent is the difference between legible and not. + */ @Composable -private fun FontFamilyRow( - selected: String, - onSelect: (String) -> Unit, +private fun PercentField( + label: String, + value: Int, + min: Int, + onChange: (Int) -> Unit, ) { - val options = listOf( - SubtitleAppearance.SANS_SERIF to "Sans-serif", - SubtitleAppearance.SERIF to "Serif", - SubtitleAppearance.MONOSPACE to "Monospace", - ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Font Family", - color = Color.White, - fontSize = 16.sp, - modifier = Modifier.weight(1f), - ) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - options.forEach { (value, label) -> - PillButton( - label = label, - isSelected = selected == value, - onClick = { onSelect(value) }, - ) - } + var text by remember(value) { mutableStateOf(value.toString()) } + // The last value handed to onChange that `value` has not caught up with + // yet. Done commits and then clears focus, which commits again on blur; + // without this the second commit would repeat the same write. + var sent by remember(value) { mutableStateOf(null) } + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + + // Committing on every keystroke fights the clamp: typing "0" below the + // floor calls onChange(min), which can equal the value already in effect, + // so `value` never changes and `remember(value)` never re-keys the draft + // back to a valid display. Committing once, on blur/Done, avoids that + // entirely and lets the field hold invalid intermediate text (including + // empty) while the user is still typing. + fun commit() { + val clamped = text.toIntOrNull()?.coerceIn(min, 100) + if (clamped != null && clamped != value && clamped != sent) { + sent = clamped + onChange(clamped) } + text = (clamped ?: value).toString() } -} -@Composable -private fun BackgroundStyleRow( - selected: SubtitleBackgroundStylePreset, - onSelect: (SubtitleBackgroundStylePreset) -> Unit, -) { - val options = listOf( - SubtitleBackgroundStylePreset.Box to "Box", - SubtitleBackgroundStylePreset.Shadow to "Shadow", - SubtitleBackgroundStylePreset.Outline to "Outline", - SubtitleBackgroundStylePreset.None to "None", - ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Background Style", - color = Color.White, - fontSize = 16.sp, - modifier = Modifier.weight(1f), - ) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - options.forEach { (value, label) -> - PillButton( - label = label, - isSelected = selected == value, - onClick = { onSelect(value) }, - ) - } - } + // Dismissing the menu while the field is still focused (swipe-away, back + // press) tears down this composable without firing onFocusChanged(false), + // so a typed-but-uncommitted percentage would otherwise be silently lost. + // rememberUpdatedState keeps the lambda pointed at the latest commit + // closure across recompositions, so onDispose always commits current text. + val latestCommit = rememberUpdatedState(::commit) + DisposableEffect(Unit) { + onDispose { latestCommit.value() } } -} -@Composable -private fun PositionRow( - selected: SubtitlePositionPreset, - onSelect: (SubtitlePositionPreset) -> Unit, -) { - val options = listOf( - SubtitlePositionPreset.Bottom to "Bottom", - SubtitlePositionPreset.LowerThird to "Lower Third", - SubtitlePositionPreset.Top to "Top", - ) Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 8.dp), + modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 14.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = "Position", - color = Color.White, - fontSize = 16.sp, - modifier = Modifier.weight(1f), - ) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - options.forEach { (value, label) -> - PillButton( - label = label, - isSelected = selected == value, - onClick = { onSelect(value) }, - ) - } - } - } -} - -@Composable -private fun PillButton( - label: String, - isSelected: Boolean, - onClick: () -> Unit, -) { - val shape = RoundedCornerShape(14.dp) - val baseModifier = Modifier.clickable(onClick = onClick) - Box( - modifier = if (isSelected) { - baseModifier.background(color = Color.White, shape = shape) - } else { - baseModifier - .background(color = Color.Transparent, shape = shape) - .border(width = 1.dp, color = Color.White, shape = shape) - }, - ) { - Text( - text = label, - color = if (isSelected) Color.Black else Color.White, - fontSize = 13.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + Text(text = label, style = PlayerType.FieldLabel, modifier = Modifier.weight(1f)) + BasicTextField( + value = text, + onValueChange = { input -> text = input.filter { it.isDigit() }.take(3) }, + singleLine = true, + textStyle = PlayerType.Value.copy(color = PlayerChrome.Paper, textAlign = TextAlign.End), + cursorBrush = SolidColor(PlayerChrome.Paper), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { + commit() + focusManager.clearFocus() + keyboardController?.hide() + }, + ), + modifier = Modifier + .width(56.dp) + .clip(RoundedCornerShape(10.dp)) + .background(PlayerChrome.Raised) + .padding(horizontal = 10.dp, vertical = 8.dp) + .onFocusChanged { focusState -> if (!focusState.isFocused) commit() }, ) + Spacer(modifier = Modifier.width(6.dp)) + Text(text = "%", style = PlayerType.FieldLabel) } } -@Composable -private fun ColorSwatchRow( - label: String, - swatches: List, - selectedHex: String, - onSelect: (String) -> Unit, -) { - Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp)) { - Text( - text = label, - color = Color.White, - fontSize = 16.sp, - ) - Spacer(modifier = Modifier.height(8.dp)) - LazyRow( - horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(horizontal = 0.dp), - ) { - items(swatches) { hex -> - ColorSwatch( - hex = hex, - isSelected = colorsEqual(hex, selectedHex), - onClick = { onSelect(hex) }, - ) - } - } - } -} +private val FONT_SIZES = listOf( + Triple(SubtitleFontSizePreset.Small, "S", "Small"), + Triple(SubtitleFontSizePreset.Medium, "M", "Medium"), + Triple(SubtitleFontSizePreset.Large, "L", "Large"), + Triple(SubtitleFontSizePreset.XLarge, "XL", "Extra large"), + Triple(SubtitleFontSizePreset.XXLarge, "XXL", "Largest"), +) -@Composable -private fun ColorSwatch( - hex: String, - isSelected: Boolean, - onClick: () -> Unit, -) { - val color = hexToComposeColor(hex) - val baseModifier = Modifier - .size(24.dp) - .clickable(onClick = onClick) - .background(color = color, shape = CircleShape) - Box( - modifier = if (isSelected) { - baseModifier.border(width = 2.dp, color = Color.White, shape = CircleShape) - } else { - baseModifier.border(width = 1.dp, color = Color.White.copy(alpha = 0.4f), shape = CircleShape) - }, - ) -} +private val FONT_FAMILIES = listOf( + SubtitleAppearance.SANS_SERIF to "Sans", + SubtitleAppearance.SERIF to "Serif", + SubtitleAppearance.MONOSPACE to "Mono", +) -@Composable -private fun OpacityRow( - opacity: Int, - onChange: (Int) -> Unit, -) { - Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Opacity ${opacity.coerceIn(0, 100)}%", - color = Color.White, - fontSize = 16.sp, - modifier = Modifier.weight(1f), - ) - } - Slider( - value = opacity.coerceIn(0, 100).toFloat(), - onValueChange = { value -> onChange(value.toInt().coerceIn(0, 100)) }, - valueRange = 0f..100f, - steps = 0, - colors = SliderDefaults.colors( - thumbColor = Color.White, - activeTrackColor = Color(0xFF06B6D4), - inactiveTrackColor = Color.White.copy(alpha = 0.25f), - ), - ) - } -} +private val BACKGROUND_STYLES = listOf( + SubtitleBackgroundStylePreset.None to "None", + SubtitleBackgroundStylePreset.Shadow to "Shadow", + SubtitleBackgroundStylePreset.Box to "Box", + SubtitleBackgroundStylePreset.Outline to "Outline", +) -@Composable -private fun ToggleRow( - label: String, - subtitle: String?, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = label, - color = Color.White, - fontSize = 16.sp, - ) - if (subtitle != null) { - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = subtitle, - color = Color.White.copy(alpha = 0.6f), - fontSize = 12.sp, - ) - } - } - Spacer(modifier = Modifier.width(12.dp)) - Switch( - checked = checked, - onCheckedChange = onCheckedChange, - colors = SwitchDefaults.colors( - checkedThumbColor = Color.White, - checkedTrackColor = Color(0xFF06B6D4), - ), - ) - } -} +private val POSITIONS = listOf( + SubtitlePositionPreset.Bottom to "Bottom", + SubtitlePositionPreset.LowerThird to "Lower third", + SubtitlePositionPreset.Top to "Top", +) -private val TEXT_COLOR_SWATCHES = listOf( - "#ffffff", // White - "#facc15", // Yellow - "#22c55e", // Green - "#06b6d4", // Cyan - "#d946ef", // Magenta - "#ef4444", // Red - "#3b82f6", // Blue - "#000000", // Black +private val TEXT_COLORS = listOf( + "#ffffff" to "White", + "#facc15" to "Yellow", + "#22c55e" to "Green", + "#06b6d4" to "Cyan", + "#d946ef" to "Magenta", + "#ef4444" to "Red", + "#3b82f6" to "Blue", + "#9ca3af" to "Gray", + "#000000" to "Black", ) -private val BACKGROUND_COLOR_SWATCHES = listOf( - "#000000", // Black - "#374151", // Dark Gray - "#1e3a5f", // Navy - "#7f1d1d", // Dark Red - "#14532d", // Dark Green +private val BACKGROUND_COLORS = listOf( + "#000000" to "Black", + "#374151" to "Dark gray", + "#1e3a5f" to "Navy", + "#7f1d1d" to "Dark red", + "#14532d" to "Dark green", ) /** Compose `Color` from a `#rrggbb` string. Falls back to white on garbage input. */ diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleSyncCard.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleSyncCard.kt new file mode 100644 index 0000000000..131f8f9df2 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleSyncCard.kt @@ -0,0 +1,151 @@ +package org.prairieserver.prairie.android.ui.screens.player + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Warning +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.prairieserver.prairie.playback.SubtitleSyncNotice + +private val SyncSuccessColor = Color(0xFF6EE7B7) +private val SyncInfoColor = Color(0xFF7DD3FC) +private val SyncWarningColor = Color(0xFFFCD34D) + +/** + * A small card that follows a subtitle sync this viewer started: its progress + * while it runs, then how it ended. It does not block the player; a finished + * card leaves on its own, or on a tap. + */ +@Composable +fun SubtitleSyncCard( + notice: SubtitleSyncNotice?, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + // Keyed by the notice id, so one job's progress updates the card in place + // and a finished card fades out with its last content. + AnimatedContent( + targetState = notice, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + contentKey = { it?.id }, + label = "SubtitleSyncCard", + modifier = modifier, + ) { current -> + if (current != null) SubtitleSyncCardContent(current, onDismiss) + } +} + +@Composable +private fun SubtitleSyncCardContent(notice: SubtitleSyncNotice, onDismiss: () -> Unit) { + val running = notice.tone == SubtitleSyncNotice.Tone.Progress + val accent = when (notice.tone) { + SubtitleSyncNotice.Tone.Progress -> Color.White.copy(alpha = 0.15f) + SubtitleSyncNotice.Tone.Success -> SyncSuccessColor.copy(alpha = 0.45f) + SubtitleSyncNotice.Tone.Info -> SyncInfoColor.copy(alpha = 0.45f) + SubtitleSyncNotice.Tone.Warning -> SyncWarningColor.copy(alpha = 0.55f) + } + Surface( + color = Color.Black.copy(alpha = 0.82f), + shape = RoundedCornerShape(14.dp), + border = BorderStroke(1.dp, accent), + modifier = Modifier + .width(300.dp) + .semantics { liveRegion = LiveRegionMode.Polite } + .then(if (running) Modifier else Modifier.clickable(onClick = onDismiss)), + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + SyncToneIcon(notice.tone, Modifier.padding(top = 2.dp).size(18.dp)) + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + text = notice.title, + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + ) + notice.detail?.let { + Text(text = it, color = Color.White.copy(alpha = 0.70f), fontSize = 12.sp) + } + if (running && notice.percent != null) { + SyncProgressRow(percent = notice.percent ?: 0, modifier = Modifier.padding(top = 4.dp)) + } + } + } + } +} + +@Composable +private fun SyncToneIcon(tone: SubtitleSyncNotice.Tone, modifier: Modifier) { + when (tone) { + SubtitleSyncNotice.Tone.Progress -> CircularProgressIndicator( + color = Color.White, + trackColor = Color.White.copy(alpha = 0.25f), + strokeWidth = 2.dp, + modifier = modifier, + ) + SubtitleSyncNotice.Tone.Success -> + Icon(Icons.Outlined.CheckCircle, contentDescription = null, tint = SyncSuccessColor, modifier = modifier) + SubtitleSyncNotice.Tone.Info -> + Icon(Icons.Outlined.Info, contentDescription = null, tint = SyncInfoColor, modifier = modifier) + SubtitleSyncNotice.Tone.Warning -> + Icon(Icons.Outlined.Warning, contentDescription = null, tint = SyncWarningColor, modifier = modifier) + } +} + +/** A thin bar and its percentage, for a running sync. */ +@Composable +fun SyncProgressRow(percent: Int, modifier: Modifier = Modifier, label: String? = null) { + val progress by animateFloatAsState(targetValue = percent.coerceIn(0, 100) / 100f, label = "SubtitleSyncProgress") + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + LinearProgressIndicator( + progress = { progress }, + color = Color.White.copy(alpha = 0.85f), + trackColor = Color.White.copy(alpha = 0.15f), + strokeCap = StrokeCap.Round, + modifier = Modifier.weight(1f), + ) + if (label == null) { + Text(text = "$percent%", color = Color.White.copy(alpha = 0.60f), fontSize = 11.sp) + } + } + if (label != null) { + Row(horizontalArrangement = Arrangement.SpaceBetween) { + Text(text = label, color = Color.White.copy(alpha = 0.55f), fontSize = 12.sp, modifier = Modifier.weight(1f)) + Text(text = "$percent%", color = Color.White.copy(alpha = 0.55f), fontSize = 12.sp) + } + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/TracksSheet.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/TracksSheet.kt index 45bca2dde5..779d0e985d 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/TracksSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/TracksSheet.kt @@ -1,53 +1,43 @@ package org.prairieserver.prairie.android.ui.screens.player -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.GraphicEq -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Subtitles -import androidx.compose.material.icons.filled.Translate -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon +import androidx.compose.material.icons.rounded.AutoAwesome +import androidx.compose.material.icons.rounded.Restore +import androidx.compose.material.icons.rounded.Schedule +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material.icons.rounded.Sync import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.input.nestedscroll.nestedScroll -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.dp -import androidx.compose.ui.unit.sp import org.prairieserver.prairie.model.catalog.AudioTrack import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo +import org.prairieserver.prairie.playback.SubtitleTimingActions import org.prairieserver.prairie.player.formatSubtitleTrackDisplayLabel -/** Combined audio and subtitle picker with adaptive phone and foldable layouts. */ -@OptIn(ExperimentalMaterial3Api::class) +/** Which list the Audio & Subtitles menu opens on. */ +enum class TracksTab { Subtitles, Audio } + +/** + * Audio & Subtitles. A segmented switch picks the list; the subtitle list ends + * in Find more / Translate / Timing. Docked beside the picture (landscape) a + * choice applies while the panel stays open, so the viewer sees the new track + * on screen; as a portrait sheet, which covers the picture, a choice closes it. + */ @Composable fun TracksSheet( isVisible: Boolean, @@ -62,360 +52,190 @@ fun TracksSheet( showTranslateAction: Boolean = false, onSearchSubtitles: () -> Unit = {}, onTranslateWithAi: () -> Unit = {}, + subtitleStatus: (PlayerSubtitleInfo) -> String? = { null }, + timingActions: SubtitleTimingActions? = null, + onSyncSubtitle: (String) -> Unit = {}, + onResetTiming: (String) -> Unit = {}, + initialTab: TracksTab = TracksTab.Subtitles, tabletopPaneHeight: Dp? = null, ) { if (!isVisible) return - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val scope = rememberCoroutineScope() - val dismissSheet = { scope.dismissPlayerSheet(sheetState, onDismiss) } - val selectAudioAndDismiss: (Int) -> Unit = { index -> - onSelectAudio(index) - dismissSheet() - } - val selectSubtitleAndDismiss: (Int) -> Unit = { index -> - onSelectSubtitle(index) - dismissSheet() - } - val openSubtitleSearch = { - scope.dismissPlayerSheet(sheetState, onDismiss, onSearchSubtitles) + val controller = rememberPlayerMenuController(onDismiss) + val staysOpen = controller.presentation == PlayerMenuPresentation.Docked + fun choose(select: () -> Unit) { + select() + if (!staysOpen) controller.dismiss() } - val openAiTranslate = { - scope.dismissPlayerSheet(sheetState, onDismiss, onTranslateWithAi) - } - - LaunchedEffect(isVisible) { - if (isVisible) sheetState.show() + var tabOrdinal by rememberSaveable { + mutableIntStateOf(if (audioTracks.isEmpty()) TracksTab.Subtitles.ordinal else initialTab.ordinal) } + var showTiming by rememberSaveable { mutableStateOf(false) } + val tab = TracksTab.entries[tabOrdinal] - PlayerModalBottomSheet( - onDismissRequest = dismissSheet, - sheetState = sheetState, - tabletopPaneHeight = tabletopPaneHeight, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .playerSheetContent(tabletopPaneHeight) - .nestedScroll(PlayerSheetFlingGuard), - ) { - PlayerSheetHeader( - title = "Audio & Subtitles", - subtitle = "Choose language and accessibility tracks", - onDismiss = dismissSheet, + PlayerMenu(controller = controller, tabletopPaneHeight = tabletopPaneHeight) { + if (showTiming && timingActions != null) { + PlayerPanelHeader( + title = "Timing", + onClose = { controller.dismiss() }, + onBack = { showTiming = false }, + backDescription = "Back to Audio & Subtitles", ) - - if (tabletopPaneHeight != null && audioTracks.isNotEmpty()) { - Row( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 24.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - AudioTrackCard( - audioTracks = audioTracks, - selectedAudioIndex = selectedAudioIndex, - onSelect = selectAudioAndDismiss, - scrollContent = true, - modifier = Modifier - .weight(0.44f) - .fillMaxHeight(), - ) - SubtitleTrackCard( - subtitles = subtitles, - selectedSubtitleIndex = selectedSubtitleIndex, - onSelect = selectSubtitleAndDismiss, - showSearchAction = showSearchAction, - showTranslateAction = showTranslateAction, - onSearchSubtitles = openSubtitleSearch, - onTranslateWithAi = openAiTranslate, - scrollContent = true, - modifier = Modifier - .weight(0.56f) - .fillMaxHeight(), - ) - } - } else { - Column( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .verticalScroll(rememberScrollState()) - .padding( - start = playerSheetHorizontalPadding(tabletopPaneHeight), - end = playerSheetHorizontalPadding(tabletopPaneHeight), - top = 8.dp, - bottom = 24.dp, - ), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - if (audioTracks.isNotEmpty()) { - AudioTrackCard( - audioTracks = audioTracks, - selectedAudioIndex = selectedAudioIndex, - onSelect = selectAudioAndDismiss, - scrollContent = false, - modifier = Modifier.fillMaxWidth(), - ) - } - SubtitleTrackCard( - subtitles = subtitles, - selectedSubtitleIndex = selectedSubtitleIndex, - onSelect = selectSubtitleAndDismiss, - showSearchAction = showSearchAction, - showTranslateAction = showTranslateAction, - onSearchSubtitles = openSubtitleSearch, - onTranslateWithAi = openAiTranslate, - scrollContent = false, - modifier = Modifier.fillMaxWidth(), - ) - } + PlayerMenuScrollColumn(horizontalPadding = 12.dp) { + Spacer(Modifier.height(8.dp)) + SubtitleTimingGroup( + actions = timingActions, + onSync = { onSyncSubtitle(timingActions.key) }, + onReset = { onResetTiming(timingActions.key) }, + ) } + return@PlayerMenu } - } -} -@Composable -private fun AudioTrackCard( - audioTracks: List, - selectedAudioIndex: Int, - onSelect: (Int) -> Unit, - scrollContent: Boolean, - modifier: Modifier = Modifier, -) { - TrackSectionCard( - title = "Audio", - detail = trackCountLabel(audioTracks.size), - icon = Icons.Filled.GraphicEq, - scrollContent = scrollContent, - modifier = modifier, - ) { - audioTracks.forEachIndexed { index, track -> - TrackRow( - label = audioTrackName(track, index), - attributes = audioTrackAttributes(track), - isSelected = index == selectedAudioIndex, - onClick = { onSelect(index) }, + PlayerPanelHeader(title = "Audio & Subtitles", onClose = { controller.dismiss() }) + if (audioTracks.isNotEmpty()) { + PlayerSegmentedControl( + segments = listOf( + PlayerSegment("Subtitles", subtitles.size), + PlayerSegment("Audio", audioTracks.size), + ), + selectedIndex = tab.ordinal, + onSelect = { tabOrdinal = it }, + modifier = Modifier.padding(start = 14.dp, end = 14.dp, top = 4.dp, bottom = 8.dp), ) } - } -} -@Composable -private fun SubtitleTrackCard( - subtitles: List, - selectedSubtitleIndex: Int, - onSelect: (Int) -> Unit, - showSearchAction: Boolean, - showTranslateAction: Boolean, - onSearchSubtitles: () -> Unit, - onTranslateWithAi: () -> Unit, - scrollContent: Boolean, - modifier: Modifier = Modifier, -) { - TrackSectionCard( - title = "Subtitles", - detail = trackCountLabel(subtitles.size + 1), - icon = Icons.Filled.Subtitles, - scrollContent = scrollContent, - modifier = modifier, - ) { - TrackRow( - label = "Off", - attributes = "No subtitles", - isSelected = selectedSubtitleIndex == -1, - onClick = { onSelect(-1) }, - ) - subtitles.forEachIndexed { index, subtitle -> - TrackRow( - label = subtitleTrackLabel(subtitle, index), - isSelected = index == selectedSubtitleIndex, - onClick = { onSelect(index) }, - ) - } - if (showSearchAction || showTranslateAction) { - PlayerSheetDivider(modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)) - } - if (showSearchAction) { - ActionRow( - icon = Icons.Filled.Search, - label = "Find subtitles", - onClick = onSearchSubtitles, - ) - } - if (showTranslateAction) { - ActionRow( - icon = Icons.Filled.Translate, - label = "Translate with AI", - onClick = onTranslateWithAi, - ) - } - } -} - -@Composable -private fun TrackSectionCard( - title: String, - detail: String, - icon: ImageVector, - scrollContent: Boolean, - modifier: Modifier = Modifier, - content: @Composable () -> Unit, -) { - PlayerSheetCard(modifier = modifier) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f), - modifier = Modifier.size(36.dp), - ) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Icon( - imageVector = icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp), + when (tab) { + TracksTab.Subtitles -> { + PlayerMenuScrollColumn { + PlayerTrackRow( + title = "Off", + selected = selectedSubtitleIndex == -1, + onClick = { choose { onSelectSubtitle(-1) } }, ) + subtitles.forEachIndexed { index, subtitle -> + val presentation = subtitleTrackPresentation(subtitle, index, subtitleStatus(subtitle)) + PlayerTrackRow( + title = presentation.title, + chips = presentation.chips, + detail = presentation.detail, + selected = index == selectedSubtitleIndex, + onClick = { choose { onSelectSubtitle(index) } }, + ) + } + Spacer(Modifier.height(8.dp)) + } + if (showSearchAction || showTranslateAction || timingActions != null) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 14.dp, end = 14.dp, top = 8.dp, bottom = 6.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (showSearchAction) { + PlayerFooterButton( + icon = Icons.Rounded.Search, + label = "Find more", + onClick = { controller.dismiss(then = onSearchSubtitles) }, + modifier = Modifier.weight(1f), + ) + } + if (showTranslateAction) { + PlayerFooterButton( + icon = Icons.Rounded.AutoAwesome, + label = "Translate", + onClick = { controller.dismiss(then = onTranslateWithAi) }, + modifier = Modifier.weight(1f), + ) + } + if (timingActions != null) { + PlayerFooterButton( + icon = Icons.Rounded.Schedule, + label = "Timing", + onClick = { showTiming = true }, + modifier = Modifier.weight(1f), + ) + } + } } } - Column(modifier = Modifier.weight(1f)) { - Text( - text = title, - color = Color.White, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = detail, - color = Color.White.copy(alpha = 0.48f), - fontSize = 12.sp, - ) + TracksTab.Audio -> PlayerMenuScrollColumn { + audioTracks.forEachIndexed { index, track -> + val presentation = audioTrackPresentation(track, index) + PlayerTrackRow( + title = presentation.title, + detail = presentation.detail, + selected = index == selectedAudioIndex, + onClick = { choose { onSelectAudio(index) } }, + ) + } + Spacer(Modifier.height(8.dp)) } } - PlayerSheetDivider() - Column( - modifier = if (scrollContent) { - Modifier - .weight(1f) - .verticalScroll(rememberScrollState()) - .padding(vertical = 6.dp) - } else { - Modifier.padding(vertical = 6.dp) - }, - content = { content() }, - ) } } +/** + * "Sync to audio" and "Reset timing" for the selected subtitle, stored or a + * file next to the media, with a running sync's progress and the last result. + * Anyone who can play the file may retime it; after a refusal (demo mode) the + * actions give way to a short explanation. + */ @Composable -private fun TrackRow( - label: String, - isSelected: Boolean, - onClick: () -> Unit, - attributes: String? = null, +private fun SubtitleTimingGroup( + actions: SubtitleTimingActions, + onSync: () -> Unit, + onReset: () -> Unit, ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 2.dp) - .clip(RoundedCornerShape(12.dp)) - .background(if (isSelected) PlayerSheetSelectedColor else Color.Transparent) - .clickable(onClick = onClick) - .heightIn(min = 52.dp) - .padding(horizontal = 12.dp, vertical = 9.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = label, - color = Color.White, - fontSize = 15.sp, - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - if (!attributes.isNullOrBlank()) { - Text( - text = attributes, - color = Color.White.copy(alpha = 0.50f), - fontSize = 12.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + if (actions.forbidden) { + PlayerFootnote(SubtitleTimingActions.FORBIDDEN_MESSAGE) + return + } + if (actions.canSync || actions.canReset) { + PlayerGroup { + if (actions.canSync) { + PlayerActionRow( + icon = Icons.Rounded.Sync, + label = if (actions.inProgress) "Syncing…" else "Sync to audio", + onClick = onSync, + enabled = actions.actionsEnabled, ) } - } - if (isSelected) { - Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(26.dp), - ) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = "Selected", - tint = Color.Black, - modifier = Modifier.size(17.dp), - ) - } + if (actions.canReset) { + PlayerActionRow( + icon = Icons.Rounded.Restore, + label = "Reset timing", + onClick = onReset, + enabled = actions.actionsEnabled, + separator = actions.canSync, + ) } } } -} - -@Composable -private fun ActionRow( - icon: ImageVector, - label: String, - onClick: () -> Unit, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 2.dp) - .clip(RoundedCornerShape(12.dp)) - .clickable(onClick = onClick) - .heightIn(min = 50.dp) - .padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp), - ) - Text( - text = label, - color = MaterialTheme.colorScheme.primary, - fontSize = 15.sp, - fontWeight = FontWeight.Medium, + if (actions.inProgress) { + SyncProgressRow( + percent = actions.percent ?: 0, + label = actions.phaseLabel, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), ) } + actions.result?.let { result -> + TimingMessage(result.text, color = if (result.warning) TimingWarningColor else null) + } + actions.note?.let { TimingMessage(it, color = PlayerChrome.Faint) } + actions.error?.let { TimingMessage(it, color = MaterialTheme.colorScheme.error) } } -private fun trackCountLabel(count: Int): String = if (count == 1) "1 option" else "$count options" +private val TimingWarningColor = Color(0xFFFDE68A).copy(alpha = 0.85f) -private fun audioTrackName(track: AudioTrack, index: Int): String = - listOfNotNull( - track.title?.takeIf { it.isNotBlank() }, - track.language?.takeIf { it.isNotBlank() }?.uppercase(), - ).joinToString(" · ").ifBlank { "Audio ${index + 1}" } - -private fun audioTrackAttributes(track: AudioTrack): String = - listOfNotNull( - track.codec?.takeIf { it.isNotBlank() }?.uppercase(), - track.channels?.let { "${it}ch" }, - ).joinToString(" · ") +@Composable +private fun TimingMessage(text: String, color: Color? = null) { + Text( + text = text, + style = PlayerType.Footnote.copy(color = color ?: PlayerChrome.Graphite), + modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp), + ) +} internal fun subtitleTrackLabel(sub: PlayerSubtitleInfo, index: Int): String = formatSubtitleTrackDisplayLabel( diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/VersionSelector.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/VersionSelector.kt new file mode 100644 index 0000000000..27abfe24f4 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/VersionSelector.kt @@ -0,0 +1,54 @@ +package org.prairieserver.prairie.android.ui.screens.player + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.prairieserver.prairie.android.ui.util.formatBytes +import org.prairieserver.prairie.model.catalog.FileVersion +import org.prairieserver.prairie.model.catalog.editionLabel + +/** + * Picks which file version of the title plays: resolution and HDR as the + * title, codecs and size as the detail. The server's streaming qualities are + * a separate menu, [PlaybackQualitySheet]. + */ +@Composable +fun VersionSelector( + versions: List, + selectedIndex: Int, + onSelect: (Int) -> Unit, + onDismiss: () -> Unit, + tabletopPaneHeight: Dp? = null, +) { + val controller = rememberPlayerMenuController(onDismiss) + + PlayerMenu(controller = controller, tabletopPaneHeight = tabletopPaneHeight) { + PlayerPanelHeader(title = "Version", onClose = { controller.dismiss() }) + PlayerMenuScrollColumn { + Spacer(Modifier.height(4.dp)) + versions.forEachIndexed { index, version -> + PlayerTrackRow( + title = buildString { + version.editionLabel?.let { append(it).append(" · ") } + append(version.resolution ?: "Unknown") + }, + chips = listOfNotNull("HDR".takeIf { version.hdr }), + detail = buildList { + version.codecVideo?.uppercase()?.let(::add) + version.codecAudio?.let { audioCodecDisplayName(it) }?.let(::add) + if (version.fileSize > 0) add(formatBytes(version.fileSize)) + }.joinToString(" · ").ifBlank { null }, + selected = selectedIndex == index, + onClick = { + onSelect(index) + controller.dismiss() + }, + ) + } + Spacer(Modifier.height(8.dp)) + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/WatchPartyPlayerSupport.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/WatchPartyPlayerSupport.kt new file mode 100644 index 0000000000..372fd29479 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/WatchPartyPlayerSupport.kt @@ -0,0 +1,92 @@ +package org.prairieserver.prairie.android.ui.screens.player + +import androidx.media3.common.Player +import org.prairieserver.prairie.common.player.seek.PlaybackSeekDecision +import org.prairieserver.prairie.common.player.seek.decideSeek +import org.prairieserver.prairie.common.player.watchparty.WatchPartyPlayback +import org.prairieserver.prairie.model.playback.PlaybackAvailableQualityV3 +import org.prairieserver.prairie.model.playback.PlaybackTimeline +import org.prairieserver.prairie.playback.PlaybackAction +import org.prairieserver.prairie.watchtogether.RoomPlayerObservation +import org.prairieserver.prairie.watchtogether.RoomPlayerPort +import org.prairieserver.prairie.watchtogether.RoomPlayerState +import org.prairieserver.prairie.watchtogether.RoomTransportResult +import kotlinx.coroutines.flow.StateFlow + +/** + * The phone player's side of the Watch Party binding. It reports what + * [PlayerViewModel] observes from Media3 and applies what the binding asks, + * through paths that never count as the viewer's own transport intent. + */ +internal class MobileRoomPlayerPort(private val viewModel: PlayerViewModel) : RoomPlayerPort { + override val observations: StateFlow = viewModel.roomObservation + + override fun seekTo(sourceSeconds: Double) = viewModel.applyRoomSeek(sourceSeconds) + + override fun setPlaying(playing: Boolean) = viewModel.applyRoomPlaying(playing) + + override fun setCorrectionRate(rate: Double?) = viewModel.setRoomCorrectionRate(rate) + + override fun isBuffered(sourceSeconds: Double): Boolean = viewModel.isRoomPositionBuffered(sourceSeconds) +} + +internal fun roomPlayerState(playbackState: Int): RoomPlayerState = when (playbackState) { + Player.STATE_BUFFERING -> RoomPlayerState.Buffering + Player.STATE_READY -> RoomPlayerState.Ready + Player.STATE_ENDED -> RoomPlayerState.Ended + else -> RoomPlayerState.Idle +} + +/** + * Whether the mounted stream can reach [targetSourceSeconds] without loading + * new media: the target maps natively onto the current plan's timeline (no + * re-anchor) and lands between just behind the playhead and the buffered + * position. False whenever that cannot be shown. + */ +internal fun roomTargetBufferedLocally( + timeline: PlaybackTimeline?, + targetSourceSeconds: Double, + playerPositionSeconds: Double, + bufferedPlayerSeconds: Double, + backwardSlackSeconds: Double = 1.0, +): Boolean { + if (timeline == null) return false + if (!targetSourceSeconds.isFinite() || targetSourceSeconds < 0.0) return false + if (!playerPositionSeconds.isFinite() || !bufferedPlayerSeconds.isFinite()) return false + if (bufferedPlayerSeconds < playerPositionSeconds) return false + val native = timeline.decideSeek(targetSourceSeconds) as? PlaybackSeekDecision.NativeSeek ?: return false + val target = native.targetPlayerPositionSeconds + return target >= playerPositionSeconds - backwardSlackSeconds && target <= bufferedPlayerSeconds +} + +/** + * One rung below the current one on the same file's quality ladder, or null + * when nothing lower exists. An unmatched [selectedLabel] (Auto, Original, or + * none) is treated as the source rung. + */ +internal fun lowerQualityRung( + available: List, + selectedLabel: String?, +): PlaybackAvailableQualityV3? { + val rungs = available.filter { it.height > 0 } + val current = rungs.firstOrNull { it.label == selectedLabel } + ?: rungs.firstOrNull { it.preservesSource } + ?: rungs.maxByOrNull { it.height } + ?: return null + return rungs + .filter { !it.preservesSource && it.height < current.height } + .maxByOrNull { it.height } +} + +/** Routes a playback-control socket transport command through the room's permission decision. */ +internal fun routeRemoteTransportToRoom( + action: PlaybackAction, + party: WatchPartyPlayback, + locallyPaused: Boolean, +): Boolean = when (action) { + PlaybackAction.Pause -> party.requestPlayPause(pause = true) + PlaybackAction.Unpause -> party.requestPlayPause(pause = false) + PlaybackAction.TogglePlayPause -> party.requestPlayPause(pause = !locallyPaused) + is PlaybackAction.SeekTo -> party.requestSeek(action.positionSeconds) + else -> RoomTransportResult.Ignored +} == RoomTransportResult.Sent diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/CreateProfileScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/CreateProfileScreen.kt index 85ea58fc29..bcc730a6b3 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/CreateProfileScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/CreateProfileScreen.kt @@ -1,5 +1,6 @@ package org.prairieserver.prairie.android.ui.screens.profiles +import org.prairieserver.prairie.android.ui.components.PrairieDropdownMenuItem import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -18,7 +19,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults @@ -310,7 +310,7 @@ internal fun DropdownField( onDismissRequest = { expanded = false }, ) { options.forEach { option -> - DropdownMenuItem( + PrairieDropdownMenuItem( text = { Text(option) }, onClick = { onSelected(option) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/HouseholdReverifyHost.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/HouseholdReverifyHost.kt new file mode 100644 index 0000000000..3f681fd483 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/HouseholdReverifyHost.kt @@ -0,0 +1,29 @@ +package org.prairieserver.prairie.android.ui.screens.profiles + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import org.koin.compose.koinInject +import org.prairieserver.prairie.repository.ProfileRepository + +/** + * Asks for the primary profile's PIN again when a manage-mode call finds its + * token stale (see [org.prairieserver.prairie.repository.HouseholdManagementSession]). + * App-wide because the call can come from the picker, add, or edit screen. + */ +@Composable +fun HouseholdReverifyHost(profileRepository: ProfileRepository = koinInject()) { + val session = profileRepository.householdManagement + val prompt by session.reverify.collectAsState() + prompt?.let { + PINEntryOverlay( + profile = it.profile, + isVerifying = it.isVerifying, + error = it.error, + errorCount = it.errorCount, + onPinComplete = session::submitPin, + onDismiss = session::cancelReverify, + prompt = "Enter this PIN to manage profiles", + ) + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/PINEntryDialog.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/PINEntryDialog.kt deleted file mode 100644 index a2c6e0d8af..0000000000 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/PINEntryDialog.kt +++ /dev/null @@ -1,270 +0,0 @@ -package org.prairieserver.prairie.android.ui.screens.profiles - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Backspace -import androidx.compose.material3.Icon -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -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 -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog -import org.prairieserver.prairie.android.ui.screens.auth.AuthColors -import org.prairieserver.prairie.common.ui.components.ProfileAvatarRef - -private const val PIN_LENGTH = 4 - -/** - * Modal dialog for entering a 4-digit profile PIN. - * - * @param profileName Name displayed in the title. - * @param isLoading Whether the PIN is being verified. - * @param error Error message to display (e.g. "Incorrect PIN"). - * @param onPinComplete Called with the full 4-digit PIN when the user finishes entering. - * @param onDismiss Called when the user cancels. - */ -@Composable -fun PINEntryDialog( - profileName: String, - profileAvatar: ProfileAvatarRef, - isLoading: Boolean, - error: String?, - onPinComplete: (String) -> Unit, - onDismiss: () -> Unit, -) { - // Deliberately NOT rememberSaveable: saved-instance state is serialized by - // the OS across configuration change and process death, which would put the - // raw PIN in system-managed storage well beyond the request that needs it. - // Losing four digits on rotation is the correct trade. - var pin by remember { mutableStateOf("") } - - // Clear pin on new error so the user can re-enter. - LaunchedEffect(error) { - if (error != null) { - pin = "" - } - } - - Dialog(onDismissRequest = onDismiss) { - // iOS phone presents PIN entry as a sheet over the black background. - Surface( - shape = RoundedCornerShape(24.dp), - color = AuthColors.Background, - ) { - // VStack(spacing: 32) header / dots / pad / cancel. - Column( - modifier = Modifier.padding(horizontal = 24.dp, vertical = 32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - // Header: avatar (64) + label, spacing 10. - ProfileAvatar( - avatar = profileAvatar, - name = profileName, - size = 64.dp, - ) - - Spacer(modifier = Modifier.height(10.dp)) - - Text( - text = "Enter PIN for $profileName", - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - color = AuthColors.OnSurface, - textAlign = TextAlign.Center, - ) - - Spacer(modifier = Modifier.height(32.dp)) - - // PIN dots row (size 20, spacing 20). - PinDots(filledCount = pin.length) - - if (error != null) { - Spacer(modifier = Modifier.height(12.dp)) - Text( - text = error, - fontSize = 13.sp, - color = AuthColors.Error, - textAlign = TextAlign.Center, - ) - } - - Spacer(modifier = Modifier.height(32.dp)) - - // Number pad - NumberPad( - enabled = !isLoading, - onDigit = { digit -> - if (pin.length < PIN_LENGTH) { - pin += digit - if (pin.length == PIN_LENGTH) { - onPinComplete(pin) - } - } - }, - onBackspace = { - if (pin.isNotEmpty()) { - pin = pin.dropLast(1) - } - }, - ) - - Spacer(modifier = Modifier.height(32.dp)) - - // Cancel stays live during verification (the user must be able - // to back out of a slow round trip), and the ViewModel's - // generation guard makes that abandon the in-flight answer - // rather than commit it late. - TextButton(onClick = onDismiss) { - Text( - text = "Cancel", - color = AuthColors.Primary, - fontSize = 15.sp, - ) - } - } - } - } -} - -// iOS siloSurfaceVariant (#0E0F12) — used for empty PIN dots and pad keys. -private val SurfaceVariant = Color(0xFF0E0F12) - -@Composable -private fun PinDots(filledCount: Int) { - // iOS phone: dotSize 20, spacing 20; empty dots are a filled surfaceVariant. - Row( - horizontalArrangement = Arrangement.spacedBy(20.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - repeat(PIN_LENGTH) { index -> - val filled = index < filledCount - Box( - modifier = Modifier - .size(20.dp) - .clip(CircleShape) - .background(if (filled) AuthColors.Primary else SurfaceVariant), - ) - } - } -} - -@Composable -private fun NumberPad( - enabled: Boolean, - onDigit: (String) -> Unit, - onBackspace: () -> Unit, -) { - val rows = listOf( - listOf("1", "2", "3"), - listOf("4", "5", "6"), - listOf("7", "8", "9"), - listOf("", "0", "back"), - ) - - // iOS pad: 3-column grid, key size 64, spacing 16. - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - for (row in rows) { - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - for (key in row) { - when (key) { - "" -> { - // Empty slot keeps the "0" centered (iOS Color.clear). - Spacer(modifier = Modifier.size(64.dp)) - } - - "back" -> { - Box( - modifier = Modifier - .size(64.dp) - .clip(CircleShape) - .background(SurfaceVariant) - .then( - if (enabled) { - Modifier.clickable(onClick = onBackspace) - } else { - Modifier - }, - ), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Backspace, - contentDescription = "Backspace", - tint = if (enabled) AuthColors.OnSurface else AuthColors.OnSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - } - } - - else -> { - NumberPadKey( - digit = key, - enabled = enabled, - onClick = { onDigit(key) }, - ) - } - } - } - } - } - } -} - -@Composable -private fun NumberPadKey( - digit: String, - enabled: Boolean, - onClick: () -> Unit, -) { - Box( - modifier = Modifier - .size(64.dp) - .clip(CircleShape) - .background(SurfaceVariant) - .then( - if (enabled) Modifier.clickable(onClick = onClick) else Modifier, - ), - contentAlignment = Alignment.Center, - ) { - // iOS siloPIN: 32 bold monospaced. - Text( - text = digit, - fontSize = 32.sp, - fontWeight = FontWeight.Bold, - fontFamily = FontFamily.Monospace, - color = if (enabled) AuthColors.OnSurface else AuthColors.OnSurfaceVariant, - ) - } -} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/PINEntryOverlay.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/PINEntryOverlay.kt new file mode 100644 index 0000000000..d736675edb --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/PINEntryOverlay.kt @@ -0,0 +1,285 @@ +package org.prairieserver.prairie.android.ui.screens.profiles + +import android.view.HapticFeedbackConstants +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.Backspace +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeIconButton +import org.prairieserver.prairie.common.ui.marquee.marqueeShake +import org.prairieserver.prairie.common.ui.marquee.MarqueeColors +import org.prairieserver.prairie.common.ui.marquee.MarqueeProfileAvatar +import org.prairieserver.prairie.common.ui.marquee.MarqueeScrim +import org.prairieserver.prairie.common.ui.marquee.MarqueeScrimStyle +import org.prairieserver.prairie.model.profile.Profile + +private const val PIN_LENGTH = 4 + +/** + * Full-screen PIN prompt over the brand light: the person's avatar, four + * dots, and a keypad laid out like the system passcode screen. A wrong PIN + * keeps the prompt open, turns the dots red, shakes them and clears for + * another try (prairie-apple `PINEntryView`). + * + * Back stays live during verification so a slow round trip can be left; the + * view model drops an answer for a prompt that's gone. + * + * @param errorCount bumped on each rejection so the dots shake and the entry clears. + */ +@Composable +fun PINEntryOverlay( + profile: Profile, + isVerifying: Boolean, + error: String?, + errorCount: Int, + onPinComplete: (String) -> Unit, + onDismiss: () -> Unit, + prompt: String = "Enter your PIN", +) { + // Deliberately NOT rememberSaveable: saved-instance state is serialized by + // the OS across configuration change and process death, which would put the + // raw PIN in system-managed storage well beyond the request that needs it. + // Losing four digits on rotation is the correct trade. + var pin by remember { mutableStateOf("") } + LaunchedEffect(errorCount) { if (errorCount > 0) pin = "" } + BackHandler(onBack = onDismiss) + + Box( + Modifier + .fillMaxSize() + // Swallow taps so the picker underneath can't be reached. + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {}, + ) { + MarqueeScrim(MarqueeScrimStyle.Ambient) + Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.35f))) + BoxWithConstraints( + Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing), + contentAlignment = Alignment.TopCenter, + ) { + // A short screen can't fit the full layout (about 690dp); the + // compact one keeps the keypad whole, and scrolling is the last resort. + val compact = maxHeight < 690.dp + val scrolls = maxHeight < 560.dp + Column( + Modifier + .widthIn(max = 440.dp) + .fillMaxSize() + .then(if (scrolls) Modifier.verticalScroll(rememberScrollState()) else Modifier), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row(Modifier.fillMaxWidth().padding(start = 24.dp, end = 24.dp, top = 6.dp)) { + MarqueeIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", onDismiss) + } + Spacer(if (scrolls) Modifier.height(8.dp) else Modifier.weight(1f)) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.clearAndSetSemantics { contentDescription = "Enter PIN for ${profile.name}" }, + ) { + MarqueeProfileAvatar(profile, size = if (compact) 64.dp else 88.dp, showsBadges = false, modifier = Modifier.padding(bottom = 8.dp)) + Text( + profile.name, + color = MarqueeColors.Ink, + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + PinDots( + filled = pin.length, + error = error != null && pin.isEmpty(), + modifier = Modifier.padding(top = if (compact) 14.dp else 22.dp).marqueeShake(errorCount), + ) + + Box(Modifier.padding(top = if (compact) 10.dp else 14.dp).height(22.dp), contentAlignment = Alignment.Center) { + when { + error != null -> Text(error, color = MarqueeColors.Error, fontSize = 15.sp, textAlign = TextAlign.Center) + isVerifying -> CircularProgressIndicator(color = MarqueeColors.Ink, strokeWidth = 2.dp, modifier = Modifier.size(18.dp)) + else -> Text(prompt, color = MarqueeColors.InkSecondary, fontSize = 15.sp) + } + } + + NumberPad( + enabled = !isVerifying, + rowSpacing = if (compact) 10.dp else 16.dp, + modifier = Modifier.padding(top = if (compact) 18.dp else 28.dp), + onDigit = { digit -> + if (pin.length < PIN_LENGTH && !isVerifying) { + pin += digit + if (pin.length == PIN_LENGTH) onPinComplete(pin) + } + }, + onBackspace = { if (pin.isNotEmpty() && !isVerifying) pin = pin.dropLast(1) }, + ) + + Spacer(if (scrolls) Modifier.height(8.dp) else Modifier.weight(1f)) + if (!compact) { + Text( + "Forgot your PIN? The account admin can reset it.", + color = MarqueeColors.InkTertiary, + fontSize = 13.sp, + textAlign = TextAlign.Center, + modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 16.dp), + ) + } + } + } + } +} + +@Composable +private fun PinDots(filled: Int, error: Boolean, modifier: Modifier = Modifier) { + val color = if (error) MarqueeColors.Error else MarqueeColors.Ink + Row( + modifier.semantics { contentDescription = "$filled of $PIN_LENGTH digits entered" }, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + repeat(PIN_LENGTH) { index -> + val isFilled = index < filled || error + Box( + Modifier + .size(14.dp) + .clip(CircleShape) + .background(if (isFilled) color else Color.Transparent) + .border(1.5.dp, color.copy(alpha = if (isFilled) 1f else 0.7f), CircleShape), + ) + } + } +} + +private val Letters = mapOf(2 to "ABC", 3 to "DEF", 4 to "GHI", 5 to "JKL", 6 to "MNO", 7 to "PQRS", 8 to "TUV", 9 to "WXYZ") + +@Composable +private fun NumberPad( + enabled: Boolean, + rowSpacing: Dp, + onDigit: (String) -> Unit, + onBackspace: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(rowSpacing)) { + for (row in 0 until 3) { + Row(horizontalArrangement = Arrangement.spacedBy(26.dp)) { + for (column in 1..3) { + val digit = row * 3 + column + PadKey(enabled, "$digit", onClick = { onDigit("$digit") }) { + PadDigit("$digit", Letters[digit]) + } + } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(26.dp)) { + Spacer(Modifier.size(PadKeySize)) + PadKey(enabled, "0", onClick = { onDigit("0") }) { PadDigit("0", null) } + PadKey(enabled, "Delete", onClick = onBackspace, filled = false) { + Icon(Icons.AutoMirrored.Outlined.Backspace, contentDescription = null, tint = MarqueeColors.Ink, modifier = Modifier.size(24.dp)) + } + } + } +} + +private val PadKeySize = 76.dp + +@Composable +private fun PadDigit(digit: String, letters: String?) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(digit, color = MarqueeColors.Ink, fontSize = 34.sp, lineHeight = 36.sp) + if (letters != null) { + Text( + letters, + color = MarqueeColors.Ink.copy(alpha = 0.62f), + fontSize = 9.5.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.5.sp, + lineHeight = 10.sp, + ) + } + } +} + +@Composable +private fun PadKey( + enabled: Boolean, + label: String, + onClick: () -> Unit, + filled: Boolean = true, + content: @Composable () -> Unit, +) { + val view = LocalView.current + val interaction = remember { MutableInteractionSource() } + val pressed by interaction.collectIsPressedAsState() + LaunchedEffect(pressed) { if (pressed) view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) } + Box( + Modifier + .size(PadKeySize) + .clip(CircleShape) + .then( + if (filled) { + Modifier + .background(Color.White.copy(alpha = if (pressed) 0.3f else 0.13f)) + .border(1.dp, Color.White.copy(alpha = 0.10f), CircleShape) + } else { + Modifier + }, + ) + .clickable(enabled = enabled, role = Role.Button, interactionSource = interaction, indication = null, onClick = onClick) + .alpha(if (enabled) 1f else 0.5f) + .semantics { contentDescription = label }, + contentAlignment = Alignment.Center, + ) { + content() + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/ProfilePickerLayout.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/ProfilePickerLayout.kt new file mode 100644 index 0000000000..8944d3b37a --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/ProfilePickerLayout.kt @@ -0,0 +1,66 @@ +package org.prairieserver.prairie.android.ui.screens.profiles + +import kotlin.math.floor +import kotlin.math.min +import kotlin.math.roundToInt + +/** + * How "Who's watching?" arranges its tiles (profiles plus "Add profile") in + * the room it has, in dp: two, three or four per row, whichever gives the + * largest avatars that still fit without scrolling. When even four per row + * would shrink avatars below a comfortable tap size, the picker keeps four + * per row at that size and scrolls. Port of prairie-apple's `ProfilePickerLayout`, + * except that a scrolling grid stays at the minimum size: going back up to + * full size would make avatars grow as the household grows. + */ +data class ProfilePickerLayout( + val perRow: Int, + val avatarSize: Float, + /** Alone on the last row, "Add profile" is a utility, not a person, so it is drawn smaller. */ + val addSize: Float, + val rowSpacing: Float, + /** Every row uses the full rows' column width, so a short last row is centered rather than spread out. */ + val columnWidth: Float, + val scrolls: Boolean, +) { + private data class Option(val perRow: Int, val maxAvatar: Float, val gutter: Float, val rowSpacing: Float) + + companion object { + /** Name, optional "Last used" caption and their spacing under an avatar. */ + const val LABEL_HEIGHT = 48f + const val MINIMUM_AVATAR = 64f + + private val options = listOf( + Option(perRow = 2, maxAvatar = 140f, gutter = 36f, rowSpacing = 28f), + Option(perRow = 3, maxAvatar = 104f, gutter = 16f, rowSpacing = 26f), + Option(perRow = 4, maxAvatar = 76f, gutter = 10f, rowSpacing = 22f), + ) + + fun of(tileCount: Int, width: Float, height: Float): ProfilePickerLayout { + val count = tileCount.coerceAtLeast(1) + fun widthFit(option: Option) = min(option.maxAvatar, width / option.perRow - option.gutter) + fun fit(option: Option): Float { + val rows = ((count + option.perRow - 1) / option.perRow).toFloat() + val heightFit = (height - (rows - 1) * option.rowSpacing) / rows - LABEL_HEIGHT + return min(widthFit(option), heightFit) + } + + var best: Pair? = null + for (option in options) { + val size = fit(option) + if (size >= MINIMUM_AVATAR && size > (best?.second ?: 0f)) best = option to size + } + val chosen = best ?: options.last().let { it to min(widthFit(it), MINIMUM_AVATAR) } + val avatar = floor(chosen.second).coerceAtLeast(0f) + val perRow = chosen.first.perRow + return ProfilePickerLayout( + perRow = perRow, + avatarSize = avatar, + addSize = if (count > 1 && count % perRow == 1) (avatar * 0.6f).roundToInt().toFloat() else avatar, + rowSpacing = chosen.first.rowSpacing, + columnWidth = floor(width / perRow), + scrolls = best == null, + ) + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/ProfileSelectionScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/ProfileSelectionScreen.kt index bba787d304..9463b3f9a6 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/ProfileSelectionScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/ProfileSelectionScreen.kt @@ -1,91 +1,143 @@ package org.prairieserver.prairie.android.ui.screens.profiles +import org.prairieserver.prairie.android.ui.components.PrairieConfirmDialog +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.ui.draw.drawBehind +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Logout import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Eco import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.WorkspacePremium +import androidx.compose.material.icons.outlined.Dns +import androidx.compose.material.icons.outlined.ManageAccounts import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Text -import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.TextButton +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import org.prairieserver.prairie.android.ui.screens.auth.AuthColors -import org.prairieserver.prairie.android.ui.screens.auth.AuthErrorBanner -import org.prairieserver.prairie.android.ui.components.aurora.AuroraBackdrop -import org.prairieserver.prairie.android.ui.components.aurora.AuroraScrim -import org.prairieserver.prairie.android.ui.components.aurora.AuroraVariant -import org.prairieserver.prairie.common.ui.components.ThumbhashImage -import org.prairieserver.prairie.common.ui.components.avatarRef -import org.prairieserver.prairie.common.ui.components.isEmojiAvatar -import org.prairieserver.prairie.common.ui.components.profileAvatarDisplayText -import org.prairieserver.prairie.common.ui.components.rememberProfileAvatarImage -import org.prairieserver.prairie.model.profile.Profile -import androidx.compose.runtime.remember +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeButton +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeButtonKind +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeErrorHaptic +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeErrorText +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeHeadline +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeMetrics +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeServerChip +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeTopBarSpacer +import org.prairieserver.prairie.android.ui.components.marquee.MarqueeWordmark +import org.prairieserver.prairie.android.ui.components.marquee.marqueePressHaptic +import org.prairieserver.prairie.common.ui.marquee.MarqueeColors +import org.prairieserver.prairie.common.ui.marquee.MarqueeProfileAvatar +import org.prairieserver.prairie.common.ui.marquee.MarqueeScene +import org.prairieserver.prairie.common.ui.marquee.MarqueeScrim +import org.prairieserver.prairie.common.ui.marquee.MarqueeScrimStyle +import org.prairieserver.prairie.common.ui.marquee.ProfileTilePalette +import org.prairieserver.prairie.common.ui.marquee.ServerBrandingLoader +import org.prairieserver.prairie.common.ui.marquee.rememberSignInServer +import org.prairieserver.prairie.model.profile.Profile +import org.prairieserver.prairie.network.ServerRegistry + +private val TopGap = 24.dp +private val GridGap = 36.dp +private val FooterGap = 32.dp +private val BottomGap = 20.dp /** - * Grid of profile avatars shown after login. + * "Who's watching?" (prairie-apple `ProfileSelectionView`): round avatars sized + * to the household, centered between the title and small utilities (change + * server, sign out, and for admins, manage profiles). Pressing a profile + * tints the backdrop with that person's color; a PIN prompt opens full + * screen over the same light. * * @param onNavigateToHome Called after a profile is selected. - * @param onNavigateToCreateProfile Called when the "Add Profile" card is tapped. - * @param onNavigateToEditProfile Called when the edit icon is tapped in manage mode. + * @param onNavigateToCreateProfile Called when "Add profile" is tapped. + * @param onNavigateToEditProfile Called when a profile is tapped in manage mode. + * @param onChangeServer "Change server" from the footer or the server chip. + * @param onSignOut "Sign out" from the footer. */ @Composable fun ProfileSelectionScreen( onNavigateToHome: () -> Unit, onNavigateToCreateProfile: () -> Unit, onNavigateToEditProfile: (profileId: String) -> Unit, + onChangeServer: () -> Unit, + onSignOut: () -> Unit, viewModel: ProfileSelectionViewModel = koinViewModel(), + brandingLoader: ServerBrandingLoader = koinInject(), + serverRegistry: ServerRegistry = koinInject(), ) { val state by viewModel.uiState.collectAsState() + val activeEntry by serverRegistry.activeEntry.collectAsState() + val server = rememberSignInServer( + serverUrl = activeEntry?.url.orEmpty(), + savedName = activeEntry?.fetchedName, + loader = brandingLoader, + ) + var pressedProfileId by remember { mutableStateOf(null) } // Reload on resume so a profile created/edited on a pushed screen is - // reflected when we return (the VM otherwise only loads in init) — the - // TvProfileSelectionScreen pattern. + // reflected when we return (the VM otherwise only loads in init). val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> @@ -95,401 +147,395 @@ fun ProfileSelectionScreen( onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } + LaunchedEffect(Unit) { MarqueeScene.focus = MarqueeScene.Focus.Profiles } + // The profile under a finger, then the one asking for a PIN, tints the light. + val tintedProfileId = state.pinDialogProfile?.id ?: pressedProfileId + LaunchedEffect(tintedProfileId) { + MarqueeScene.personalTint = tintedProfileId?.let(ProfileTilePalette::tint) + } + DisposableEffect(Unit) { onDispose { MarqueeScene.personalTint = null } } + // Navigate after a profile is selected. + LaunchedEffect(state.openAddProfile) { + if (state.openAddProfile) { + viewModel.onAddProfileConsumed() + onNavigateToCreateProfile() + } + } + LaunchedEffect(state.selectedProfileId) { if (state.selectedProfileId != null) { viewModel.onProfileSelectedConsumed() onNavigateToHome() } } + MarqueeErrorHaptic(state.pinErrorCount) state.deleteDialogProfile?.let { profile -> val deletingActive = profile.id == state.activeProfileId - AlertDialog( - onDismissRequest = viewModel::dismissDeleteDialog, - title = { Text("Delete \"${profile.name}\"?") }, - text = { - Text( - if (deletingActive) { - "You're signed in as this profile. Deleting it removes its " + - "watch history and preferences, and you'll pick another " + - "profile to continue." - } else { - "This removes the profile's watch history and preferences. " + - "This can't be undone." - }, - ) - }, - confirmButton = { - TextButton(onClick = viewModel::confirmDeleteProfile) { - Text("Delete", color = MaterialTheme.colorScheme.error) - } - }, - dismissButton = { - TextButton(onClick = viewModel::dismissDeleteDialog) { Text("Cancel") } + PrairieConfirmDialog( + title = "Delete \"${profile.name}\"?", + body = if (deletingActive) { + "You're signed in as this profile. Deleting it removes its " + + "watch history and preferences, and you'll pick another " + + "profile to continue." + } else { + "This removes the profile's watch history and preferences. " + + "This can't be undone." }, + confirmLabel = "Delete", + onConfirm = viewModel::confirmDeleteProfile, + onDismiss = viewModel::dismissDeleteDialog, ) } - // PIN entry dialog - state.pinDialogProfile?.let { profile -> - PINEntryDialog( - profileName = profile.name, - profileAvatar = profile.avatarRef(), - isLoading = state.pinIsVerifying, - error = state.pinError, - onPinComplete = viewModel::onPinEntered, - onDismiss = viewModel::dismissPinDialog, - ) - } + val pinOpen = state.pinDialogProfile != null + val pickerAlpha by animateFloatAsState(if (pinOpen || state.openingOnlyProfile) 0f else 1f, label = "pickerAlpha") - Box(modifier = Modifier.fillMaxSize()) { - AuroraBackdrop(variant = AuroraVariant.Profile, scrim = AuroraScrim.Soft) - // Vertical scroll on the whole page, mirroring iOS phone's - // `ScrollView { LazyVGrid(adaptive(min: 140, max: 180)) }` pattern at - // `ProfileSelectionView.swift:119`. The previous implementation - // capped the grid at a fixed 220.dp, which scrolled internally as - // soon as profiles spilled to a third row. Now the grid is a - // non-lazy FlowRow that grows naturally; only the page scrolls if - // content exceeds the viewport. + Box(Modifier.fillMaxSize()) { + MarqueeScrim(MarqueeScrimStyle.Ambient) Column( - modifier = Modifier + Modifier .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 20.dp) - .statusBarsPadding() - .padding(top = 24.dp, bottom = 40.dp), - horizontalAlignment = Alignment.CenterHorizontally, + .alpha(pickerAlpha) + // Behind the PIN prompt the picker is gone for TalkBack too. + .then(if (pinOpen) Modifier.clearAndSetSemantics {} else Modifier) + .windowInsetsPadding(WindowInsets.statusBars), ) { - // Header: title + subtitle, spacing 6 (iOS titleBlock). - Text( - text = "Who's watching?", - fontSize = 32.sp, - fontWeight = FontWeight.SemiBold, - letterSpacing = (-0.5).sp, - color = AuthColors.OnBackground, - ) - - Spacer(modifier = Modifier.height(6.dp)) - - Text( - text = "Select your profile", - fontSize = 15.sp, - fontWeight = FontWeight.Normal, - color = AuthColors.OnSurfaceVariant, - ) - - state.error?.let { error -> - Spacer(modifier = Modifier.height(16.dp)) - AuthErrorBanner(message = error) + Row( + Modifier + .fillMaxWidth() + .heightIn(min = 44.dp) + .padding(start = 24.dp, end = 24.dp, top = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MarqueeWordmark() + MarqueeTopBarSpacer() + MarqueeServerChip( + name = server.name, + hostLabel = server.hostLabel, + markUrl = server.branding?.markUrl, + actionLabel = "Change server", + onAction = onChangeServer, + enabled = !pinOpen && !state.openingOnlyProfile, + ) } + Picker( + state = state, + enabled = !pinOpen && !state.openingOnlyProfile, + onPressChange = { profile, pressed -> + pressedProfileId = if (pressed) profile.id else pressedProfileId.takeIf { it != profile.id } + }, + onProfileTap = { profile -> + if (state.isManageMode) onNavigateToEditProfile(profile.id) else viewModel.onProfileTapped(profile) + }, + onProfileDelete = viewModel::requestDeleteProfile, + onAddProfile = viewModel::requestAddProfile, + onChangeServer = onChangeServer, + onSignOut = onSignOut, + onToggleManage = viewModel::toggleManageMode, + modifier = Modifier.weight(1f).windowInsetsPadding(WindowInsets.navigationBars), + ) + } - if (state.isLoading) { - Spacer(modifier = Modifier.height(36.dp)) - CircularProgressIndicator(color = AuthColors.Primary) - } else { - // VStack(spacing: 36) between header and tile grid. - Spacer(modifier = Modifier.height(36.dp)) - - ProfileFlow( - profiles = state.profiles, - canAddProfile = state.canManageProfiles, - isManageMode = state.isManageMode, - onProfileTap = { viewModel.onProfileTapped(it) }, - onProfileEdit = { onNavigateToEditProfile(it.id) }, - onProfileDelete = { viewModel.requestDeleteProfile(it) }, - onAddProfile = onNavigateToCreateProfile, + AnimatedVisibility(visible = pinOpen, enter = fadeIn(), exit = fadeOut()) { + // Keep the last profile while the prompt fades out. + var shown by remember { mutableStateOf(state.pinDialogProfile) } + state.pinDialogProfile?.let { shown = it } + shown?.let { profile -> + PINEntryOverlay( + profile = profile, + isVerifying = state.pinIsVerifying, + error = state.pinError, + errorCount = state.pinErrorCount, + onPinComplete = viewModel::onPinEntered, + onDismiss = viewModel::dismissPinDialog, + prompt = if (state.pinForManagement) "Enter this PIN to manage profiles" else "Enter your PIN", ) - - if (state.canManageProfiles) { - Spacer(modifier = Modifier.height(24.dp)) - - TextButton(onClick = viewModel::toggleManageMode) { - Text( - text = if (state.isManageMode) "Done" else "Manage Profiles", - fontSize = 15.sp, - fontWeight = FontWeight.Medium, - color = AuthColors.OnBackground, - ) - } - } } } } } -/** - * Non-lazy wrapping grid of profile tiles + the AddProfile tile. Uses - * [FlowRow] so the row count grows naturally with the profile count — - * the parent scroll handles overflow only when content exceeds the - * viewport, matching iOS phone's `ScrollView { LazyVGrid }` pattern. - */ -@OptIn(ExperimentalLayoutApi::class) @Composable -private fun ProfileFlow( - profiles: List, - canAddProfile: Boolean, - isManageMode: Boolean, +private fun Picker( + state: ProfileSelectionUiState, + enabled: Boolean, + onPressChange: (Profile, Boolean) -> Unit, onProfileTap: (Profile) -> Unit, - onProfileEdit: (Profile) -> Unit, onProfileDelete: (Profile) -> Unit, onAddProfile: () -> Unit, + onChangeServer: () -> Unit, + onSignOut: () -> Unit, + onToggleManage: () -> Unit, + modifier: Modifier = Modifier, ) { - FlowRow( - modifier = Modifier.fillMaxWidth(), - // iOS adaptive grid: tileSpacing 16, rowSpacing 28. - horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally), - verticalArrangement = Arrangement.spacedBy(28.dp), - ) { - profiles.forEach { profile -> - ProfileCard( - profile = profile, - isManageMode = isManageMode, - onTap = { onProfileTap(profile) }, - onEdit = { onProfileEdit(profile) }, - onDelete = { onProfileDelete(profile) }, - ) - } - if (canAddProfile) { - AddProfileCard(onClick = onAddProfile) + val density = LocalDensity.current + var headerHeight by remember { mutableStateOf(0) } + var footerHeight by remember { mutableStateOf(0) } + BoxWithConstraints(modifier.fillMaxWidth()) { + val viewportHeight = maxHeight + val tileCount = state.profiles.size + if (state.canManageProfiles) 1 else 0 + // The room between the title and the footer decides how many avatars + // share a row and how large they are. Nothing draws until it's + // measured, so tiles never render at a placeholder size and jump. + val layout = if (headerHeight > 0 && footerHeight > 0) { + with(density) { + ProfilePickerLayout.of( + tileCount = tileCount, + width = minOf(maxWidth.value, 440f) - 48f, + height = viewportHeight.value - headerHeight.toDp().value - footerHeight.toDp().value - + (TopGap + GridGap + FooterGap + BottomGap).value, + ) + } + } else { + null } - } -} - -// iOS phone ProfileTile sizing. -private val TileSize = 140.dp -private val TileCornerRadius = 18.dp -private const val TileEmojiSize = 72f -private const val TileInitialSize = 56f -private val TileNameSize = 17.sp - -@Composable -private fun ProfileCard( - profile: Profile, - isManageMode: Boolean, - onTap: () -> Unit, - onEdit: () -> Unit, - onDelete: () -> Unit, -) { - val tint = ProfileTilePalette.tint(profile.id) - - Box(contentAlignment = Alignment.TopEnd) { - // VStack(spacing: 20) tile + name. Column( - modifier = Modifier - .clickable(onClick = if (isManageMode) onEdit else onTap), + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally, ) { - ProfileTileBody(profile = profile, tint = tint) - - Spacer(modifier = Modifier.height(20.dp)) - - Text( - text = profile.name, - fontSize = TileNameSize, - fontWeight = FontWeight.Medium, - color = Color.White.copy(alpha = 0.72f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - ) - } - - // Manage mode overlays (Android management affordance). - if (isManageMode) { - IconButton( - onClick = onEdit, - modifier = Modifier - .size(28.dp) - .clip(CircleShape) - .background(Color.White), + Column( + Modifier + .heightIn(min = viewportHeight) + .widthIn(max = 440.dp) + .fillMaxWidth() + .padding(start = 24.dp, end = 24.dp, bottom = BottomGap), + horizontalAlignment = Alignment.CenterHorizontally, ) { - Icon( - imageVector = Icons.Filled.Edit, - contentDescription = "Edit profile", - tint = Color.Black, - modifier = Modifier.size(16.dp), - ) - } - - // The server never deletes the primary profile (409 - // primary_profile_protected), so don't offer it. - if (!profile.isPrimary) { - IconButton( - onClick = onDelete, - modifier = Modifier - .align(Alignment.TopStart) - .size(28.dp) - .clip(CircleShape) - .background(AuthColors.Error), + Spacer(Modifier.height(TopGap)) + Column( + Modifier.onSizeChanged { headerHeight = it.height }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp), ) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = "Delete profile", - tint = Color.White, - modifier = Modifier.size(16.dp), + MarqueeHeadline("Who's watching?", centered = true) + // Keeps its height while the account loads so the picker doesn't shift. + Text( + state.accountName?.let { "Signed in as $it" } ?: " ", + color = MarqueeColors.InkSecondary, + fontSize = MarqueeMetrics.LeadFont, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + state.error?.let { MarqueeErrorText(it) } + } + Spacer(Modifier.height(GridGap)) + if (state.isLoading && state.profiles.isEmpty()) { + CircularProgressIndicator(color = MarqueeColors.Ink, strokeWidth = 2.dp, modifier = Modifier.size(28.dp)) + } else { + TileRows( + state = state, + layout = layout, + enabled = enabled, + onPressChange = onPressChange, + onProfileTap = onProfileTap, + onProfileDelete = onProfileDelete, + onAddProfile = onAddProfile, ) } + Spacer(Modifier.height(FooterGap)) + Spacer(Modifier.weight(1f)) + // Changing server and signing out are utilities, kept small and below the people. + Row( + Modifier.onSizeChanged { footerHeight = it.height }, + horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally), + ) { + MarqueeButton( + text = "Change server", + onClick = onChangeServer, + kind = MarqueeButtonKind.Plain, + fullWidth = false, + compact = true, + icon = Icons.Outlined.Dns, + enabled = enabled, + ) + MarqueeButton( + text = "Sign out", + onClick = onSignOut, + kind = MarqueeButtonKind.Plain, + fullWidth = false, + compact = true, + icon = Icons.AutoMirrored.Outlined.Logout, + enabled = enabled, + ) + if (state.canManageProfiles) { + MarqueeButton( + text = if (state.isManageMode) "Done" else "Manage", + onClick = onToggleManage, + kind = MarqueeButtonKind.Plain, + fullWidth = false, + compact = true, + icon = if (state.isManageMode) null else Icons.Outlined.ManageAccounts, + enabled = enabled, + ) + } + } } } } } -/** - * Square tinted profile tile body matching iOS phone `ProfileTile`: - * tinted fill, top-edge highlight stroke, avatar (image fills the tile, - * emoji/initials sit on the tint), and lock/child badges top-right. - */ -@Composable -private fun ProfileTileBody(profile: Profile, tint: Color) { - val shape = RoundedCornerShape(TileCornerRadius) - val avatar = profile.avatarRef() - val avatarImage = rememberProfileAvatarImage(avatar) +private sealed interface PickerItem { + data class Person(val profile: Profile) : PickerItem + data object Add : PickerItem +} - Box( - modifier = Modifier - .size(TileSize) - .clip(shape) - .background(tint) - // Top-edge highlight so the tile reads as a physical surface. - .border( - width = 1.dp, - brush = Brush.verticalGradient( - colors = listOf( - Color.White.copy(alpha = 0.22f), - Color.White.copy(alpha = 0.04f), - ), - ), - shape = shape, - ), - contentAlignment = Alignment.Center, +/** Rows are centered, so a short last row sits in the middle instead of hanging off the left. */ +@Composable +private fun TileRows( + state: ProfileSelectionUiState, + layout: ProfilePickerLayout?, + enabled: Boolean, + onPressChange: (Profile, Boolean) -> Unit, + onProfileTap: (Profile) -> Unit, + onProfileDelete: (Profile) -> Unit, + onAddProfile: () -> Unit, +) { + val items = state.profiles.map(PickerItem::Person) + if (state.canManageProfiles) listOf(PickerItem.Add) else emptyList() + val perRow = layout?.perRow ?: 3 + Column( + Modifier.fillMaxWidth().alpha(if (layout == null) 0f else 1f), + verticalArrangement = Arrangement.spacedBy((layout?.rowSpacing ?: 0f).dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { - if (avatarImage != null) { - ThumbhashImage( - url = avatarImage.url, - thumbhash = null, - contentDescription = "${profile.name} avatar", - modifier = Modifier - .fillMaxSize() - .clip(shape), - contentScale = ContentScale.Crop, - transparent = true, - cacheKey = avatarImage.cacheKey, - onError = avatarImage.onLoadFailed, - ) - } else if (isEmojiAvatar(avatar)) { - Text( - text = avatar.avatar.orEmpty().trim(), - fontSize = TileEmojiSize.sp, - ) - } else { - Text( - text = profileAvatarDisplayText(avatar = avatar, name = profile.name), - fontSize = TileInitialSize.sp, - fontWeight = FontWeight.SemiBold, - color = Color.White.copy(alpha = 0.92f), - ) - } - - // Primary / child / lock badges ride the top-right corner. - if (profile.isPrimary || profile.hasPin || profile.isChild) { - Row( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(12.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - if (profile.isPrimary) { - TileBadge(Icons.Filled.WorkspacePremium, contentDescription = "Primary profile") - } - if (profile.isChild) { - TileBadge(Icons.Filled.Eco) - } - if (profile.hasPin) { - TileBadge(Icons.Filled.Lock) + items.chunked(perRow).forEach { row -> + // Top-aligned so a "Last used" caption doesn't lift its avatar above the rest. + Row(verticalAlignment = Alignment.Top) { + row.forEach { item -> + Box(Modifier.width((layout?.columnWidth ?: 0f).dp), contentAlignment = Alignment.TopCenter) { + when (item) { + is PickerItem.Person -> ProfileTile( + profile = item.profile, + size = (layout?.avatarSize ?: 0f).dp, + isLastUsed = item.profile.id == state.activeProfileId, + isManageMode = state.isManageMode, + enabled = enabled, + onPressChange = { onPressChange(item.profile, it) }, + onTap = { onProfileTap(item.profile) }, + onDelete = { onProfileDelete(item.profile) }, + ) + PickerItem.Add -> AddProfileTile(size = (layout?.addSize ?: 0f).dp, enabled = enabled, onClick = onAddProfile) + } + } } } } } } +/** Names grow more slowly than avatars so large avatars keep short labels. */ +private fun nameSize(avatar: Float) = 15f * (1f + (avatar / 92f - 1f) * 0.35f) + @Composable -private fun TileBadge( - icon: androidx.compose.ui.graphics.vector.ImageVector, - contentDescription: String? = null, +private fun ProfileTile( + profile: Profile, + size: androidx.compose.ui.unit.Dp, + isLastUsed: Boolean, + isManageMode: Boolean, + enabled: Boolean, + onPressChange: (Boolean) -> Unit, + onTap: () -> Unit, + onDelete: () -> Unit, ) { - Box( - modifier = Modifier - .clip(CircleShape) - .background(Color.Black.copy(alpha = 0.35f)) - .padding(8.dp), - contentAlignment = Alignment.Center, + val interaction = remember { MutableInteractionSource() } + val pressed by interaction.collectIsPressedAsState() + LaunchedEffect(pressed) { onPressChange(pressed) } + Column( + Modifier + .clickable(enabled = enabled, role = Role.Button, interactionSource = interaction, indication = null, onClick = onTap) + .marqueePressHaptic(interaction) + .alpha(if (pressed) 0.8f else 1f) + .semantics(mergeDescendants = true) { + contentDescription = profile.name + // The tile merges its children, so the delete badge is + // reached as an action rather than its own node. + if (isManageMode && !profile.isPrimary) { + customActions = listOf(CustomAccessibilityAction("Delete ${profile.name}") { onDelete(); true }) + } + stateDescription = listOfNotNull( + "Last used".takeIf { isLastUsed }, + "PIN protected".takeIf { profile.hasPin }, + "Kids profile".takeIf { profile.isChild }, + "Edit".takeIf { isManageMode }, + ).joinToString(", ") + }, + horizontalAlignment = Alignment.CenterHorizontally, ) { - Icon( - imageVector = icon, - contentDescription = contentDescription, - tint = Color.White, - modifier = Modifier.size(16.dp), + Box { + MarqueeProfileAvatar(profile, size = size, modifier = Modifier.alpha(if (isManageMode) 0.6f else 1f)) + if (isManageMode) { + Box(Modifier.size(size), contentAlignment = Alignment.Center) { + Icon(Icons.Filled.Edit, contentDescription = null, tint = Color.White, modifier = Modifier.size(size * 0.28f)) + } + // The server never deletes the primary profile (409 + // primary_profile_protected), so don't offer it. + if (!profile.isPrimary) { + Box( + Modifier + .align(Alignment.TopStart) + .size(28.dp) + .clip(CircleShape) + .background(MarqueeColors.Error) + .clickable(role = Role.Button, onClick = onDelete) + .semantics { contentDescription = "Delete ${profile.name}" }, + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Filled.Close, contentDescription = null, tint = Color.White, modifier = Modifier.size(16.dp)) + } + } + } + } + Text( + profile.name, + color = MarqueeColors.Ink, + fontSize = nameSize(size.value).sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 10.dp, start = 4.dp, end = 4.dp), ) + if (isLastUsed) { + Text("Last used", color = MarqueeColors.InkTertiary, fontSize = 12.sp, maxLines = 1, modifier = Modifier.padding(top = 2.dp)) + } } } @Composable -private fun AddProfileCard(onClick: () -> Unit) { - val shape = RoundedCornerShape(TileCornerRadius) +private fun AddProfileTile(size: androidx.compose.ui.unit.Dp, enabled: Boolean, onClick: () -> Unit) { + val interaction = remember { MutableInteractionSource() } Column( - modifier = Modifier.clickable(onClick = onClick), + Modifier + .clickable(enabled = enabled, role = Role.Button, interactionSource = interaction, indication = null, onClick = onClick) + .marqueePressHaptic(interaction) + .semantics(mergeDescendants = true) { contentDescription = "Add profile" }, horizontalAlignment = Alignment.CenterHorizontally, ) { Box( - modifier = Modifier - .size(TileSize) - .clip(shape) - .background(Color.White.copy(alpha = 0.06f)) - .border(width = 2.dp, color = Color.White.copy(alpha = 0.28f), shape = shape), + Modifier + .size(size) + .drawBehind { + val stroke = 2.dp.toPx() + drawCircle( + color = Color.White.copy(alpha = 0.25f), + radius = this.size.minDimension / 2f - stroke / 2f, + center = Offset(this.size.width / 2f, this.size.height / 2f), + style = Stroke(width = stroke, pathEffect = PathEffect.dashPathEffect(floatArrayOf(6.dp.toPx(), 5.dp.toPx()))), + ) + }, contentAlignment = Alignment.Center, ) { - Icon( - imageVector = Icons.Filled.Add, - contentDescription = "Add profile", - tint = Color.White.copy(alpha = 0.6f), - modifier = Modifier.size(84.dp), - ) + Icon(Icons.Filled.Add, contentDescription = null, tint = MarqueeColors.InkTertiary, modifier = Modifier.size(size * 0.3f)) } - - Spacer(modifier = Modifier.height(20.dp)) - Text( - text = "Add Profile", - fontSize = TileNameSize, - fontWeight = FontWeight.Medium, - color = Color.White.copy(alpha = 0.55f), - textAlign = TextAlign.Center, + "Add profile", + color = MarqueeColors.InkSecondary, + fontSize = nameSize(size.value).sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(top = 10.dp), ) } } - -/** - * Warm tile palette mirroring iOS `ProfileTilePalette`. Tint is derived - * from the profile id via DJB2 so a profile always gets the same color. - */ -private object ProfileTilePalette { - private val colors = listOf( - Color(red = 0.850f, green = 0.460f, blue = 0.380f), // coral - Color(red = 0.400f, green = 0.560f, blue = 0.720f), // slate blue - Color(red = 0.690f, green = 0.540f, blue = 0.400f), // warm tan - Color(red = 0.460f, green = 0.620f, blue = 0.520f), // sage - Color(red = 0.780f, green = 0.480f, blue = 0.520f), // dusty rose - Color(red = 0.560f, green = 0.480f, blue = 0.720f), // lavender - Color(red = 0.360f, green = 0.580f, blue = 0.620f), // teal - Color(red = 0.780f, green = 0.640f, blue = 0.380f), // amber - ) - - fun tint(profileId: String): Color { - var h = 5381UL - for (byte in profileId.encodeToByteArray()) { - h = ((h shl 5) + h) + byte.toUByte().toULong() - } - return colors[(h % colors.size.toULong()).toInt()] - } -} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/ProfileSelectionViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/ProfileSelectionViewModel.kt index 5804570d1f..094611692e 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/ProfileSelectionViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/ProfileSelectionViewModel.kt @@ -4,17 +4,20 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import org.prairieserver.prairie.model.profile.Profile import org.prairieserver.prairie.model.profile.authorizedProfileToken +import org.prairieserver.prairie.model.profile.householdPrimary import org.prairieserver.prairie.model.server.ServerContract import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.AuthScopeSnapshot import org.prairieserver.prairie.repository.AuthRepository import org.prairieserver.prairie.repository.ProfileCommitResult import org.prairieserver.prairie.repository.ProfileRepository +import org.prairieserver.prairie.repository.ProfileVerificationRecovery import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.prairieserver.prairie.common.ui.marquee.SignInHandoff data class ProfileSelectionUiState( val profiles: List = emptyList(), @@ -24,8 +27,16 @@ data class ProfileSelectionUiState( val isManageMode: Boolean = false, /** Non-null when a PIN-protected profile was tapped and the dialog should show. */ val pinDialogProfile: Profile? = null, + /** The open PIN prompt is the primary profile's, unlocking manage mode rather than selecting it. */ + val pinForManagement: Boolean = false, val pinIsVerifying: Boolean = false, val pinError: String? = null, + /** Bumped on each rejected PIN so the dots shake and the entry clears. */ + val pinErrorCount: Int = 0, + /** The signed-in account's username, shown under the title; null until read. */ + val accountName: String? = null, + /** A one-profile household is being opened straight after sign-in; the picker stays hidden. */ + val openingOnlyProfile: Boolean = false, /** Set after a profile is successfully selected. */ val selectedProfileId: String? = null, /** Non-null when a delete was requested and the confirm dialog should show. */ @@ -33,11 +44,15 @@ data class ProfileSelectionUiState( /** The profile this session is signed in as — deleting it needs a * stronger warning and clears the local selection first. */ val activeProfileId: String? = null, + /** Manage mode just started for "Add profile"; the screen opens the add form and consumes this. */ + val openAddProfile: Boolean = false, ) class ProfileSelectionViewModel( private val profileRepository: ProfileRepository, private val authRepository: AuthRepository? = null, + private val consumeSkipsSingleProfile: () -> Boolean = SignInHandoff::consumeSkipsSingleProfilePicker, + private val profileVerificationRecovery: ProfileVerificationRecovery? = null, ) : ViewModel() { private val _uiState = MutableStateFlow(ProfileSelectionUiState()) @@ -49,9 +64,43 @@ class ProfileSelectionViewModel( /** Monotonic generation for profile-list loads; see [loadProfiles]. */ private var loadAttempt: Int = 0 + /** + * Read once, on the first load: a sign-in that just finished asked to + * skip a one-person picker (see [SignInHandoff]). + */ + private var skipsSingleProfile: Boolean? = null + + /** "Add profile" asked for manage mode; open the add form once it starts. */ + private var addAfterUnlock = false + init { loadProfiles() reloadWhenUpdateRequiredLifts() + reloadWhenStaleProfileCleared() + leaveManageModeWhenSessionEnds() + } + + /** + * A load sent with a profile token the server no longer accepts fails with + * `profile_verification_required`. The recovery then clears that profile, + * and the grid reloads without it instead of leaving the error up. + */ + private fun reloadWhenStaleProfileCleared() { + val recovery = profileVerificationRecovery ?: return + viewModelScope.launch { + recovery.profileCleared.collect { loadProfiles() } + } + } + + /** Cancel on a re-prompt for the primary's PIN ends the session; manage mode goes with it. */ + private fun leaveManageModeWhenSessionEnds() { + viewModelScope.launch { + profileRepository.householdManagement.isActive.collect { active -> + if (!active && _uiState.value.isManageMode) { + _uiState.update { it.copy(isManageMode = false, deleteDialogProfile = null) } + } + } + } } /** @@ -97,7 +146,9 @@ class ProfileSelectionViewModel( val scope = profileRepository.captureIdentityScope() val activeId = profileRepository.getActiveProfileId() - val isAdmin = (authRepository?.getCurrentUser() as? ApiResult.Success)?.data?.role?.equals("admin", ignoreCase = true) == true + val skipSingle = skipsSingleProfile ?: consumeSkipsSingleProfile().also { skipsSingleProfile = it } + val user = (authRepository?.getCurrentUser() as? ApiResult.Success)?.data + val isAdmin = user?.role?.equals("admin", ignoreCase = true) == true val result = profileRepository.listProfiles() // Two separate reasons to drop this response: a newer load // superseded it, or the identity it was fetched under is gone. @@ -109,6 +160,7 @@ class ProfileSelectionViewModel( // Leaving a scope behind for an empty grid is stale metadata // that a later selection could be qualified against. gridScope = null + profileRepository.householdManagement.end() _uiState.update { it.copy( isLoading = false, @@ -130,9 +182,16 @@ class ProfileSelectionViewModel( // accepted as belonging to the new one. That is worse than // the unguarded commit this was meant to fix. gridScope = scope + // A household with one profile and no PIN doesn't need a + // picker after signing in: open it, once. + val only = result.data.singleOrNull()?.takeIf { skipSingle && !it.hasPin } + skipsSingleProfile = false + if (!isAdmin) profileRepository.householdManagement.end() _uiState.update { it.copy( isLoading = false, + openingOnlyProfile = only != null, + accountName = user?.username ?: it.accountName, profiles = result.data, activeProfileId = activeId, canManageProfiles = isAdmin, @@ -140,6 +199,7 @@ class ProfileSelectionViewModel( deleteDialogProfile = if (isAdmin) it.deleteDialogProfile else null, ) } + if (only != null) onProfileTapped(only) } is ApiResult.Error -> { @@ -160,9 +220,68 @@ class ProfileSelectionViewModel( } } + /** + * Entering manage mode acts as the household's primary profile, the only + * one the server lets manage the others, whichever profile this device + * last used. A PIN-locked primary is verified first; Cancel there leaves + * manage mode off. The primary is never selected or remembered. + */ fun toggleManageMode() { - if (!_uiState.value.canManageProfiles) return - _uiState.update { it.copy(isManageMode = !it.isManageMode) } + val state = _uiState.value + if (!state.canManageProfiles) return + if (state.isManageMode) { + profileRepository.householdManagement.end() + _uiState.update { it.copy(isManageMode = false) } + return + } + enterManageMode(thenAdd = false) + } + + /** + * "Add profile" creates a household profile, so it needs the same primary + * profile step as Manage; the add form opens once manage mode is on. + */ + fun requestAddProfile() { + val state = _uiState.value + if (!state.canManageProfiles) return + if (state.isManageMode && profileRepository.householdManagement.isActive.value) { + _uiState.update { it.copy(openAddProfile = true) } + } else { + enterManageMode(thenAdd = true) + } + } + + fun onAddProfileConsumed() { + _uiState.update { it.copy(openAddProfile = false) } + } + + private fun enterManageMode(thenAdd: Boolean) { + val primary = _uiState.value.profiles.householdPrimary() + if (primary == null) { + _uiState.update { it.copy(error = "Couldn't find the primary profile needed to manage profiles.") } + return + } + pinAttempt++ + addAfterUnlock = thenAdd + if (primary.hasPin) { + _uiState.update { + it.copy(pinDialogProfile = primary, pinForManagement = true, pinIsVerifying = false, pinError = null) + } + return + } + profileRepository.householdManagement.begin(primary, profileToken = null, scope = gridScope) + onManageModeStarted() + } + + private fun onManageModeStarted() { + val add = addAfterUnlock + addAfterUnlock = false + _uiState.update { it.copy(isManageMode = true, openAddProfile = add) } + } + + override fun onCleared() { + profileRepository.householdManagement.end() + super.onCleared() } /** @@ -191,6 +310,7 @@ class ProfileSelectionViewModel( _uiState.update { it.copy( pinDialogProfile = profile, + pinForManagement = false, pinIsVerifying = false, pinError = null, ) @@ -208,6 +328,7 @@ class ProfileSelectionViewModel( */ fun onPinEntered(pin: String) { val profile = _uiState.value.pinDialogProfile ?: return + val forManagement = _uiState.value.pinForManagement val attempt = ++pinAttempt viewModelScope.launch { @@ -226,12 +347,24 @@ class ProfileSelectionViewModel( when (result) { is ApiResult.Success -> { val token = result.data.authorizedProfileToken() - if (token != null) { + if (token != null && forManagement) { + // An account or server change during the round trip + // voids this answer; the reload shows the new identity. + if (!profileRepository.identityScopeUnchanged(scope)) { + dismissPinDialog() + loadProfiles() + return@launch + } + // Kept for manage mode only; the device's selection is untouched. + profileRepository.householdManagement.begin(profile, token, scope) + _uiState.update { it.copy(pinIsVerifying = false, pinDialogProfile = null, pinForManagement = false) } + onManageModeStarted() + } else if (token != null) { _uiState.update { it.copy(pinIsVerifying = false, pinDialogProfile = null) } selectProfile(profile.id, token, scope) } else { _uiState.update { - it.copy(pinIsVerifying = false, pinError = "Incorrect PIN") + it.copy(pinIsVerifying = false, pinError = "Wrong PIN. Try again.", pinErrorCount = it.pinErrorCount + 1) } } } @@ -241,13 +374,14 @@ class ProfileSelectionViewModel( it.copy( pinIsVerifying = false, pinError = result.message.ifBlank { "Verification failed" }, + pinErrorCount = it.pinErrorCount + 1, ) } } is ApiResult.NetworkError -> { _uiState.update { - it.copy(pinIsVerifying = false, pinError = "Network error") + it.copy(pinIsVerifying = false, pinError = "Network error", pinErrorCount = it.pinErrorCount + 1) } } } @@ -255,11 +389,12 @@ class ProfileSelectionViewModel( } fun dismissPinDialog() { + addAfterUnlock = false // Bump the generation so an in-flight verification for the dismissed // profile can no longer commit. pinAttempt++ _uiState.update { - it.copy(pinDialogProfile = null, pinIsVerifying = false, pinError = null) + it.copy(pinDialogProfile = null, pinForManagement = false, pinIsVerifying = false, pinError = null) } } @@ -277,10 +412,11 @@ class ProfileSelectionViewModel( val profile = _uiState.value.deleteDialogProfile ?: return _uiState.update { it.copy(deleteDialogProfile = null) } viewModelScope.launch { - // Order matters: the DELETE itself is authorized by the signed-in - // profile's X-Profile-Id/X-Profile-Token headers, so credentials - // must stay intact until the server has answered. Only after a - // successful delete of the signed-in profile do we clear the local + // Order matters: the DELETE is authorized by the household + // manager's (primary profile's) headers, but the signed-in + // profile's credentials stay intact until the server has + // answered. Only after a successful delete of the signed-in + // profile do we clear the local // selection — BEFORE reloading, so the list refresh doesn't ride // the now-invalidated profile token (previously that errored and // rendered an empty list — "all profiles gone"). @@ -317,6 +453,7 @@ class ProfileSelectionViewModel( ) { viewModelScope.launch { val result = profileRepository.selectProfile(profileId, profileToken, expectedScope) + if (result != ProfileCommitResult.Committed) _uiState.update { it.copy(openingOnlyProfile = false) } if (result == ProfileCommitResult.ScopeChanged) { // Someone else owns the identity now. Drop everything bound to // the identity we no longer have — a retained grid would let diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/requests/MyRequestsScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/requests/MyRequestsScreen.kt index 7eff1193f5..a43796c874 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/requests/MyRequestsScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/requests/MyRequestsScreen.kt @@ -1,136 +1,225 @@ package org.prairieserver.prairie.android.ui.screens.requests -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize +import org.prairieserver.prairie.android.ui.components.PrairieDropdownMenuItem +import org.prairieserver.prairie.android.ui.components.PrairieDropdownMenu +import org.prairieserver.prairie.common.requests.rememberRequestRouter +import org.prairieserver.prairie.model.request.MediaRequest +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.DownloadForOffline +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material.icons.outlined.HowToReg import androidx.compose.material.icons.outlined.Inbox -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Scaffold +import androidx.compose.material.icons.outlined.Inventory2 +import androidx.compose.material.icons.outlined.SyncProblem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Text -import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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 +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import org.prairieserver.prairie.android.ui.components.PrairieTopBar -import org.prairieserver.prairie.android.ui.components.EmptyStateView -import org.prairieserver.prairie.android.ui.components.LoadingIndicator -import org.prairieserver.prairie.common.ui.components.DeferImagePresentationWhileScrolling -import org.prairieserver.prairie.model.request.MediaRequest -import org.prairieserver.prairie.model.request.canCancel -import org.prairieserver.prairie.viewmodel.MyRequestsViewModel +import androidx.compose.ui.unit.sp +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import androidx.compose.material3.Icon import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.Spacer import androidx.compose.material.icons.filled.Close +import org.koin.core.parameter.parametersOf +import org.prairieserver.prairie.android.ui.components.EmptyStateView +import org.prairieserver.prairie.android.ui.components.ErrorView +import org.prairieserver.prairie.android.ui.components.rememberShimmerProgress +import org.prairieserver.prairie.android.ui.theme.PrairieNavPillBorder +import org.prairieserver.prairie.android.ui.theme.PrairieOverlayPillSurface +import org.prairieserver.prairie.android.ui.theme.PrairieSecondaryText +import org.prairieserver.prairie.common.requests.RequestColors +import org.prairieserver.prairie.model.feature.RequestsFeatureStore +import org.prairieserver.prairie.model.request.MyRequestsBucket +import org.prairieserver.prairie.viewmodel.MyRequestsViewModel +import org.prairieserver.prairie.viewmodel.RequestApprovalsViewModel -@OptIn(ExperimentalMaterial3Api::class) +/** + * The signed-in user's requests as a grouped list in the Downloads manager's + * shape: a stage track on every row, swipe to cancel, Open once a title + * lands, and a filter menu in the bar. Admins get a card into the approval + * queue. + */ @Composable fun MyRequestsScreen( onBackClick: () -> Unit, - onRequestClick: (MediaRequest) -> Unit, + onApprovalsClick: () -> Unit, + onRequestDetailClick: (mediaType: String, tmdbId: Int) -> Unit, + onLibraryItemClick: (String) -> Unit, viewModel: MyRequestsViewModel = koinViewModel(), + featureStore: RequestsFeatureStore = koinInject(), + approvals: RequestApprovalsViewModel = koinViewModel(key = "my-requests-approvals") { parametersOf(false) }, ) { val state by viewModel.uiState.collectAsState() + val canModerate by featureStore.canModerate.collectAsState() + val approvalsState by approvals.uiState.collectAsState() + val router = rememberRequestRouter(koinInject(), onLibraryItemClick, onRequestDetailClick) + var filter by remember { mutableStateOf(null) } - Scaffold( - topBar = { - PrairieTopBar( - title = "My Requests", - onBackClick = onBackClick, - ) + // For the approvals card; a failed read just hides it. + LaunchedEffect(canModerate) { if (canModerate) approvals.load() } + // A filter whose last request moved on would leave a blank list. + LaunchedEffect(state.buckets) { + if (filter != null && state.buckets.none { it.first == filter }) filter = null + } + + RequestsLargeTitlePage( + title = "My Requests", + onBackClick = onBackClick, + isRefreshing = state.isRefreshing, + onRefresh = { + viewModel.refresh() + // The approvals card is this page's only way into the queue. + if (canModerate) approvals.refresh() }, - containerColor = MaterialTheme.colorScheme.background, - ) { padding -> + itemSpacing = 0.dp, + actions = { + if (state.buckets.size > 1) { + FilterMenu(buckets = state.buckets, filter = filter, onFilter = { filter = it }) + } + }, + ) { + // Independent of the admin's own list: an admin with no requests of + // their own still needs the way into the queue. + val pending = approvalsState.awaitingApproval.size + val failed = approvalsState.failed.size + if (canModerate && approvalsState.hasLoaded && (pending > 0 || failed > 0)) { + item(key = "approvals-card") { + RequestSummaryCard( + modifier = Modifier.padding(top = RequestGroupGap), + title = when { + pending == 1 -> "1 request needs your approval" + pending > 1 -> "$pending requests need your approval" + failed == 1 -> "1 failed request to review" + else -> "$failed failed requests to review" + }, + leadingIcon = if (pending > 0) Icons.Outlined.HowToReg else Icons.Outlined.SyncProblem, + leadingTint = if (pending > 0) RequestColors.Amber else RequestColors.Rose, + onClick = onApprovalsClick, + ) + } + } when { - state.isLoading && state.requests.isEmpty() -> { - LoadingIndicator( - modifier = Modifier - .fillMaxSize() - .padding(padding), + state.error != null && state.buckets.isEmpty() -> item(key = "error") { + ErrorView(message = state.error.orEmpty(), onRetry = viewModel::load, modifier = Modifier.padding(top = 60.dp)) + } + state.isLoading && state.buckets.isEmpty() -> item(key = "loading") { + Box(modifier = Modifier.padding(top = RequestGroupGap)) { RequestRowsSkeleton(progress = rememberShimmerProgress()) } + } + state.isEmpty -> item(key = "empty") { + EmptyStateView( + title = "No requests yet", + subtitle = "Movies and series you request will show up here", + icon = Icons.Outlined.Inbox, + modifier = Modifier.padding(top = 80.dp), ) } else -> { - PullToRefreshBox( - isRefreshing = state.isRefreshing, - onRefresh = viewModel::refresh, - modifier = Modifier - .fillMaxSize() - .padding(padding), - ) { - val listState = rememberLazyListState() - DeferImagePresentationWhileScrolling(listState) { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - state.error?.let { error -> - item { - Text( - text = error, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - ) - } - } - if (state.requests.isEmpty()) { - item { - EmptyStateView( - title = "No requests yet", - subtitle = "Movies and series you request will show up here with their status.", - icon = Icons.Outlined.Inbox, - ) - } - } else { - items(state.requests, key = { it.id }) { request -> - RequestListItem( - request = request, - onClick = { onRequestClick(request) }, - trailing = { - if (request.canCancel()) { - Column { - OutlinedButton( - onClick = { viewModel.cancel(request.id) }, - enabled = state.actionInFlightId != request.id, - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = if (state.actionInFlightId == request.id) { - "Canceling" - } else { - "Cancel" - }, - maxLines = 1, - ) - } - } - } - }, - ) - } - } + state.actionErrorMessage?.let { message -> + item(key = "action-error") { + Text( + text = message, + fontSize = 12.sp, + color = PrairieSecondaryText, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(top = RequestGroupGap).padding(horizontal = 16.dp), + ) } + } + state.buckets.filter { filter == null || it.first == filter }.forEach { (bucket, requests) -> + requestGroupedSection(key = "bucket:${bucket.name}", title = bucket.title, items = requests, itemKey = { it.id }) { record, showDivider -> + MyRequestRow( + record = record, + isBusy = state.cancellingId == record.id, + showDivider = showDivider, + onOpen = { router.openRecord(record) }, + onOpenInLibrary = record.libraryContentId?.takeIf { it.isNotBlank() }?.let { id -> { onLibraryItemClick(id) } }, + onCancel = if (state.canCancel(record)) ({ viewModel.cancel(record) }) else null, + ) } } } } } } + +/** One control in the bar instead of a chip row that repeats the section headers. */ +@Composable +private fun FilterMenu( + buckets: List>>, + filter: MyRequestsBucket?, + onFilter: (MyRequestsBucket?) -> Unit, +) { + var open by remember { mutableStateOf(false) } + Box { + IconButton( + onClick = { open = true }, + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(if (filter != null) Color.White.copy(alpha = 0.30f) else PrairieOverlayPillSurface) + .border(1.dp, PrairieNavPillBorder, CircleShape), + ) { + Icon( + imageVector = Icons.Filled.FilterList, + contentDescription = filter?.let { "Filter: ${it.title}" } ?: "Filter", + tint = Color.White, + ) + } + PrairieDropdownMenu(expanded = open, onDismissRequest = { open = false }) { + FilterItem(label = "All Requests", icon = Icons.Outlined.Inventory2, selected = filter == null) { + open = false + onFilter(null) + } + buckets.forEach { (bucket, requests) -> + val icon = when (bucket) { + MyRequestsBucket.InMotion -> Icons.Outlined.DownloadForOffline + MyRequestsBucket.NeedsAttention -> Icons.Outlined.ErrorOutline + MyRequestsBucket.Landed -> Icons.Outlined.CheckCircle + } + FilterItem(label = "${bucket.title} (${requests.size})", icon = icon, selected = filter == bucket) { + open = false + onFilter(bucket) + } + } + } + } +} + +@Composable +private fun FilterItem(label: String, icon: ImageVector, selected: Boolean, onClick: () -> Unit) { + PrairieDropdownMenuItem( + text = { Text(label) }, + leadingIcon = { + Box(modifier = Modifier.size(24.dp), contentAlignment = Alignment.Center) { + if (selected) Icon(Icons.Filled.Check, contentDescription = "Selected") else Icon(icon, contentDescription = null) + } + }, + onClick = onClick, + ) +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/requests/RequestApprovalsScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/requests/RequestApprovalsScreen.kt new file mode 100644 index 0000000000..2a8ac5ae18 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/requests/RequestApprovalsScreen.kt @@ -0,0 +1,151 @@ +package org.prairieserver.prairie.android.ui.screens.requests + +import org.prairieserver.prairie.android.ui.components.PrairieConfirmDialog +import android.os.Build +import android.view.HapticFeedbackConstants +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf +import org.prairieserver.prairie.android.ui.components.EmptyStateView +import org.prairieserver.prairie.android.ui.components.ErrorView +import org.prairieserver.prairie.android.ui.components.rememberShimmerProgress +import org.prairieserver.prairie.android.ui.theme.PrairieSecondaryText +import org.prairieserver.prairie.common.requests.RequestColors +import org.prairieserver.prairie.common.requests.rememberRequestRouter +import org.prairieserver.prairie.model.request.AdminRequestAction +import org.prairieserver.prairie.model.request.MediaRequest +import org.prairieserver.prairie.viewmodel.RequestApprovalsViewModel + +/** + * Everyone's requests waiting on a decision, and failed ones that can be + * retried. Only reachable for admins who can moderate. Only the tapped row + * fades while its action runs; the button shows the result before the row + * leaves, and a failure shakes the button and shows the reason on the row. + */ +@Composable +fun RequestApprovalsScreen( + onBackClick: () -> Unit, + onRequestDetailClick: (mediaType: String, tmdbId: Int) -> Unit, + onLibraryItemClick: (String) -> Unit, + viewModel: RequestApprovalsViewModel = koinViewModel(key = "approvals") { parametersOf(true) }, +) { + val state by viewModel.uiState.collectAsState() + val router = rememberRequestRouter(koinInject(), onLibraryItemClick, onRequestDetailClick) + var pendingDecline by remember { mutableStateOf(null) } + val view = LocalView.current + + // Counters the view model keeps; remembered so a return or rotation + // doesn't replay the last action's haptic. + var seenCompleted by rememberSaveable { mutableStateOf(state.completedActions) } + var seenFailed by rememberSaveable { mutableStateOf(state.failedActions) } + LaunchedEffect(state.completedActions) { + if (state.completedActions > seenCompleted) view.performHapticFeedback(confirmHaptic()) + seenCompleted = state.completedActions + } + LaunchedEffect(state.failedActions) { + if (state.failedActions > seenFailed) view.performHapticFeedback(rejectHaptic()) + seenFailed = state.failedActions + } + + pendingDecline?.let { request -> + PrairieConfirmDialog( + title = "Decline this request?", + body = "The requester will see it as declined.", + confirmLabel = "Decline ${request.title}", + dismissLabel = "Keep", + onConfirm = { + pendingDecline = null + viewModel.perform(AdminRequestAction.Decline, request) + }, + onDismiss = { pendingDecline = null }, + ) + } + + RequestsLargeTitlePage( + title = "Approvals", + onBackClick = onBackClick, + isRefreshing = state.isRefreshing, + onRefresh = viewModel::refresh, + itemSpacing = 0.dp, + ) { + when { + state.error != null && state.awaitingApproval.isEmpty() && state.failed.isEmpty() -> item(key = "error") { + ErrorView(message = state.error.orEmpty(), onRetry = viewModel::load, modifier = Modifier.padding(top = 60.dp)) + } + !state.hasLoaded -> item(key = "loading") { + Box(modifier = Modifier.padding(top = RequestGroupGap)) { RequestRowsSkeleton(progress = rememberShimmerProgress()) } + } + state.isEmpty -> item(key = "empty") { + EmptyStateView( + title = "Nothing waiting on you", + subtitle = "New requests that need approval will show up here", + icon = Icons.Outlined.CheckCircle, + modifier = Modifier.padding(top = 80.dp), + ) + } + else -> { + state.actionErrorMessage?.let { message -> + item(key = "action-error") { + Text( + text = message, + fontSize = 12.sp, + color = PrairieSecondaryText, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(top = RequestGroupGap).padding(horizontal = 16.dp), + ) + } + } + requestGroupedSection(key = "awaiting", title = "Waiting for approval", items = state.awaitingApproval, itemKey = { it.id }) { record, showDivider -> + RequestApprovalRow( + record = record, + isBusy = !state.canAct(record), + phase = state.phases[record.id], + actionError = state.rowErrors[record.id], + shakeTrigger = state.failureCounts[record.id] ?: 0, + showDivider = showDivider, + onOpen = { router.openModerationRecord(record) }, + onApprove = { viewModel.perform(AdminRequestAction.Approve, record) }, + onDecline = { pendingDecline = record }, + ) + } + requestGroupedSection(key = "failed", title = "Failed", items = state.failed, itemKey = { it.id }) { record, showDivider -> + MyRequestRow( + record = record, + isBusy = !state.canAct(record), + actionPhase = state.phases[record.id], + actionError = state.rowErrors[record.id]?.let { "Couldn't retry · $it" }, + shakeTrigger = state.failureCounts[record.id] ?: 0, + showDivider = showDivider, + onOpen = { router.openModerationRecord(record) }, + onRetry = { viewModel.perform(AdminRequestAction.Retry, record) }, + ) + } + } + } + } +} + +internal fun confirmHaptic(): Int = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) HapticFeedbackConstants.CONFIRM else HapticFeedbackConstants.LONG_PRESS + +internal fun rejectHaptic(): Int = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) HapticFeedbackConstants.REJECT else HapticFeedbackConstants.LONG_PRESS diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/requests/RequestComponents.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/requests/RequestComponents.kt index e5df4d1600..f883839bff 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/requests/RequestComponents.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/requests/RequestComponents.kt @@ -1,10 +1,12 @@ package org.prairieserver.prairie.android.ui.screens.requests import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable 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.aspectRatio @@ -12,240 +14,606 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Movie -import androidx.compose.material.icons.filled.Tv -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.FilterChip +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.PriorityHigh +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.IconButton import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.prairieserver.prairie.android.ui.components.MediaGridDefaults +import org.prairieserver.prairie.android.ui.components.skeleton +import org.prairieserver.prairie.android.ui.theme.PrairieNavPillBorder +import org.prairieserver.prairie.android.ui.theme.PrairieOnSurface +import org.prairieserver.prairie.android.ui.theme.PrairieOverlayPillSurface +import org.prairieserver.prairie.android.ui.theme.PrairiePageBackground +import org.prairieserver.prairie.android.ui.theme.PrairieSecondaryText +import org.prairieserver.prairie.android.ui.theme.PrairieSurfaceContainer +import org.prairieserver.prairie.android.ui.theme.PrairieSurfaceElevated import org.prairieserver.prairie.common.cards.LocalCardPresentation +import org.prairieserver.prairie.common.requests.RequestColors +import org.prairieserver.prairie.common.requests.color +import org.prairieserver.prairie.common.ui.components.DeferImagePresentationWhileScrolling import org.prairieserver.prairie.common.ui.components.ThumbhashImage import org.prairieserver.prairie.model.request.MediaRequest +import org.prairieserver.prairie.model.request.RequestDisplayState import org.prairieserver.prairie.model.request.RequestMediaResult -import org.prairieserver.prairie.model.request.RequestMediaType -import org.prairieserver.prairie.model.request.badgeStatus -import org.prairieserver.prairie.model.request.requestDisplayLabel +import org.prairieserver.prairie.model.request.RequestProgress +import org.prairieserver.prairie.model.request.RequestStatusTint import org.prairieserver.prairie.model.request.requestPosterUrl -import org.prairieserver.prairie.model.request.targetSummary +/** Card geometry shared with the library rows ([org.prairieserver.prairie.android.ui.components.MediaCard]). */ +internal const val RequestPosterAspect = 2f / 3.3f + +@Composable +internal fun requestCardWidth(): Dp = 120.dp * LocalCardPresentation.current.posterSize.posterScale + +/** Chrome fills shared by the summary cards and quiet buttons (Apple's resting/selected chrome). */ +internal val RequestChromeRestingFill = Color.White.copy(alpha = 0.07f) +internal val RequestChromeRestingBorder = Color.White.copy(alpha = 0.09f) +internal val RequestChromeSelectedFill = Color.White.copy(alpha = 0.14f) + +internal fun RequestStatusTint.uiColor(): Color = color(neutral = PrairieSecondaryText) + +/** + * Poster card for a TMDB search/discover result, in the library card's + * grammar. A title that's already requested or in the library gets a small + * corner badge instead of a status caption. + */ @Composable fun RequestMediaCard( item: RequestMediaResult, onClick: () -> Unit, modifier: Modifier = Modifier, + width: Dp = requestCardWidth(), +) { + RequestPosterCard( + title = item.title, + year = item.year, + posterPath = item.posterPath, + progress = RequestProgress.of(item.availability, item.request), + showsStatusInCaption = false, + onClick = onClick, + modifier = modifier, + width = width, + ) +} + +/** Poster card for one of the user's own requests: its status is the caption. */ +@Composable +fun RequestRecordCard( + record: MediaRequest, + onClick: () -> Unit, + modifier: Modifier = Modifier, + width: Dp = requestCardWidth(), ) { - // Caption gating mirrors RequestMediaCard.swift: showsTitle wraps the - // whole caption block, showsMetadata the type/year line inside it. - val cardPresentation = LocalCardPresentation.current + RequestPosterCard( + title = record.title, + year = record.year, + posterPath = record.posterPath, + progress = RequestProgress.of(record), + showsStatusInCaption = true, + onClick = onClick, + modifier = modifier, + width = width, + ) +} + +@Composable +private fun RequestPosterCard( + title: String, + year: Int?, + posterPath: String?, + progress: RequestProgress?, + showsStatusInCaption: Boolean, + onClick: () -> Unit, + modifier: Modifier, + width: Dp, +) { + val caption = LocalCardPresentation.current.caption + val label = listOfNotNull(title, year?.takeIf { it > 0 }?.toString(), progress?.shortLabel).joinToString(", ") Column( modifier = modifier - .width(132.dp * cardPresentation.posterSize.posterScale) - .clickable(onClick = onClick), + .width(width) + .clickable(onClick = onClick) + .semantics(mergeDescendants = true) { contentDescription = label }, + verticalArrangement = Arrangement.spacedBy(4.dp), ) { Box( modifier = Modifier .fillMaxWidth() - .aspectRatio(2f / 3f) - .clip(MaterialTheme.shapes.small) - .background(MaterialTheme.colorScheme.surfaceVariant), + .aspectRatio(RequestPosterAspect) + .clip(RoundedCornerShape(8.dp)) + .background(PrairieSurfaceElevated), ) { - ThumbhashImage( - url = requestPosterUrl(item.posterPath), - thumbhash = null, - contentDescription = item.title, - contentScale = ContentScale.Crop, - modifier = Modifier.matchParentSize(), - ) - RequestBadge( - text = item.badgeStatus().requestDisplayLabel(), - modifier = Modifier - .align(Alignment.TopStart) - .padding(6.dp), - ) + val url = requestPosterUrl(posterPath) + if (url != null) { + ThumbhashImage( + url = url, + thumbhash = null, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.matchParentSize(), + ) + } else { + Text( + text = title, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = PrairieSecondaryText, + textAlign = TextAlign.Center, + modifier = Modifier + .align(Alignment.Center) + .padding(12.dp), + ) + } + if (!showsStatusInCaption && progress != null) { + RequestPosterBadge( + state = progress.display, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(6.dp), + ) + } } - if (cardPresentation.caption.showsTitle) { - // The spacer belongs to the caption: artwork-only cards must not - // leave a gap under the poster. - Spacer(modifier = Modifier.height(8.dp)) + if (caption.showsTitle) { Text( - text = item.title, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurface, + text = title, + fontSize = 14.sp, + lineHeight = 18.sp, + fontWeight = FontWeight.Bold, + color = PrairieOnSurface, maxLines = 2, + minLines = 2, overflow = TextOverflow.Ellipsis, ) - if (cardPresentation.caption.showsMetadata) { - RequestMetaLine( - mediaType = item.mediaType, - year = item.year, + when { + showsStatusInCaption && progress != null -> RequestStatusLabel(progress = progress) + caption.showsMetadata && year != null && year > 0 -> Text( + text = year.toString(), + fontSize = 12.sp, + color = PrairieSecondaryText, + maxLines = 1, ) } } } } +/** + * Corner badge for a discovery poster whose title is already requested or in + * the library: a white glyph on a filled status disc, in the watched badge's + * shape. + */ @Composable -fun RequestListItem( - request: MediaRequest, - onClick: (() -> Unit)?, - trailing: @Composable (() -> Unit)? = null, +fun RequestPosterBadge( + state: RequestDisplayState, modifier: Modifier = Modifier, + size: Dp = 20.dp, ) { - Card( + val icon = when (state) { + RequestDisplayState.Pending -> Icons.Filled.Schedule + RequestDisplayState.OnTheWay -> Icons.Filled.ArrowDownward + RequestDisplayState.InLibrary -> Icons.Filled.Check + is RequestDisplayState.NeedsAttention -> Icons.Filled.PriorityHigh + is RequestDisplayState.Unavailable -> return + } + Box( modifier = modifier - .fillMaxWidth() - .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f), - ), - shape = RoundedCornerShape(8.dp), + .size(size) + .shadow(4.dp, CircleShape, ambientColor = Color.Black.copy(alpha = 0.35f), spotColor = Color.Black.copy(alpha = 0.35f)) + .clip(CircleShape) + .background(state.tint.uiColor()), + contentAlignment = Alignment.Center, ) { - Row( - modifier = Modifier.padding(12.dp), + Icon(imageVector = icon, contentDescription = null, tint = Color.White, modifier = Modifier.size(size * 0.6f)) + } +} + +/** Status dot and label: the caption under request cards and the line under row tracks. */ +@Composable +fun RequestStatusLabel( + progress: RequestProgress, + modifier: Modifier = Modifier, + text: String = progress.shortLabel, + fontSize: TextUnit = 12.sp, + fontWeight: FontWeight = FontWeight.Normal, + color: Color = PrairieSecondaryText, + maxLines: Int = 1, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + Box( + modifier = Modifier + .padding(top = (fontSize.value * 0.45f).dp) + .size(6.dp) + .background(progress.tint.uiColor(), CircleShape), + ) + Text( + text = text, + fontSize = fontSize, + lineHeight = (fontSize.value * 1.3f).sp, + fontWeight = fontWeight, + color = color, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** Horizontal poster rail shared by every request surface. */ +@Composable +internal fun RequestCardRail( + items: List, + key: (T) -> Any, + card: @Composable (T) -> Unit, +) { + val rowState = rememberLazyListState() + DeferImagePresentationWhileScrolling(rowState) { + LazyRow( + state = rowState, horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(horizontal = 16.dp), ) { - Box( - modifier = Modifier - .width(64.dp) - .aspectRatio(2f / 3f) - .clip(MaterialTheme.shapes.small) - .background(MaterialTheme.colorScheme.surfaceVariant), - ) { - ThumbhashImage( - url = requestPosterUrl(request.posterPath), - thumbhash = null, - contentDescription = request.title, - contentScale = ContentScale.Crop, - modifier = Modifier.matchParentSize(), - ) - } - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { + items(items, key = key) { card(it) } + } + } +} + +/** + * Section header in the detail pages' editorial grammar: an optional tracked + * eyebrow over the title, and an optional trailing link. + */ +@Composable +internal fun RequestsSectionHeader( + title: String, + modifier: Modifier = Modifier, + label: String? = null, + trailing: String? = null, + onTrailingClick: (() -> Unit)? = null, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.Bottom, + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + if (!label.isNullOrBlank()) { Text( - text = request.title, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 2, - overflow = TextOverflow.Ellipsis, + text = label.uppercase(), + fontSize = 11.sp, + lineHeight = 14.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.6.sp, + color = PrairieOnSurface.copy(alpha = 0.55f), + maxLines = 1, ) - RequestMetaLine( - mediaType = request.mediaType, - year = request.year, - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - request.status.takeIf { it.isNotBlank() }?.let { - RequestBadge(text = it.requestDisplayLabel()) - } - request.outcome.takeIf { it.isNotBlank() }?.let { - RequestBadge(text = it.requestDisplayLabel()) - } - } - request.targetSummary()?.let { summary -> - Text( - text = summary, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - request.lastError.takeIf { it.isNotBlank() }?.let { error -> - Text( - text = error, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } } - trailing?.invoke() + Text( + text = title, + fontSize = 22.sp, + lineHeight = 28.sp, + fontWeight = FontWeight.SemiBold, + color = PrairieOnSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (trailing != null && onTrailingClick != null) { + Text( + text = "$trailing ›", + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = PrairieSecondaryText, + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .clickable(onClick = onTrailingClick) + .padding(horizontal = 4.dp, vertical = 4.dp), + ) } } } +/** + * The pinned top row of a large-title page: a back disc, the page title faded + * in once the large title scrolls away, and optional actions. The page's own + * first item is the large title. + */ @Composable -fun RequestBadge( - text: String, +internal fun RequestsLargeTitleBar( + title: String, + titleProgress: Float, + onBackClick: () -> Unit, modifier: Modifier = Modifier, + actions: @Composable () -> Unit = {}, ) { Box( modifier = modifier - .clip(RoundedCornerShape(6.dp)) - .background(Color.Black.copy(alpha = 0.62f)) - .padding(horizontal = 8.dp, vertical = 4.dp), + .fillMaxWidth() + .background(PrairiePageBackground.copy(alpha = titleProgress)) + .statusBarsPadding() + .height(56.dp) + .padding(horizontal = 16.dp), ) { + IconButton( + onClick = onBackClick, + modifier = Modifier + .align(Alignment.CenterStart) + .size(40.dp) + .clip(CircleShape) + .background(PrairieOverlayPillSurface) + .border(1.dp, PrairieNavPillBorder, CircleShape), + ) { + Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", tint = Color.White) + } Text( - text = text, - style = MaterialTheme.typography.labelSmall, + text = title, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, color = Color.White, maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .align(Alignment.Center) + .padding(horizontal = 56.dp) + .graphicsLayer { alpha = titleProgress }, ) + Box(modifier = Modifier.align(Alignment.CenterEnd)) { actions() } } } +/** The large title a page scrolls under its pinned bar. */ @Composable -fun RequestMetaLine( - mediaType: String, - year: Int?, +internal fun RequestsLargeTitle(title: String, modifier: Modifier = Modifier) { + Text( + text = title, + fontSize = 34.sp, + lineHeight = 40.sp, + fontWeight = FontWeight.Bold, + color = PrairieOnSurface, + modifier = modifier.padding(horizontal = 16.dp), + ) +} + +/** The page's search field, in the library search's capsule. */ +@Composable +internal fun RequestsSearchField( + query: String, + onQueryChange: (String) -> Unit, + onSearch: () -> Unit, modifier: Modifier = Modifier, ) { + TextField( + value = query, + onValueChange = onQueryChange, + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + placeholder = { Text("Search movies & series", color = PrairieSecondaryText) }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, tint = PrairieSecondaryText) }, + trailingIcon = if (query.isNotEmpty()) { + { + IconButton(onClick = { onQueryChange("") }) { + Icon(Icons.Filled.Close, contentDescription = "Clear search", tint = PrairieSecondaryText) + } + } + } else { + null + }, + singleLine = true, + shape = RoundedCornerShape(50), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { onSearch() }), + colors = TextFieldDefaults.colors( + focusedContainerColor = RequestChromeSelectedFill, + unfocusedContainerColor = RequestChromeSelectedFill, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + cursorColor = PrairieOnSurface, + focusedTextColor = PrairieOnSurface, + unfocusedTextColor = PrairieOnSurface, + ), + ) +} + +/** A glanceable card with a title, optional status parts, and a chevron. */ +@Composable +internal fun RequestSummaryCard( + title: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + parts: List> = emptyList(), + leadingIcon: ImageVector? = null, + leadingTint: Color = PrairieOnSurface, +) { + val shape = RoundedCornerShape(18.dp) Row( - modifier = modifier, + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(shape) + .background(RequestChromeRestingFill) + .border(1.dp, RequestChromeRestingBorder, shape) + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Icon( - imageVector = mediaType.icon(), - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(14.dp), - ) - Text( - text = listOfNotNull(mediaType.requestDisplayLabel(), year?.toString()).joinToString(" • "), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + if (leadingIcon != null) { + Icon(imageVector = leadingIcon, contentDescription = null, tint = leadingTint, modifier = Modifier.size(24.dp)) + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(text = title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = PrairieOnSurface) + if (parts.isNotEmpty()) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + parts.forEach { (tint, text) -> + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) { + Box(modifier = Modifier.size(6.dp).background(tint.uiColor(), CircleShape)) + Text(text = text, fontSize = 12.sp, color = PrairieSecondaryText) + } + } + } + } + } + Text(text = "›", fontSize = 20.sp, color = PrairieSecondaryText) + } +} + +/** Space above each grouped section on pages that pass zero item spacing. */ +internal val RequestGroupGap = 24.dp + +/** + * A grouped list section: a muted header with a count over a rounded card of + * rows. Each row is its own lazy item, so a long queue composes only what's on + * screen; the card's corners come from the first and last rows. + */ +internal fun LazyListScope.requestGroupedSection( + key: String, + title: String, + items: List, + itemKey: (T) -> Any, + row: @Composable (item: T, showDivider: Boolean) -> Unit, +) { + if (items.isEmpty()) return + item(key = "$key:header", contentType = "group-header") { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = RequestGroupGap) + .padding(horizontal = 32.dp, vertical = 8.dp), + ) { + Text(text = title, fontSize = 14.sp, fontWeight = FontWeight.Medium, color = PrairieSecondaryText, modifier = Modifier.weight(1f)) + Text(text = items.size.toString(), fontSize = 14.sp, fontWeight = FontWeight.Medium, color = PrairieSecondaryText) + } + } + itemsIndexed(items, key = { _, item -> "$key:${itemKey(item)}" }, contentType = { _, _ -> "group-row" }) { index, item -> + val corner = 20.dp + val shape = RoundedCornerShape( + topStart = if (index == 0) corner else 0.dp, + topEnd = if (index == 0) corner else 0.dp, + bottomStart = if (index == items.lastIndex) corner else 0.dp, + bottomEnd = if (index == items.lastIndex) corner else 0.dp, ) + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(shape) + .background(PrairieSurfaceContainer), + ) { + row(item, index > 0) + } } } +/** A quiet error line in the status line's slot. */ @Composable -fun RequestFilterChip( - selected: Boolean, - label: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - FilterChip( - selected = selected, - onClick = onClick, - label = { - Text(text = label) - }, +internal fun RequestRowErrorLine(text: String, modifier: Modifier = Modifier) { + Text( + text = "⚠ $text", + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = RequestColors.Rose, + maxLines = 2, + overflow = TextOverflow.Ellipsis, modifier = modifier, ) } -private fun String.icon(): ImageVector = when (this) { - RequestMediaType.Series -> Icons.Default.Tv - else -> Icons.Default.Movie +// ── Loading placeholders ───────────────────────────────────── + +/** A rail of quiet poster placeholders in the final layout's shape. */ +@Composable +internal fun RequestRailSkeleton(progress: Float, title: String? = null, cardCount: Int = 4) { + val width = requestCardWidth() + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + if (title != null) { + RequestsSectionHeader(title = title) + } else { + Box(modifier = Modifier.padding(horizontal = 16.dp).width(160.dp).height(22.dp).skeleton(progress)) + } + Row(modifier = Modifier.padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + repeat(cardCount) { + Column(verticalArrangement = Arrangement.spacedBy(7.dp)) { + Box(modifier = Modifier.width(width).aspectRatio(RequestPosterAspect).skeleton(progress)) + Box(modifier = Modifier.width(width * 0.7f).height(10.dp).skeleton(progress, RoundedCornerShape(3.dp))) + } + } + } + } } + +/** Grouped-row placeholders for My Requests and Approvals. */ +@Composable +internal fun RequestRowsSkeleton(progress: Float, rows: Int = 5) { + Column(modifier = Modifier.padding(top = 8.dp)) { + Box(modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp).width(96.dp).height(14.dp).skeleton(progress, RoundedCornerShape(3.dp))) + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + .background(PrairieSurfaceContainer) + .padding(vertical = 4.dp), + ) { + repeat(rows) { + Row(modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Box(modifier = Modifier.width(46.dp).height(69.dp).skeleton(progress, RoundedCornerShape(6.dp))) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Box(modifier = Modifier.fillMaxWidth(0.6f).height(14.dp).skeleton(progress, RoundedCornerShape(3.dp))) + Box(modifier = Modifier.fillMaxWidth(0.4f).height(10.dp).skeleton(progress, RoundedCornerShape(3.dp))) + Spacer(modifier = Modifier.height(2.dp)) + Box(modifier = Modifier.fillMaxWidth().height(4.dp).skeleton(progress, RoundedCornerShape(2.dp))) + Box(modifier = Modifier.fillMaxWidth(0.5f).height(10.dp).skeleton(progress, RoundedCornerShape(3.dp))) + } + } + } + } + } +} + +/** Column count of the library search grid at [width]. */ +internal fun requestGridColumns(width: Dp, minCellWidth: Dp): Int = + ((width - 32.dp + MediaGridDefaults.PosterGridHorizontalSpacing) / (minCellWidth + MediaGridDefaults.PosterGridHorizontalSpacing)) + .toInt() + .coerceAtLeast(2) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/requests/RequestDetailScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/requests/RequestDetailScreen.kt index 99abe345a9..c5185a2c31 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/requests/RequestDetailScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/requests/RequestDetailScreen.kt @@ -1,52 +1,58 @@ package org.prairieserver.prairie.android.ui.screens.requests +import org.prairieserver.prairie.android.ui.components.PrairieConfirmDialog +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable 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.IntrinsicSize import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.ErrorOutline -import androidx.compose.material.icons.outlined.Info -import androidx.compose.material3.Button -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Scaffold +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +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.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import org.prairieserver.prairie.android.ui.components.PrairieTopBar -import org.prairieserver.prairie.android.ui.components.EmptyStateView -import org.prairieserver.prairie.android.ui.components.LoadingIndicator -import org.prairieserver.prairie.common.ui.components.DeferImagePresentationWhileScrolling -import org.prairieserver.prairie.common.ui.components.ThumbhashImage -import org.prairieserver.prairie.model.request.RequestMediaDetail -import org.prairieserver.prairie.model.request.RequestMediaResult -import org.prairieserver.prairie.model.request.reasonMessage -import org.prairieserver.prairie.model.request.requestBackdropUrl -import org.prairieserver.prairie.model.request.requestDisplayLabel -import org.prairieserver.prairie.model.request.requestPosterUrl -import org.prairieserver.prairie.viewmodel.RequestDetailViewModel +import androidx.compose.ui.unit.sp +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf import androidx.compose.material.icons.filled.Refresh @@ -54,317 +60,440 @@ import androidx.compose.material3.Icon import androidx.compose.foundation.layout.size import androidx.compose.material.icons.filled.OpenInNew import androidx.compose.material.icons.filled.Add +import org.prairieserver.prairie.android.ui.components.ErrorView +import org.prairieserver.prairie.android.ui.screens.detail.AdaptiveDetailHero +import org.prairieserver.prairie.android.ui.screens.detail.DetailLoadingSkeleton +import org.prairieserver.prairie.android.ui.screens.detail.DetailPageSurface +import org.prairieserver.prairie.android.ui.screens.detail.DetailScrollState +import org.prairieserver.prairie.android.ui.screens.detail.DetailTopChrome +import org.prairieserver.prairie.android.ui.screens.detail.HeaderSettledDp +import org.prairieserver.prairie.android.ui.screens.detail.HeroMetadata +import org.prairieserver.prairie.android.ui.theme.PrairieBackground +import org.prairieserver.prairie.android.ui.theme.PrairieOnSurface +import org.prairieserver.prairie.android.ui.theme.PrairieSecondaryText +import org.prairieserver.prairie.android.ui.util.rememberDominantColor +import org.prairieserver.prairie.common.requests.RequestColors +import org.prairieserver.prairie.common.requests.RequestRouter +import org.prairieserver.prairie.common.requests.RequestRowCopy +import org.prairieserver.prairie.common.requests.rememberRequestRouter +import org.prairieserver.prairie.common.ui.components.DeferImagePresentationWhileScrolling +import org.prairieserver.prairie.model.catalog.ExternalRatings +import org.prairieserver.prairie.model.catalog.ItemDetail +import org.prairieserver.prairie.model.request.AdminRequestAction +import org.prairieserver.prairie.model.request.MediaRequest +import org.prairieserver.prairie.model.request.RequestDisplayState +import org.prairieserver.prairie.model.request.RequestMediaDetail +import org.prairieserver.prairie.model.request.RequestMediaType +import org.prairieserver.prairie.model.request.RequestProgress +import org.prairieserver.prairie.model.request.RequestStep +import org.prairieserver.prairie.model.request.RequestTargetSummary +import org.prairieserver.prairie.model.request.requestBackdropUrl +import org.prairieserver.prairie.model.request.requestPosterUrl +import org.prairieserver.prairie.model.request.requestReasonCopy +import org.prairieserver.prairie.viewmodel.RequestDetailUiState +import org.prairieserver.prairie.viewmodel.RequestDetailViewModel +import org.prairieserver.prairie.viewmodel.RequestPrimaryAction -@OptIn(ExperimentalMaterial3Api::class) +/** + * Detail for a requestable TMDB title, built on the same page surface, hero, + * and floating chrome as a library title. One server-computed primary action, + * a status card with the stage steps once a request exists, and a "More like + * this" rail. Requesting has no confirmation dialog: the button is the + * confirmation and turns into the status in place. + */ @Composable fun RequestDetailScreen( mediaType: String, tmdbId: Int, onBackClick: () -> Unit, - onMediaClick: (RequestMediaResult) -> Unit, + onRequestDetailClick: (mediaType: String, tmdbId: Int) -> Unit, onLibraryItemClick: (String) -> Unit, viewModel: RequestDetailViewModel = koinViewModel( parameters = { parametersOf(mediaType to tmdbId) }, ), ) { val state by viewModel.uiState.collectAsState() + val router = rememberRequestRouter(koinInject(), onLibraryItemClick, onRequestDetailClick) + val scroll = remember { DetailScrollState() } + var confirmingDecline by remember { mutableStateOf(false) } + val view = LocalView.current - Scaffold( - topBar = { - PrairieTopBar( - title = "Request Details", - onBackClick = onBackClick, - ) - }, - containerColor = MaterialTheme.colorScheme.background, - ) { padding -> - when { - state.isLoading && state.detail == null -> { - LoadingIndicator( - modifier = Modifier - .fillMaxSize() - .padding(padding), - ) - } - state.error != null && state.detail == null -> { - EmptyStateView( - title = "Details unavailable", - subtitle = state.error ?: "Failed to load this title.", - icon = Icons.Outlined.ErrorOutline, - modifier = Modifier - .fillMaxSize() - .padding(padding), - ) - } - state.detail != null -> { - RequestDetailContent( - detail = requireNotNull(state.detail), - isSubmitting = state.isSubmitting, - error = state.error, - notice = state.notice, - onRequest = viewModel::submitRequest, - onRetry = viewModel::load, - onMediaClick = onMediaClick, - onLibraryItemClick = onLibraryItemClick, - modifier = Modifier.padding(padding), - ) + // Counters the view model keeps; remembered so a return or rotation + // doesn't replay the last action's haptic. + val accepted = state.submittedCount + state.moderatedCount + var seenAccepted by rememberSaveable { mutableStateOf(accepted) } + LaunchedEffect(accepted) { + if (accepted > seenAccepted) view.performHapticFeedback(confirmHaptic()) + seenAccepted = accepted + } + + if (confirmingDecline) { + PrairieConfirmDialog( + title = "Decline this request?", + body = "The person who asked for it will see it as declined.", + confirmLabel = "Decline", + dismissLabel = "Keep", + onConfirm = { + confirmingDecline = false + viewModel.moderate(AdminRequestAction.Decline) + }, + onDismiss = { confirmingDecline = false }, + ) + } + + Box(modifier = Modifier.fillMaxSize().background(PrairieBackground)) { + val branch = when { + state.detail != null -> "detail" + state.error != null -> "error" + else -> "loading" + } + Crossfade(targetState = branch, animationSpec = tween(180), label = "requestDetailBranch") { current -> + when (current) { + "detail" -> state.detail?.let { detail -> + RequestDetailContent( + detail = detail, + state = state, + scroll = scroll, + onPrimary = { + when (val action = state.primaryAction) { + RequestPrimaryAction.Request -> viewModel.submitRequest() + is RequestPrimaryAction.OpenInLibrary -> onLibraryItemClick(action.contentId) + else -> Unit + } + }, + onModerate = { action -> + if (action == AdminRequestAction.Decline) confirmingDecline = true else viewModel.moderate(action) + }, + onCancel = viewModel::cancel, + router = router, + ) + } + "error" -> ErrorView(message = state.error.orEmpty(), onRetry = viewModel::load) + else -> DetailLoadingSkeleton() } } + DetailTopChrome(title = state.detail?.title.orEmpty(), scroll = scroll, onBackClick = onBackClick) } } @Composable private fun RequestDetailContent( detail: RequestMediaDetail, - isSubmitting: Boolean, - error: String?, - notice: String?, - onRequest: () -> Unit, - onRetry: () -> Unit, - onMediaClick: (RequestMediaResult) -> Unit, - onLibraryItemClick: (String) -> Unit, - modifier: Modifier = Modifier, + state: RequestDetailUiState, + scroll: DetailScrollState, + onPrimary: () -> Unit, + onModerate: (AdminRequestAction) -> Unit, + onCancel: () -> Unit, + router: RequestRouter, ) { - val listState = rememberLazyListState() - DeferImagePresentationWhileScrolling(listState) { - LazyColumn( - state = listState, - modifier = modifier.fillMaxSize(), - contentPadding = PaddingValues(bottom = 32.dp), - verticalArrangement = Arrangement.spacedBy(20.dp), - ) { - item { - Box( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(16f / 9f) - .background(MaterialTheme.colorScheme.surfaceVariant), - ) { - ThumbhashImage( - url = requestBackdropUrl(detail.backdropPath) ?: requestPosterUrl(detail.posterPath), - thumbhash = null, - contentDescription = detail.title, - contentScale = ContentScale.Crop, - modifier = Modifier.matchParentSize(), - ) + val itemDetail = remember(detail) { detail.toHeroDetail() } + val dominantColor by rememberDominantColor(imageUrl = itemDetail.backdropUrl, fallback = PrairieBackground) + val feedState = rememberLazyListState() + val density = LocalDensity.current + LaunchedEffect(feedState, scroll, density) { + snapshotFlow { + if (feedState.firstVisibleItemIndex > 0) { + HeaderSettledDp + } else { + with(density) { feedState.firstVisibleItemScrollOffset.toDp().value } } - } - item { - Row( - modifier = Modifier.padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), + }.collect { scroll.update(it) } + } + DetailPageSurface(backdropUrl = itemDetail.backdropUrl, backdropThumbhash = null, tint = dominantColor) { + DeferImagePresentationWhileScrolling(feedState) { + LazyColumn( + state = feedState, + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(32.dp), ) { - Box( - modifier = Modifier - .width(116.dp) - .aspectRatio(2f / 3f) - .clip(MaterialTheme.shapes.small) - .background(MaterialTheme.colorScheme.surfaceVariant), - ) { - ThumbhashImage( - url = requestPosterUrl(detail.posterPath), - thumbhash = null, - contentDescription = detail.title, - contentScale = ContentScale.Crop, - modifier = Modifier.matchParentSize(), + item(key = "hero", contentType = "detail-hero") { + AdaptiveDetailHero( + detail = itemDetail, + eyebrow = state.eyebrow, + // The library hero's facts: year and runtime or + // seasons, then two genres as one token. + sourceTokens = if (itemDetail.type == "series") { + HeroMetadata.seriesSourceTokens(itemDetail) + } else { + HeroMetadata.movieSourceTokens(itemDetail) + }, + factsLine = if (itemDetail.type == "series") { + HeroMetadata.seriesFactsLine(itemDetail) + } else { + HeroMetadata.movieFactsLine(itemDetail) + }, + dominantColor = dominantColor, + directorText = detail.creditText(), + actions = { + RequestDetailActions(state = state, onPrimary = onPrimary, onModerate = onModerate, onCancel = onCancel) + }, ) } - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = detail.title, - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Bold, - ) - RequestMetaLine(mediaType = detail.mediaType, year = detail.year) - Text( - text = detail.basicFacts(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - RequestActions( - detail = detail, - isSubmitting = isSubmitting, - onRequest = onRequest, - onLibraryItemClick = onLibraryItemClick, - ) + val recommendations = state.recommendations + if (recommendations.isNotEmpty()) { + item(key = "more-like-this", contentType = "rail") { + Column( + modifier = Modifier.padding(bottom = 40.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + RequestsSectionHeader(title = "More like this") + RequestCardRail(items = recommendations, key = { "${it.mediaType}:${it.tmdbId}" }) { result -> + RequestMediaCard(item = result, onClick = { router.openResult(result) }) + } + } + } } } } - notice?.let { - item { - Text( - text = it, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(horizontal = 16.dp), - ) - } + } +} + +/** The primary action, its hint or error, the status card, admin decisions, and Cancel. */ +@Composable +private fun RequestDetailActions( + state: RequestDetailUiState, + onPrimary: () -> Unit, + onModerate: (AdminRequestAction) -> Unit, + onCancel: () -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) { + PrimaryActionButton(state = state, onClick = onPrimary) + val message = state.actionErrorMessage + if (message != null) { + Text( + text = message, + fontSize = 12.sp, + color = PrairieSecondaryText, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } else if (state.showsRequestHint) { + Text( + text = "Not in your library yet. Your server admin reviews requests.", + fontSize = 12.sp, + color = PrairieSecondaryText, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) } - error?.let { - item { - Column( - modifier = Modifier.padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = it, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - ) - OutlinedButton(onClick = onRetry) { + val progress = state.progress + if (progress != null && state.showsStatus) { + RequestStatusCard(progress = progress, record = state.displayedRecord) + } + val actions = state.moderationActions + if (actions.isNotEmpty()) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + actions.forEach { action -> + val prominent = action != AdminRequestAction.Decline + Row( + modifier = Modifier + .weight(1f) + .heightIn(min = 44.dp) + .clip(CircleShape) + .background(if (prominent) Color.White else RequestChromeSelectedFill) + .clickable { onModerate(action) }, + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { Icon( - imageVector = Icons.Default.Refresh, + imageVector = when (action) { + AdminRequestAction.Approve -> Icons.Filled.Check + AdminRequestAction.Decline -> Icons.Filled.Close + AdminRequestAction.Retry -> Icons.Filled.Refresh + }, contentDescription = null, + tint = if (prominent) Color.Black else PrairieOnSurface, modifier = Modifier.size(18.dp), ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Retry") + Text( + text = action.name, + fontSize = 15.sp, + fontWeight = if (prominent) FontWeight.Bold else FontWeight.SemiBold, + color = if (prominent) Color.Black else PrairieOnSurface, + ) } } } } - if (detail.overview.isNotBlank()) { - item { - Column( - modifier = Modifier.padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = "Overview", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = detail.overview, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } + if (state.canCancel) { + Text( + text = "Cancel Request", + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + color = RequestColors.Rose, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onCancel) + .padding(vertical = 12.dp), + ) } - item { - if (detail.recommendations.isEmpty()) { - EmptyStateView( - title = "No recommendations", - subtitle = "Similar titles will appear here when the server returns them.", - icon = Icons.Outlined.Info, - ) - } else { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - text = "Recommendations", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(horizontal = 16.dp), - ) - val recRowState = rememberLazyListState() - DeferImagePresentationWhileScrolling(recRowState) { - LazyRow( - state = recRowState, - horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(horizontal = 16.dp), - ) { - items(detail.recommendations, key = { "${it.mediaType}-${it.tmdbId}" }) { item -> - RequestMediaCard( - item = item, - onClick = { - val libraryId = item.libraryContentId?.takeIf { it.isNotBlank() } - if (libraryId != null) onLibraryItemClick(libraryId) else onMediaClick(item) - }, - ) - } - } - } - } - } + } +} + +/** + * One button whose label and look follow [RequestPrimaryAction]: a white pill + * for Request and Open in Library, a quiet capsule with the status dot otherwise. + */ +@Composable +private fun PrimaryActionButton(state: RequestDetailUiState, onClick: () -> Unit) { + val action = state.primaryAction + if (action == RequestPrimaryAction.Loading) return + val interactive = action.isInteractive + val shape = CircleShape + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 52.dp) + .clip(shape) + .background(if (interactive) Color.White else RequestChromeRestingFill) + .border(1.dp, if (interactive) Color.Transparent else RequestChromeRestingBorder, shape) + .clickable(enabled = interactive, onClick = onClick), + horizontalArrangement = Arrangement.spacedBy(9.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + val content = if (interactive) Color.Black else PrairieOnSurface + when (action) { + RequestPrimaryAction.Request -> Icon(Icons.Filled.Add, contentDescription = null, tint = content, modifier = Modifier.size(20.dp)) + RequestPrimaryAction.Submitting -> CircularProgressIndicator(color = PrairieSecondaryText, strokeWidth = 2.dp, modifier = Modifier.size(16.dp)) + is RequestPrimaryAction.OpenInLibrary -> + Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, tint = content, modifier = Modifier.size(18.dp)) + is RequestPrimaryAction.Status -> + Box(modifier = Modifier.size(9.dp).background(action.state.tint.uiColor(), CircleShape)) + RequestPrimaryAction.Loading -> Unit } + Text( + text = state.primaryActionTitle, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = content, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } +} + +/** The four request steps with their timestamps, under the primary action. */ +@Composable +private fun RequestStatusCard(progress: RequestProgress, record: MediaRequest?) { + val shape = RoundedCornerShape(20.dp) + Column( + modifier = Modifier + .fillMaxWidth() + .clip(shape) + .background(Color.White.copy(alpha = 0.06f)) + .border(1.dp, Color.White.copy(alpha = 0.08f), shape) + .padding(16.dp), + ) { + Text( + text = "REQUEST STATUS", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.6.sp, + color = PrairieOnSurface.copy(alpha = 0.55f), + modifier = Modifier.padding(bottom = 14.dp), + ) + RequestStep.entries.forEach { step -> + StepRow(step = step, progress = progress, record = record, isLast = step == RequestStep.Library) + } } } @Composable -private fun RequestActions( - detail: RequestMediaDetail, - isSubmitting: Boolean, - onRequest: () -> Unit, - onLibraryItemClick: (String) -> Unit, -) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - val libraryContentId = detail.libraryContentId?.takeIf { it.isNotBlank() } - when { - libraryContentId != null -> { - Button(onClick = { onLibraryItemClick(libraryContentId) }) { - Icon( - imageVector = Icons.Default.OpenInNew, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Open Library Item") - } - } - detail.request.requestable -> { - Button( - onClick = onRequest, - enabled = !isSubmitting, - ) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(if (isSubmitting) "Requesting..." else "Request") - } +private fun StepRow(step: RequestStep, progress: RequestProgress, record: MediaRequest?, isLast: Boolean) { + val isDone = step.ordinal < progress.completedSteps + val isCurrent = step == progress.currentStep + Row(modifier = Modifier.height(IntrinsicSize.Min), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Column(modifier = Modifier.width(22.dp).fillMaxHeight(), horizontalAlignment = Alignment.CenterHorizontally) { + StepMarker(isDone = isDone, isCurrent = isCurrent, tint = progress.tint.uiColor()) + if (!isLast) { + Box( + modifier = Modifier + .width(2.dp) + .weight(1f) + .background(if (isDone) PrairieOnSurface.copy(alpha = 0.6f) else Color.White.copy(alpha = 0.14f)), + ) } - else -> { - OutlinedButton( - onClick = {}, - enabled = false, - ) { - Text( - text = detail.request.status - ?.takeIf { it.isNotBlank() } - ?.replaceFirstChar { it.uppercase() } - ?: "Unavailable", - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } + } + Column( + modifier = Modifier.padding(bottom = if (isLast) 0.dp else 14.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = stepTitle(step, isCurrent, progress), + fontSize = 15.sp, + fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Medium, + color = if (isDone || isCurrent) PrairieOnSurface else PrairieOnSurface.copy(alpha = 0.45f), + ) + stepDetail(step, isDone, isCurrent, progress, record)?.let { + Text(text = it, fontSize = 12.sp, color = PrairieSecondaryText) } } - RequestDetailStatus(detail) } } @Composable -private fun RequestDetailStatus(detail: RequestMediaDetail) { - val status = if (detail.request.reason.isBlank()) { - detail.availability.requestDisplayLabel() - } else { - detail.request.reasonMessage() - } ?: return - Text( - text = status, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) +private fun StepMarker(isDone: Boolean, isCurrent: Boolean, tint: Color) { + when { + isDone -> Box( + modifier = Modifier.size(22.dp).background(PrairieOnSurface.copy(alpha = 0.9f), CircleShape), + contentAlignment = Alignment.Center, + ) { Icon(Icons.Filled.Check, contentDescription = null, tint = Color.Black, modifier = Modifier.size(13.dp)) } + isCurrent -> Box( + modifier = Modifier.size(22.dp).border(2.dp, tint, CircleShape), + contentAlignment = Alignment.Center, + ) { Box(modifier = Modifier.size(9.dp).background(tint, CircleShape)) } + else -> Box(modifier = Modifier.size(22.dp).border(2.dp, Color.White.copy(alpha = 0.18f), CircleShape)) + } } -private fun RequestMediaDetail.basicFacts(): String { - val facts = buildList { - runtime?.let { add("${it}m") } - if (genres.isNotEmpty()) add(genres.take(2).joinToString(", ")) - voteAverage?.let { add("TMDB ${"%.1f".format(it)}") } - if (numberOfSeasons != null) add("$numberOfSeasons seasons") +/** The current step reads as what's happening now: "Declined" instead of "Approved", "Queued" instead of "Downloading". */ +private fun stepTitle(step: RequestStep, isCurrent: Boolean, progress: RequestProgress): String { + if (!isCurrent) return step.title + return when (progress.display) { + is RequestDisplayState.NeedsAttention, RequestDisplayState.OnTheWay -> progress.shortLabel + else -> step.title } - return facts.joinToString(" • ").ifBlank { availability.requestDisplayLabel() } +} + +private fun stepDetail(step: RequestStep, isDone: Boolean, isCurrent: Boolean, progress: RequestProgress, record: MediaRequest?): String? { + val reason = (progress.display as? RequestDisplayState.NeedsAttention)?.reason + return when (step) { + RequestStep.Requested -> RequestRowCopy.stamp(record?.createdAt) + RequestStep.Approval -> when { + isDone -> RequestRowCopy.stamp(record?.approvedAt) + isCurrent && progress.display is RequestDisplayState.NeedsAttention -> requestReasonCopy(reason) + isCurrent -> "Waiting for your server admin" + else -> null + } + RequestStep.Download -> when { + !isCurrent && !isDone -> null + isCurrent && progress.display is RequestDisplayState.NeedsAttention -> requestReasonCopy(reason) + else -> record?.let { RequestTargetSummary.text(it.targets) } + } + RequestStep.Library -> if (isCurrent) "Your library is picking it up" else null + } +} + +/** A catalog-shaped detail for the shared hero: the title, artwork, rating, and facts a request carries. */ +private fun RequestMediaDetail.toHeroDetail(): ItemDetail = ItemDetail( + contentId = "request:$mediaType:$tmdbId", + type = if (mediaType == RequestMediaType.Series) "series" else "movie", + title = title, + year = year ?: 0, + overview = overview.takeIf { it.isNotBlank() }, + contentRating = contentRating.takeIf { it.isNotBlank() }, + genres = genres, + runtime = runtime ?: 0, + seasonCount = numberOfSeasons, + posterUrl = requestPosterUrl(posterPath), + backdropUrl = requestBackdropUrl(backdropPath) ?: requestPosterUrl(posterPath), + ratings = listOfNotNull(ExternalRatings.tmdb(voteAverage)), +) + +private fun RequestMediaDetail.creditText(): String? = when { + director.isNotBlank() -> "Directed by $director" + creators.isNotEmpty() -> "Created by ${creators.take(2).joinToString(", ")}" + else -> null } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/requests/RequestRows.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/requests/RequestRows.kt new file mode 100644 index 0000000000..94aef4e690 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/requests/RequestRows.kt @@ -0,0 +1,407 @@ +package org.prairieserver.prairie.android.ui.screens.requests + +import org.prairieserver.prairie.android.ui.components.PrairieDropdownMenuItem +import org.prairieserver.prairie.android.ui.components.PrairieDropdownMenu +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.Text +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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 +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +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 org.prairieserver.prairie.android.ui.theme.PrairieBorder +import org.prairieserver.prairie.android.ui.theme.PrairieOnSurface +import org.prairieserver.prairie.android.ui.theme.PrairieSecondaryText +import org.prairieserver.prairie.android.ui.theme.PrairieSurfaceContainer +import org.prairieserver.prairie.android.ui.theme.PrairieSurfaceElevated +import org.prairieserver.prairie.common.requests.RequestColors +import org.prairieserver.prairie.common.requests.RequestRowCopy +import org.prairieserver.prairie.common.requests.RequestStageTrack +import org.prairieserver.prairie.common.ui.components.ThumbhashImage +import org.prairieserver.prairie.model.request.AdminRequestAction +import org.prairieserver.prairie.model.request.MediaRequest +import org.prairieserver.prairie.model.request.RequestDisplayState +import org.prairieserver.prairie.model.request.RequestProgress +import org.prairieserver.prairie.model.request.RequestRowActionPhase +import org.prairieserver.prairie.model.request.requestPosterUrl + +/** + * One request in a grouped list: poster, title, meta line, the stage track, + * and a status line. The trailing slot is Open (in the library), Retry (admin, + * failed), or a disclosure chevron. Swipe left or long-press to cancel while + * the request waits for approval. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun MyRequestRow( + record: MediaRequest, + onOpen: () -> Unit, + modifier: Modifier = Modifier, + isBusy: Boolean = false, + onOpenInLibrary: (() -> Unit)? = null, + onCancel: (() -> Unit)? = null, + onRetry: (() -> Unit)? = null, + actionPhase: RequestRowActionPhase? = null, + actionError: String? = null, + shakeTrigger: Int = 0, + showDivider: Boolean = false, +) { + val progress = RequestProgress.of(record) + val latestCancel by rememberUpdatedState(onCancel) + val dismissState = rememberSwipeToDismissBoxState( + confirmValueChange = { value -> + if (value == SwipeToDismissBoxValue.EndToStart) latestCancel?.invoke() + // The row leaves when the list re-reads; it never dismisses itself. + false + }, + ) + var menuOpen by remember { mutableStateOf(false) } + SwipeToDismissBox( + state = dismissState, + modifier = modifier, + enableDismissFromStartToEnd = false, + enableDismissFromEndToStart = onCancel != null && !isBusy, + backgroundContent = { + Box( + modifier = Modifier + .fillMaxSize() + .background(RequestColors.Rose) + .padding(horizontal = 20.dp), + contentAlignment = Alignment.CenterEnd, + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Icon(Icons.Filled.Close, contentDescription = null, tint = Color.White, modifier = Modifier.size(18.dp)) + Text("Cancel", color = Color.White, fontWeight = FontWeight.SemiBold, fontSize = 14.sp) + } + } + }, + ) { + Column(modifier = Modifier.background(PrairieSurfaceContainer)) { + if (showDivider) HorizontalDivider(modifier = Modifier.padding(start = 74.dp), color = PrairieBorder.copy(alpha = 0.55f)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier + .weight(1f) + .alpha(if (isBusy || actionPhase != null) 0.45f else 1f) + .combinedClickable( + enabled = !isBusy, + onClick = onOpen, + onLongClick = if (onCancel != null) ({ menuOpen = true }) else null, + ), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RequestRowPoster(record.posterPath) + Column(modifier = Modifier.weight(1f)) { + Text( + text = record.title, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = PrairieOnSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = RequestRowCopy.meta(record, progress), + fontSize = 12.sp, + color = PrairieSecondaryText, + maxLines = 1, + modifier = Modifier.padding(top = 2.dp), + ) + RequestStageTrack(progress = progress, modifier = Modifier.padding(top = 9.dp)) + AnimatedContent( + targetState = actionError, + transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) }, + label = "rowStatus", + modifier = Modifier.padding(top = 6.dp), + ) { error -> + if (error != null) { + RequestRowErrorLine(text = error) + } else { + RequestStatusLabel( + progress = progress, + text = RequestRowCopy.status(record, progress), + fontWeight = FontWeight.SemiBold, + color = PrairieOnSurface, + maxLines = 2, + ) + } + } + } + } + when { + onRetry != null -> RequestRowActionButton( + action = AdminRequestAction.Retry, + phase = actionPhase, + shakeTrigger = shakeTrigger, + enabled = !isBusy, + onClick = onRetry, + ) + onOpenInLibrary != null && progress.display == RequestDisplayState.InLibrary -> + RequestCapsuleButton(title = "Open", icon = Icons.AutoMirrored.Filled.OpenInNew, onClick = onOpenInLibrary) + else -> Text(text = "›", fontSize = 20.sp, color = PrairieSecondaryText.copy(alpha = 0.7f)) + } + } + PrairieDropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + PrairieDropdownMenuItem( + text = { Text("Cancel Request", color = RequestColors.Rose) }, + leadingIcon = { Icon(Icons.Filled.Close, contentDescription = null, tint = RequestColors.Rose) }, + onClick = { + menuOpen = false + onCancel?.invoke() + }, + ) + } + } + } +} + +/** An admin's view of someone's pending request, with inline decisions. */ +@Composable +internal fun RequestApprovalRow( + record: MediaRequest, + onOpen: () -> Unit, + onApprove: () -> Unit, + onDecline: () -> Unit, + modifier: Modifier = Modifier, + isBusy: Boolean = false, + phase: RequestRowActionPhase? = null, + actionError: String? = null, + shakeTrigger: Int = 0, + showDivider: Boolean = false, +) { + Column(modifier = modifier.animateContentSize()) { + if (showDivider) HorizontalDivider(modifier = Modifier.padding(start = 74.dp), color = PrairieBorder.copy(alpha = 0.55f)) + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .alpha(if (phase != null) 0.45f else 1f) + .rowClickable(enabled = !isBusy, onClick = onOpen), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + RequestRowPoster(record.posterPath) + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + text = record.title + (record.year?.takeIf { it > 0 }?.let { " ($it)" } ?: ""), + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = PrairieOnSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + RequestRowCopy.day(record.createdAt)?.let { + Text(text = "Requested $it", fontSize = 12.sp, color = PrairieSecondaryText) + } + Text(text = RequestRowCopy.kindAndQuality(record), fontSize = 12.sp, color = PrairieSecondaryText) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + RequestRowActionButton( + action = AdminRequestAction.Approve, + phase = phase, + shakeTrigger = shakeTrigger, + style = RequestRowButtonStyle.Prominent, + enabled = !isBusy, + onClick = onApprove, + ) + RequestRowActionButton( + action = AdminRequestAction.Decline, + phase = phase, + shakeTrigger = shakeTrigger, + style = RequestRowButtonStyle.Wide, + enabled = !isBusy, + onClick = onDecline, + ) + } + if (actionError != null) RequestRowErrorLine(text = actionError) + } + } +} + +private fun Modifier.rowClickable(enabled: Boolean, onClick: () -> Unit): Modifier = + clip(RoundedCornerShape(8.dp)).combinedClickable(enabled = enabled, onClick = onClick) + +@Composable +internal fun RequestRowPoster(path: String?) { + Box( + modifier = Modifier + .size(width = 46.dp, height = 69.dp) + .clip(RoundedCornerShape(6.dp)) + .background(PrairieSurfaceElevated), + ) { + requestPosterUrl(path)?.let { url -> + ThumbhashImage( + url = url, + thumbhash = null, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.matchParentSize(), + ) + } + } +} + +@Composable +internal fun RequestCapsuleButton(title: String, icon: ImageVector, onClick: () -> Unit) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(RequestChromeSelectedFill) + .rowClickable(enabled = true, onClick = onClick) + .padding(horizontal = 12.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon(icon, contentDescription = null, tint = PrairieOnSurface, modifier = Modifier.size(14.dp)) + Text(title, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = PrairieOnSurface) + } +} + +internal enum class RequestRowButtonStyle { + /** Compact capsule at the row's trailing edge (Retry). */ + Trailing, + /** Full-width white capsule (Approve). */ + Prominent, + /** Full-width quiet capsule (Decline). */ + Wide, +} + +/** + * An admin action button that shows its own progress: Retry's arrow spins + * while the request is in flight (the others swap their icon for a spinner), + * the icon turns into a check when the server accepts it, and the button + * shakes when it fails. Buttons for the other actions on the row step back. + */ +@Composable +internal fun RowScope.RequestRowActionButton( + action: AdminRequestAction, + phase: RequestRowActionPhase?, + onClick: () -> Unit, + shakeTrigger: Int = 0, + style: RequestRowButtonStyle = RequestRowButtonStyle.Trailing, + enabled: Boolean = true, +) { + val isWorking = phase == RequestRowActionPhase.Working(action) + val isDone = phase == RequestRowActionPhase.Succeeded(action) + val isSidelined = phase != null && !isWorking && !isDone + val shake = remember { Animatable(0f) } + LaunchedEffect(shakeTrigger) { + if (shakeTrigger == 0) return@LaunchedEffect + for (offset in listOf(-7f, 6f, -4f, 0f)) shake.animateTo(offset, tween(70)) + } + val sidelineAlpha by animateFloatAsState(if (isSidelined) 0.35f else 1f, tween(250), label = "sideline") + val foreground = if (style == RequestRowButtonStyle.Prominent) Color.Black else PrairieOnSurface + val background = if (style == RequestRowButtonStyle.Prominent) Color.White else RequestChromeSelectedFill + val title = when (action) { + AdminRequestAction.Approve -> if (isWorking) "Approving…" else if (isDone) "Approved" else "Approve" + AdminRequestAction.Decline -> if (isWorking) "Declining…" else if (isDone) "Declined" else "Decline" + AdminRequestAction.Retry -> if (isWorking) "Retrying…" else if (isDone) "Retried" else "Retry" + } + val idleIcon = when (action) { + AdminRequestAction.Approve -> Icons.Filled.Check + AdminRequestAction.Decline -> Icons.Filled.Close + AdminRequestAction.Retry -> Icons.Filled.Refresh + } + val wide = style != RequestRowButtonStyle.Trailing + Row( + modifier = (if (wide) Modifier.weight(1f).heightIn(min = 38.dp) else Modifier) + .graphicsLayer { + translationX = shake.value * density + alpha = sidelineAlpha + } + .clip(CircleShape) + .background(background) + .rowClickable(enabled = enabled && phase == null, onClick = onClick) + .padding(horizontal = 12.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), + ) { + Box(modifier = Modifier.size(16.dp), contentAlignment = Alignment.Center) { + when { + isDone -> Icon( + Icons.Filled.Check, + contentDescription = null, + tint = if (style == RequestRowButtonStyle.Prominent) Color.Black else RequestColors.Emerald, + modifier = Modifier.size(16.dp), + ) + isWorking && action == AdminRequestAction.Retry -> { + val spin = rememberInfiniteTransition(label = "retrySpin") + val angle by spin.animateFloat(0f, 360f, infiniteRepeatable(tween(900, easing = LinearEasing), RepeatMode.Restart), label = "angle") + Icon(idleIcon, contentDescription = null, tint = foreground, modifier = Modifier.size(16.dp).rotate(angle)) + } + isWorking -> CircularProgressIndicator(color = foreground, strokeWidth = 2.dp, modifier = Modifier.size(14.dp)) + else -> Icon(idleIcon, contentDescription = null, tint = foreground, modifier = Modifier.size(16.dp)) + } + } + Text( + text = title, + fontSize = if (wide) 14.sp else 13.sp, + fontWeight = if (style == RequestRowButtonStyle.Prominent) FontWeight.Bold else FontWeight.SemiBold, + color = foreground, + maxLines = 1, + ) + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/requests/RequestsLargeTitlePage.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/requests/RequestsLargeTitlePage.kt new file mode 100644 index 0000000000..e3232285c5 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/requests/RequestsLargeTitlePage.kt @@ -0,0 +1,108 @@ +package org.prairieserver.prairie.android.ui.screens.requests + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.prairieserver.prairie.android.ui.theme.prairiePageBackdrop +import org.prairieserver.prairie.common.ui.components.DeferImagePresentationWhileScrolling + +/** Height of the pinned bar below the status bar. */ +private val BarHeight = 56.dp + +/** Scroll distance over which the large title hands off to the bar's title. */ +private val TitleHandoff = 44.dp + +/** + * A pushed page with a large title, as the Apple clients draw Requests: the + * title scrolls away under a pinned bar that keeps the back disc and gains the + * compact title. Pull to refresh reloads the page. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun RequestsLargeTitlePage( + title: String, + onBackClick: () -> Unit, + isRefreshing: Boolean, + onRefresh: () -> Unit, + modifier: Modifier = Modifier, + listState: LazyListState = rememberLazyListState(), + /** Gap between list items; grouped-row pages pass zero and space their own sections. */ + itemSpacing: Dp = 24.dp, + actions: @Composable () -> Unit = {}, + content: LazyListScope.() -> Unit, +) { + val density = LocalDensity.current + val topInset = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + BarHeight + val bottomInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + val titleProgress by remember(listState, density) { + derivedStateOf { + if (listState.firstVisibleItemIndex > 0) { + 1f + } else { + val handoff = with(density) { TitleHandoff.toPx() } + (listState.firstVisibleItemScrollOffset / handoff).coerceIn(0f, 1f) + } + } + } + val pullState = rememberPullToRefreshState() + Box(modifier = modifier.fillMaxSize().prairiePageBackdrop()) { + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = onRefresh, + state = pullState, + modifier = Modifier.fillMaxSize(), + indicator = { + PullToRefreshDefaults.Indicator( + state = pullState, + isRefreshing = isRefreshing, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = topInset), + ) + }, + ) { + DeferImagePresentationWhileScrolling(listState) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(top = topInset, bottom = 24.dp + bottomInset), + verticalArrangement = Arrangement.spacedBy(itemSpacing), + ) { + item(key = "large-title", contentType = "large-title") { + RequestsLargeTitle(title = title) + } + content() + } + } + } + RequestsLargeTitleBar( + title = title, + titleProgress = titleProgress, + onBackClick = onBackClick, + actions = actions, + ) + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/requests/RequestsScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/requests/RequestsScreen.kt index 2f2e73761e..ffe1e3ff72 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/requests/RequestsScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/requests/RequestsScreen.kt @@ -1,375 +1,226 @@ package org.prairieserver.prairie.android.ui.screens.requests +import androidx.compose.ui.unit.Dp +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints 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.fillMaxSize +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.outlined.ErrorOutline -import androidx.compose.material.icons.outlined.Movie -import androidx.compose.material3.Button -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold +import androidx.compose.material.icons.outlined.AutoAwesome +import androidx.compose.material.icons.outlined.HowToReg +import androidx.compose.material.icons.outlined.Search import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp -import org.prairieserver.prairie.android.ui.components.PrairieTopBar -import org.prairieserver.prairie.android.ui.components.EmptyStateView -import org.prairieserver.prairie.android.ui.components.LoadingIndicator -import org.prairieserver.prairie.common.ui.components.DeferImagePresentationWhileScrolling -import org.prairieserver.prairie.model.request.RequestMediaResult -import org.prairieserver.prairie.model.request.RequestMediaType -import org.prairieserver.prairie.viewmodel.RequestSearchViewModel -import org.prairieserver.prairie.viewmodel.RequestsViewModel +import androidx.compose.ui.unit.sp +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import androidx.compose.material.icons.filled.Refresh import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import org.prairieserver.prairie.android.ui.components.EmptyStateView +import org.prairieserver.prairie.android.ui.components.ErrorView +import org.prairieserver.prairie.android.ui.components.MediaGridDefaults +import org.prairieserver.prairie.android.ui.components.rememberShimmerProgress +import org.prairieserver.prairie.android.ui.components.skeleton +import org.prairieserver.prairie.android.ui.theme.PrairieSecondaryText +import org.prairieserver.prairie.common.requests.RequestColors +import org.prairieserver.prairie.common.requests.RequestRouter +import org.prairieserver.prairie.common.requests.rememberRequestRouter +import org.prairieserver.prairie.model.request.RequestMediaResult +import org.prairieserver.prairie.model.request.RequestStatusTint +import org.prairieserver.prairie.viewmodel.RequestsUiState +import org.prairieserver.prairie.viewmodel.RequestsViewModel -@OptIn(ExperimentalMaterial3Api::class) +/** + * The Requests hub, in the same grammar as the library pages: search TMDB to + * request, a status summary and the user's own requests one glance down, then + * the discover carousels. + */ @Composable fun RequestsScreen( onBackClick: () -> Unit, onMyRequestsClick: () -> Unit, - onMediaClick: (RequestMediaResult) -> Unit, + onApprovalsClick: () -> Unit, + onRequestDetailClick: (mediaType: String, tmdbId: Int) -> Unit, onLibraryItemClick: (String) -> Unit, viewModel: RequestsViewModel = koinViewModel(), - searchViewModel: RequestSearchViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() - val searchState by searchViewModel.uiState.collectAsState() - val hasSubmittedQuery = searchState.hasSubmittedQuery - val hasSearchResults = searchState.results.isNotEmpty() - val showDiscoverRows = !hasSubmittedQuery || - (searchState.error != null && state.sections.any { it.results.isNotEmpty() }) + val router = rememberRequestRouter(koinInject(), onLibraryItemClick, onRequestDetailClick) - Scaffold( - topBar = { - PrairieTopBar( - title = "Requests", - onBackClick = onBackClick, - actions = { - TextButton(onClick = onMyRequestsClick) { - Text("My Requests") - } - }, + RequestsLargeTitlePage( + title = "Requests", + onBackClick = onBackClick, + isRefreshing = state.isRefreshing, + onRefresh = viewModel::refresh, + ) { + item(key = "search", contentType = "search") { + RequestsSearchField( + query = state.query, + onQueryChange = viewModel::onQueryChanged, + onSearch = viewModel::submitSearch, ) - }, - containerColor = MaterialTheme.colorScheme.background, - ) { padding -> + } when { - state.isLoading && state.sections.isEmpty() -> { - LoadingIndicator( - modifier = Modifier - .fillMaxSize() - .padding(padding), - ) - } - state.error != null && state.sections.isEmpty() && !state.isEnabled && !hasSubmittedQuery -> { - RequestErrorState( - message = state.error ?: "Requests are unavailable.", - onRetry = viewModel::load, - modifier = Modifier.padding(padding), - ) + !state.isShowingDiscover -> searchResults(state, router::openResult) + state.error != null -> item(key = "error") { + ErrorView(message = state.error.orEmpty(), onRetry = viewModel::load, modifier = Modifier.padding(top = 60.dp)) } - else -> { - PullToRefreshBox( - isRefreshing = state.isRefreshing, - onRefresh = viewModel::refresh, - modifier = Modifier - .fillMaxSize() - .padding(padding), - ) { - val feedState = rememberLazyListState() - DeferImagePresentationWhileScrolling(feedState) { - LazyColumn( - state = feedState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(20.dp), - ) { - item(contentType = "request-search") { - RequestSearchPanel( - query = searchState.query, - selectedType = searchState.mediaType, - isLoading = searchState.isLoading, - error = searchState.error, - totalResults = searchState.totalResults, - results = searchState.results, - hasSubmittedQuery = hasSubmittedQuery, - onQueryChange = searchViewModel::onQueryChanged, - onTypeChange = { type -> - searchViewModel.onMediaTypeChanged(type) - if (searchState.query.isNotBlank()) { - searchViewModel.search() - } - }, - onSearch = { searchViewModel.search() }, - onMediaClick = onMediaClick, - onLibraryItemClick = onLibraryItemClick, - ) - } - - state.error?.let { error -> - item(contentType = "request-error") { - Text( - text = error, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.fillMaxWidth(), - ) - } - } - - if (state.sections.isEmpty() && !hasSubmittedQuery) { - item(contentType = "request-empty") { - EmptyStateView( - title = "No request suggestions yet", - subtitle = "Search for movies or series to request.", - icon = Icons.Outlined.Movie, - ) - } - } else if (showDiscoverRows) { - items( - state.sections, - key = { it.key }, - contentType = { "request-section" }, - ) { section -> - RequestSectionRow( - title = section.title, - items = section.results, - onMediaClick = onMediaClick, - onLibraryItemClick = onLibraryItemClick, - ) - } - } - } - } + state.isLoading -> item(key = "loading") { + val progress = rememberShimmerProgress() + Column(verticalArrangement = Arrangement.spacedBy(24.dp)) { + RequestRailSkeleton(progress, title = "Your requests") + RequestRailSkeleton(progress) } } + state.myRequests.isEmpty() && state.sections.isEmpty() -> item(key = "empty") { + EmptyStateView( + title = "Nothing here yet", + subtitle = "Search for a movie or series to request it", + icon = Icons.Outlined.AutoAwesome, + modifier = Modifier.padding(top = 80.dp), + ) + } + else -> discover(state, router, onMyRequestsClick, onApprovalsClick) } } } -@Composable -private fun RequestSearchPanel( - query: String, - selectedType: String?, - isLoading: Boolean, - error: String?, - totalResults: Int, - results: List, - hasSubmittedQuery: Boolean, - onQueryChange: (String) -> Unit, - onTypeChange: (String?) -> Unit, - onSearch: () -> Unit, - onMediaClick: (RequestMediaResult) -> Unit, - onLibraryItemClick: (String) -> Unit, +private fun LazyListScope.discover( + state: RequestsUiState, + router: RequestRouter, + onMyRequestsClick: () -> Unit, + onApprovalsClick: () -> Unit, ) { - val summary = requestSearchSummary( - query = query, - totalResults = totalResults, - isLoading = isLoading, - hasSubmittedQuery = hasSubmittedQuery, - hasResults = results.isNotEmpty(), - error = error, - ) - - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - text = "Search", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - ) - OutlinedTextField( - value = query, - onValueChange = onQueryChange, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Movie or series title") }, - leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, - singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), - keyboardActions = KeyboardActions(onSearch = { onSearch() }), - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - RequestFilterChip( - selected = selectedType == RequestMediaType.All, - label = "All", - onClick = { onTypeChange(RequestMediaType.All) }, - ) - RequestFilterChip( - selected = selectedType == RequestMediaType.Movie, - label = "Movies", - onClick = { onTypeChange(RequestMediaType.Movie) }, - ) - RequestFilterChip( - selected = selectedType == RequestMediaType.Series, - label = "Series", - onClick = { onTypeChange(RequestMediaType.Series) }, - ) - } - Button(onClick = onSearch, enabled = !isLoading) { - Icon( - imageVector = Icons.Default.Search, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(if (isLoading) "Searching..." else "Search") - } - error?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) + val moving = state.inProgressCount + state.needsAttentionCount + if (moving > 0 || state.pendingApprovals > 0) { + item(key = "summary", contentType = "summary") { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + if (moving > 0) { + RequestSummaryCard(title = summaryTitle(state), parts = summaryParts(state), onClick = onMyRequestsClick) + } + if (state.pendingApprovals > 0) { + RequestSummaryCard( + title = when { + state.morePendingApprovals -> "${state.pendingApprovals}+ requests need your approval" + state.pendingApprovals == 1 -> "1 request needs your approval" + else -> "${state.pendingApprovals} requests need your approval" + }, + leadingIcon = Icons.Outlined.HowToReg, + leadingTint = RequestColors.Amber, + onClick = onApprovalsClick, + ) + } + } } - summary?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + } + if (state.myRequests.isNotEmpty()) { + item(key = "mine", contentType = "rail") { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + RequestsSectionHeader(title = "Your requests", trailing = "See all", onTrailingClick = onMyRequestsClick) + RequestCardRail(items = state.myRequests, key = { it.id }) { record -> + RequestRecordCard(record = record, onClick = { router.openRecord(record) }) + } + } } - if (results.isNotEmpty()) { - RequestMediaRow( - items = results, - onMediaClick = onMediaClick, - onLibraryItemClick = onLibraryItemClick, - ) + } + state.sections.forEachIndexed { index, section -> + item(key = "discover:${section.key}", contentType = "rail") { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + RequestsSectionHeader(title = section.title, label = if (index == 0) "Discover" else null) + RequestCardRail(items = section.results, key = { "${it.mediaType}:${it.tmdbId}" }) { result -> + RequestMediaCard(item = result, onClick = { router.openResult(result) }) + } + } } } } -@Composable -private fun RequestSectionRow( - title: String, - items: List, - onMediaClick: (RequestMediaResult) -> Unit, - onLibraryItemClick: (String) -> Unit, -) { - if (items.isEmpty()) return - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - text = title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - ) - RequestMediaRow( - items = items, - onMediaClick = onMediaClick, - onLibraryItemClick = onLibraryItemClick, - ) +private fun summaryTitle(state: RequestsUiState): String { + val count = state.inProgressCount + return when (count) { + 0 -> "Your requests need you" + 1 -> "1 request in progress" + else -> "$count requests in progress" } } -@Composable -private fun RequestMediaRow( - items: List, - onMediaClick: (RequestMediaResult) -> Unit, - onLibraryItemClick: (String) -> Unit, -) { - val rowState = rememberLazyListState() - DeferImagePresentationWhileScrolling(rowState) { - LazyRow( - state = rowState, - horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(end = 16.dp), - ) { - items( - items, - key = { "${it.mediaType}-${it.tmdbId}" }, - contentType = { item -> item.mediaType }, - ) { item -> - RequestMediaCard( - item = item, - onClick = { - val libraryId = item.libraryContentId?.takeIf { it.isNotBlank() } - if (libraryId != null) { - onLibraryItemClick(libraryId) - } else { - onMediaClick(item) - } - }, +private fun summaryParts(state: RequestsUiState): List> = buildList { + if (state.onTheWayCount > 0) add(RequestStatusTint.Sky to "${state.onTheWayCount} on the way") + if (state.pendingCount > 0) add(RequestStatusTint.Amber to "${state.pendingCount} pending") + if (state.needsAttentionCount > 0) add(RequestStatusTint.Rose to "${state.needsAttentionCount} need you") +} + +/** Library search's grid: adaptive columns from the card presentation, placeholders in the same cells while searching. */ +private fun LazyListScope.searchResults(state: RequestsUiState, onOpen: (RequestMediaResult) -> Unit) { + when { + state.isSearching && state.searchResults.isEmpty() -> item(key = "search-loading") { + SearchGrid(count = 9) { width, _ -> + val progress = rememberShimmerProgress() + Column(verticalArrangement = Arrangement.spacedBy(7.dp)) { + Box( + modifier = Modifier.width(width).aspectRatio(RequestPosterAspect).skeleton(progress), + ) + Box( + modifier = Modifier.width(width * 0.7f).height(10.dp).skeleton(progress, RoundedCornerShape(3.dp)), + ) + } + } + } + state.hasSearched && state.searchResults.isEmpty() -> item(key = "search-empty") { + EmptyStateView( + title = "No matches", + subtitle = "Nothing on TMDB matched that search", + icon = Icons.Outlined.Search, + modifier = Modifier.padding(top = 80.dp), ) } - } + else -> { + item(key = "search-count") { + Text( + text = "${state.searchTotal} result${if (state.searchTotal == 1) "" else "s"}", + fontSize = 12.sp, + color = PrairieSecondaryText, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + item(key = "search-grid") { + SearchGrid(count = state.searchResults.size) { width, index -> + val result = state.searchResults[index] + RequestMediaCard(item = result, width = width, onClick = { onOpen(result) }) + } + } + } } } @Composable -private fun RequestErrorState( - message: String, - onRetry: () -> Unit, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.Center, - ) { - Icon( - imageVector = Icons.Outlined.ErrorOutline, - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = message, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(modifier = Modifier.height(20.dp)) - Button(onClick = onRetry) { - Icon( - imageVector = Icons.Default.Refresh, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Retry") +private fun SearchGrid(count: Int, cell: @Composable (width: Dp, index: Int) -> Unit) { + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val columns = requestGridColumns(maxWidth, MediaGridDefaults.scaledPosterGridMinWidth) + val spacing = MediaGridDefaults.PosterGridHorizontalSpacing + val cellWidth = (maxWidth - 32.dp - spacing * (columns - 1)) / columns + Column( + modifier = Modifier.padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(MediaGridDefaults.PosterGridVerticalSpacing), + ) { + (0 until count).chunked(columns).forEach { row -> + Row(horizontalArrangement = Arrangement.spacedBy(spacing)) { + row.forEach { index -> cell(cellWidth, index) } + } + } } } } - -private fun requestSearchSummary( - query: String, - totalResults: Int, - isLoading: Boolean, - hasSubmittedQuery: Boolean, - hasResults: Boolean, - error: String?, -): String? = when { - query.isBlank() -> "Search movies and series to request them." - isLoading -> "Searching..." - error != null -> null - hasSubmittedQuery && !hasResults -> "No matches for \"$query\"." - hasResults && totalResults == 1 -> "1 result" - hasResults -> "$totalResults results" - else -> null -} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/search/RequestSearchSection.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/search/RequestSearchSection.kt index 56ecfd99f1..ae7c8f27da 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/search/RequestSearchSection.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/search/RequestSearchSection.kt @@ -21,8 +21,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.prairieserver.prairie.android.ui.screens.requests.RequestMediaCard +import org.prairieserver.prairie.common.requests.rememberRequestRouter import org.prairieserver.prairie.common.ui.components.DeferImagePresentationWhileScrolling import org.prairieserver.prairie.model.request.RequestMediaResult import org.prairieserver.prairie.model.request.RequestMediaType @@ -33,12 +35,13 @@ fun RequestSearchSection( query: String, selectedMediaType: MobileSearchMediaType, requestsEnabled: Boolean, - onRequestMediaClick: (RequestMediaResult) -> Unit, + onRequestMediaClick: (mediaType: String, tmdbId: Int) -> Unit, onRequestLibraryItemClick: (String) -> Unit, modifier: Modifier = Modifier, viewModel: RequestSearchViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() + val router = rememberRequestRouter(koinInject(), onRequestLibraryItemClick, onRequestMediaClick) val trimmedQuery = query.trim() val requestMediaType = selectedMediaType.toRequestMediaType() val visibleResults = state.results.filter { result -> @@ -92,14 +95,7 @@ fun RequestSearchSection( ) { item -> RequestMediaCard( item = item, - onClick = { - val libraryId = item.libraryContentId?.takeIf { it.isNotBlank() } - if (libraryId != null) { - onRequestLibraryItemClick(libraryId) - } else { - onRequestMediaClick(item) - } - }, + onClick = { router.openResult(item) }, ) } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/search/SearchResults.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/search/SearchResults.kt index bab94c7019..f60e73bb9f 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/search/SearchResults.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/search/SearchResults.kt @@ -1,5 +1,8 @@ package org.prairieserver.prairie.android.ui.screens.search +import org.prairieserver.prairie.common.cards.LocalEpisodeSpoilerPrefs +import org.prairieserver.prairie.model.settings.EpisodeSpoilers + import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues @@ -126,8 +129,13 @@ fun SearchResults( key = { it.contentId }, contentType = { item -> item.type }, ) { item -> - val (actions, userState) = rememberBrowseItemCardActions(item) + val (actions, userState, positionSeconds) = rememberBrowseItemCardActions(item) + val spoilerPrefs = LocalEpisodeSpoilerPrefs.current + val hideArtwork = EpisodeSpoilers.hidesBrowseArtwork(item, spoilerPrefs, userState.played, positionSeconds = positionSeconds) + val hideDetailArtwork = EpisodeSpoilers.hidesBrowseArtwork(item, spoilerPrefs, userState.played, selectBackdrop = true, positionSeconds = positionSeconds) MediaCard( + hideArtwork = hideArtwork, + hideDetailArtwork = hideDetailArtwork, title = item.title, posterUrl = item.posterUrl, posterThumbhash = item.posterThumbhash, diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/search/SearchScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/search/SearchScreen.kt index e8fd401fcc..8a74d3331e 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/search/SearchScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/search/SearchScreen.kt @@ -38,10 +38,12 @@ import org.prairieserver.prairie.model.navigation.MediaMode import org.prairieserver.prairie.model.navigation.MediaModeCapabilities import org.prairieserver.prairie.model.navigation.mobileMediaModeCapabilities import org.prairieserver.prairie.model.feature.RequestsFeatureStore -import org.prairieserver.prairie.model.request.RequestMediaResult import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.repository.PersonalDataRepository import org.koin.compose.koinInject +import org.prairieserver.prairie.common.ui.OnViewerAccessChanged +import org.prairieserver.prairie.common.ui.rememberViewerAccessKey +import org.prairieserver.prairie.network.AccessChangeSignals /** * The search screen with a search bar and results grid. @@ -57,7 +59,7 @@ import org.koin.compose.koinInject fun SearchScreen( onItemClick: (String) -> Unit, onPersonClick: (Long) -> Unit, - onRequestMediaClick: (RequestMediaResult) -> Unit, + onRequestMediaClick: (mediaType: String, tmdbId: Int) -> Unit, onRequestLibraryItemClick: (String) -> Unit, onBackClick: (() -> Unit)? = null, viewModel: SearchViewModel, @@ -70,6 +72,11 @@ fun SearchScreen( val personalDataRepository: PersonalDataRepository = koinInject() val requestsFeatureStore: RequestsFeatureStore = koinInject() val requestsEnabled by requestsFeatureStore.isEnabled.collectAsState() + // An access change can add or remove libraries and titles: re-derive the + // media modes and re-run the current query under the new policy. + val accessChangeSignals: AccessChangeSignals = koinInject() + val viewerAccessKey = rememberViewerAccessKey(accessChangeSignals) + OnViewerAccessChanged(accessChangeSignals, viewModel.accessChanges) { viewModel.retry() } val availableModes by produceState( initialValue = MediaModeCapabilities( listOf( @@ -79,6 +86,7 @@ fun SearchScreen( ), ).mobileModes(), personalDataRepository, + viewerAccessKey, ) { value = when (val result = personalDataRepository.listUserLibraries()) { is ApiResult.Success -> result.data.mobileMediaModeCapabilities().mobileModes() diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/search/SearchViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/search/SearchViewModel.kt index 82ceae6915..fd816c0cd2 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/search/SearchViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/search/SearchViewModel.kt @@ -128,6 +128,8 @@ data class SearchUiState( class SearchViewModel( private val catalogRepository: CatalogRepository, ) : ViewModel() { + /** Access changes this ViewModel has applied, kept while its screen is away. */ + val accessChanges = org.prairieserver.prairie.network.AccessChangeCursor() private val _uiState = MutableStateFlow(SearchUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -136,6 +138,7 @@ class SearchViewModel( private var continuation: CatalogContinuationV2? = null private var peopleJob: Job? = null + private var searchGeneration = 0L private val pageSize = 60 /** @@ -304,6 +307,10 @@ class SearchViewModel( } private suspend fun performSearch(query: String, reset: Boolean) { + // A new search makes every earlier one's answer obsolete, even for the + // same query: a re-run after an access change must not be overwritten + // by the older request's results. Loading more continues the newest. + val generation = if (reset) ++searchGeneration else searchGeneration val currentState = _uiState.value val requestedMediaType = currentState.mediaType var offset = if (reset) 0 else currentState.nextOffset @@ -325,7 +332,7 @@ class SearchViewModel( limit = pageSize, ) val latest = _uiState.value - if (latest.query != query || latest.mediaType != requestedMediaType) return + if (generation != searchGeneration || latest.query != query || latest.mediaType != requestedMediaType) return when (result) { is ApiResult.Success -> { diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/servers/ServerListScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/servers/ServerListScreen.kt index 27c55769a6..6ef9ea2522 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/servers/ServerListScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/servers/ServerListScreen.kt @@ -1,5 +1,11 @@ package org.prairieserver.prairie.android.ui.screens.servers +import org.prairieserver.prairie.android.ui.components.PrairieDropdownMenuItem +import org.prairieserver.prairie.android.ui.components.PrairieDropdownMenu +import org.prairieserver.prairie.android.ui.components.PrairieDialogActionStyle +import org.prairieserver.prairie.android.ui.components.PrairieDialogAction +import org.prairieserver.prairie.android.ui.components.PrairieDialog +import org.prairieserver.prairie.android.ui.components.PrairieConfirmDialog import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -28,10 +34,7 @@ import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Storage -import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -39,7 +42,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable @@ -62,7 +64,6 @@ import org.prairieserver.prairie.discovery.DiscoveryHit import org.prairieserver.prairie.discovery.normalizeDiscoveryUrl import org.prairieserver.prairie.model.server.ServerEntry import org.koin.compose.viewmodel.koinViewModel -import androidx.compose.material.icons.filled.Close /** * Server list — first-run connect (scan + branding) and multi-server management. @@ -266,31 +267,15 @@ fun ServerListScreen( } removeTarget?.let { target -> - AlertDialog( - onDismissRequest = { removeTarget = null }, - title = { Text("Remove this server?") }, - text = { - Text( - "Sign-in credentials for ${target.displayName} will be " + - "forgotten on this device.", - ) - }, - confirmButton = { - TextButton(onClick = { - viewModel.onRemove(target.id) - removeTarget = null - }) { Text("Remove", color = MaterialTheme.colorScheme.error) } + PrairieConfirmDialog( + title = "Remove this server?", + body = "Sign-in credentials for ${target.displayName} will be forgotten on this device.", + confirmLabel = "Remove", + onConfirm = { + viewModel.onRemove(target.id) + removeTarget = null }, - dismissButton = { - TextButton(onClick = { removeTarget = null }) { Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Cancel") } - }, - containerColor = MaterialTheme.colorScheme.primaryContainer, + onDismiss = { removeTarget = null }, ) } } @@ -462,11 +447,11 @@ private fun ServerRow( modifier = Modifier.size(20.dp), ) } - DropdownMenu( + PrairieDropdownMenu( expanded = menuExpanded, onDismissRequest = { menuExpanded = false }, ) { - DropdownMenuItem( + PrairieDropdownMenuItem( text = { Text("Rename") }, onClick = { menuExpanded = false @@ -474,7 +459,7 @@ private fun ServerRow( }, leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) }, ) - DropdownMenuItem( + PrairieDropdownMenuItem( text = { Text("Remove Server") }, onClick = { menuExpanded = false @@ -548,48 +533,24 @@ private fun RenameDialog( ) { var input by remember { mutableStateOf(entry.userOverrideName ?: entry.fetchedName.orEmpty()) } val hasOverride = entry.userOverrideName != null - AlertDialog( + PrairieDialog( + title = "Rename server", + message = "Override the server-provided name with a label just for this device.", onDismissRequest = onDismiss, - title = { Text("Rename server") }, - text = { - Column { - Text( - text = "Override the server-provided name with a label just " + - "for this device.", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(12.dp)) - OutlinedTextField( - value = input, - onValueChange = { input = it }, - label = { Text("Name") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - } - }, - confirmButton = { - TextButton(onClick = { onConfirm(input) }) { Text("Save") } - }, - dismissButton = { + actions = buildList { + add(PrairieDialogAction("Save", { onConfirm(input) }, PrairieDialogActionStyle.Primary)) if (hasOverride) { - TextButton(onClick = { onConfirm("") }) { - Text( - "Reset to server-provided name", - color = MaterialTheme.colorScheme.error, - ) - } - } else { - TextButton(onClick = onDismiss) { Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Cancel") } + add(PrairieDialogAction("Reset to server-provided name", { onConfirm("") })) } + add(PrairieDialogAction("Cancel", onDismiss)) }, - containerColor = MaterialTheme.colorScheme.primaryContainer, - ) + ) { + OutlinedTextField( + value = input, + onValueChange = { input = it }, + label = { Text("Name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/servers/ServerListViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/servers/ServerListViewModel.kt index 6904237bd9..c4fb8d13f1 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/servers/ServerListViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/servers/ServerListViewModel.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.prairieserver.prairie.android.auth.ActiveProfileReset /** * Where to land after a successful server switch or discovery connect. The list @@ -52,6 +53,7 @@ class ServerListViewModel( private val tokenManager: TokenManager, private val authRepository: AuthRepository, private val lanDiscovery: LanDiscovery, + private val activeProfileReset: ActiveProfileReset, ) : ViewModel() { private val _uiState = MutableStateFlow(ServerListUiState()) @@ -207,6 +209,10 @@ class ServerListViewModel( val destination = when { accessToken.isNullOrBlank() -> ServerSwitchDestination.Login profileId.isNullOrBlank() -> ServerSwitchDestination.ProfileSelection + // Profile Selection asks on a server switch as it does at + // launch, so a protected profile there needs its PIN. Cleared + // before navigating, so the picker loads under the new server. + activeProfileReset.clearIfSelectionRequired() -> ServerSwitchDestination.ProfileSelection else -> ServerSwitchDestination.Home } @@ -307,6 +313,16 @@ class ServerListViewModel( val promotedId = serverRegistry.activeServerId.value if (wasActive && promotedId != null) { authRepository.switchToServer(promotedId) + // A promotion is a server switch too: when Profile Selection + // asks, the promoted server's saved profile needs choosing + // (and its PIN) again, so leave for Who's Watching rather + // than back to a shell that would reopen it. Without a + // saved profile there is nothing to protect. + val hasProfile = !tokenManager.getAccessToken().isNullOrBlank() && + !(serverRegistry.activeEntry.value?.profileId ?: tokenManager.getProfileId()).isNullOrBlank() + if (hasProfile && activeProfileReset.clearIfSelectionRequired()) { + _uiState.update { it.copy(switchedTo = ServerSwitchDestination.ProfileSelection) } + } } } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/AccountSection.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/AccountSection.kt deleted file mode 100644 index a0950f7f94..0000000000 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/AccountSection.kt +++ /dev/null @@ -1,154 +0,0 @@ -package org.prairieserver.prairie.android.ui.screens.settings - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Person -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.unit.dp -import org.prairieserver.prairie.android.ui.components.SignOutConfirmDialog -import org.prairieserver.prairie.android.ui.theme.SettingsDimens -import org.prairieserver.prairie.android.ui.theme.SettingsTextStyles -import org.prairieserver.prairie.android.ui.theme.PrairieForeground -import org.prairieserver.prairie.android.ui.theme.PrairieMutedText -import org.prairieserver.prairie.android.ui.theme.Spacing -import org.prairieserver.prairie.model.auth.User - -/** - * Settings section showing user account info, device pairing, and sign out. - * - * Session management and the admin surface are deliberately absent: both were - * removed from the Android clients outright — phone, TV, and the shared code - * that served them — not merely hidden behind a gate. - */ -@Composable -fun AccountSection( - user: User?, - isLoadingUser: Boolean, - onPairDevice: () -> Unit, - onSignOut: () -> Unit, - modifier: Modifier = Modifier, - // iOS parity: the account header is a button that opens profile - // selection ("Tap to switch profile") — the chevron was previously dead. - onSwitchProfile: () -> Unit = {}, -) { - var confirmSignOut by rememberSaveable { mutableStateOf(false) } - - SettingsSectionCard(modifier = modifier) { - if (isLoadingUser) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(Spacing.xxl), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator( - modifier = Modifier.size(Spacing.xxl), - strokeWidth = 2.dp, - ) - } - } else if (user != null) { - // Claims the card's first row slot, so the row below it still - // draws its hairline. - val headerDivider = settingsRowDividerVisible() - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = SettingsDimens.rowMinHeight) - .settingsRowDivider(headerDivider) - .clickable(onClick = onSwitchProfile) - .padding( - horizontal = SettingsDimens.rowHorizontalPadding, - vertical = SettingsDimens.rowVerticalPadding, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - // Avatar — iOS ProfileAvatarView size 56. - Box( - modifier = Modifier - .size(SettingsDimens.avatarSize) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Default.Person, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(SettingsDimens.avatarIconSize), - ) - } - - Spacer(modifier = Modifier.width(SettingsDimens.avatarGap)) - - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(SettingsDimens.rowLabelGap), - ) { - Text( - text = user.username, - style = SettingsTextStyles.accountName, - color = PrairieForeground, - maxLines = 1, - ) - Text( - text = user.email, - style = SettingsTextStyles.rowDescription, - color = PrairieMutedText, - maxLines = 1, - ) - } - - Spacer(modifier = Modifier.width(SettingsDimens.rowTrailingGap)) - - SettingsRowChevron() - } - - SettingsNavigationRow( - label = "Quick Connect", - description = "Link a TV or another device to this account.", - onClick = onPairDevice, - ) - - SettingsDestructiveRow( - label = "Sign out", - description = "Sign this device out of ${user.username}'s account.", - onClick = { confirmSignOut = true }, - ) - - // Same dialog the profile menu raises, so the answer to "are you - // sure?" does not depend on which of the two routes was taken. - SignOutConfirmDialog( - visible = confirmSignOut, - accountName = user.username, - onConfirm = { - confirmSignOut = false - onSignOut() - }, - onDismiss = { confirmSignOut = false }, - ) - } - } -} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/EpisodeSpoilerSettings.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/EpisodeSpoilerSettings.kt new file mode 100644 index 0000000000..f728e1ff77 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/EpisodeSpoilerSettings.kt @@ -0,0 +1,38 @@ +package org.prairieserver.prairie.android.ui.screens.settings + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** + * Spoiler protection for unwatched episodes (see EpisodeSpoilers.MIN_CONTRACT_REVISION). Both + * switches are profile-wide, so the section says they follow the profile to + * every device. Shown only when the server supports the keys. + */ +@Composable +fun EpisodeSpoilerSettings( + hideImages: Boolean, + hideOverviews: Boolean, + onHideImagesChanged: (Boolean) -> Unit, + onHideOverviewsChanged: (Boolean) -> Unit, + modifier: Modifier = Modifier, + saveError: String? = null, +) { + SettingsSection(title = "Spoilers", modifier = modifier) { + SettingsSwitchRow( + label = "Blur unwatched episode images", + description = "Blur an episode's thumbnail until you start watching it, so the image does not give away the story.", + checked = hideImages, + onCheckedChange = onHideImagesChanged, + ) + SettingsSwitchRow( + label = "Hide unwatched episode descriptions", + description = "Hide an episode's description until you start watching it.", + checked = hideOverviews, + onCheckedChange = onHideOverviewsChanged, + ) + SettingsProse( + body = "Applies to episodes you have not started, on every device that uses this profile.", + ) + saveError?.let { SettingsProse(body = it) } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/MediaCardsSettings.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/MediaCardsSettings.kt index caaf33bc65..80d518fcda 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/MediaCardsSettings.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/MediaCardsSettings.kt @@ -12,8 +12,9 @@ import org.prairieserver.prairie.model.settings.CardPresentationPreset private const val CustomPresetLabel = "Custom" /** - * "Media Cards" section — the cross-client `ui.card_presentation` preference - * (poster size + caption style, plus the client-side presets over the pair). + * Interface → "Cards & Posters" (Apple `InterfaceCustomizationView`) — the + * cross-client `ui.card_presentation` preference (poster size + caption style, + * plus the client-side presets over the pair). * * Writes go to `profile_client` so the choice roams among this profile's * phones/tablets, unless "Only this device" pins a `profile_device` override. @@ -29,8 +30,18 @@ fun MediaCardsSettings( onUseProfileDefault: () -> Unit, modifier: Modifier = Modifier, ) { - SettingsSection(title = "Media Cards", modifier = modifier) { - if (state.support == CardPresentationSupport.Unsupported) { + val unsupported = state.support == CardPresentationSupport.Unsupported + SettingsSection( + title = "Cards & Posters", + footer = if (unsupported) { + null + } else { + "Start with Balanced, Compact, Cinema, or Artwork Only, then fine-tune size and captions. These " + + "choices sync with other phones and tablets on this profile unless Only This Device is on." + }, + modifier = modifier, + ) { + if (unsupported) { SettingsProse(body = "Update your Prairie server to customize media cards.") return@SettingsSection } @@ -43,7 +54,6 @@ fun MediaCardsSettings( listOfNotNull(CustomPresetLabel.takeIf { activePreset == null }) SettingsDropdownRow( label = "Preset", - description = "A starting point for poster size and captions.", value = activePreset?.displayName ?: CustomPresetLabel, options = presetOptions, onOptionSelected = { label -> @@ -54,8 +64,7 @@ fun MediaCardsSettings( ) SettingsDropdownRow( - label = "Poster size", - description = "How large posters render in rows and grids.", + label = "Poster Size", value = presentation.posterSize.displayName, options = CardPosterSize.entries.map { it.displayName }, onOptionSelected = { label -> @@ -67,7 +76,6 @@ fun MediaCardsSettings( SettingsDropdownRow( label = "Captions", - description = "What shows beneath each poster.", value = presentation.caption.displayName, options = CardCaption.entries.map { it.displayName }, onOptionSelected = { label -> @@ -79,24 +87,17 @@ fun MediaCardsSettings( val deviceOnly = state.source == CardPresentationSource.DeviceOverride SettingsSwitchRow( - label = "Only this device", - description = "Keep these choices on this device instead of syncing them.", + label = "Only This Device", checked = deviceOnly, onCheckedChange = onDeviceOnlyChanged, ) if (!deviceOnly && state.source == CardPresentationSource.ClientFamily) { SettingsNavigationRow( - label = "Use profile default", - description = "Clear this device family's choice and follow the profile.", + label = "Use Profile Default", onClick = onUseProfileDefault, showChevron = false, ) } - - SettingsProse( - body = "Choices sync with other phones (or tablets) signed into this " + - "profile unless 'Only this device' is on.", - ) } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/PlaybackSettings.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/PlaybackSettings.kt index 2bfefd2de3..faafd815f5 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/PlaybackSettings.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/PlaybackSettings.kt @@ -2,12 +2,21 @@ package org.prairieserver.prairie.android.ui.screens.settings import androidx.annotation.StringRes import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import org.prairieserver.prairie.android.R +import org.prairieserver.prairie.android.ui.components.PrairieConfirmDialog +import org.prairieserver.prairie.common.settings.UseProfileSettingsCopy import org.prairieserver.prairie.domain.player.IntroSkipMode import org.prairieserver.prairie.model.settings.LanguageOptions +import org.prairieserver.prairie.model.settings.PlaybackSettingsKeys import org.prairieserver.prairie.model.settings.QualityPresets import org.prairieserver.prairie.model.settings.SettingKeys @@ -16,8 +25,8 @@ import org.prairieserver.prairie.model.settings.SettingKeys // uncapped). The preset table is shared with the TV app and mirrors the web // client's, so the same choice reads back with the same label everywhere. -// Discrete choices for the two behavior settings (0 = off). Dropdown idiom -// matches the rest of this section; the label↔value maps below convert. +// Discrete choices for the two behavior settings (0 = off). The label↔value +// maps below convert. private val resumeRewindOptions = listOf(0, 3, 5, 7, 10, 15, 20, 30) private val passOutThresholdOptions = listOf(0, 2, 3, 4, 5) // Up-Next prompt timing (seconds before end; 0 = at end). Mirrors TV/tvOS. @@ -32,137 +41,218 @@ private fun nextUpPromptLabel(seconds: Int): String = when { } /** - * Playback settings section with quality preference, audio language, - * and auto-skip toggles. + * The menu for a profile-layered picker: "Use profile setting" first, then + * [options]. Routes that first option to [onUseProfileSetting] and checks it + * while the device has no value of its own. */ @Composable -fun PlaybackSettings( +private fun ProfileLayeredDropdownRow( + label: String, + value: String, + options: List, + overridden: Boolean, + onOptionSelected: (String) -> Unit, + onUseProfileSetting: () -> Unit, +) { + SettingsDropdownRow( + label = label, + value = value, + options = listOf(UseProfileSettingsCopy.OPTION) + options, + selectedOption = if (overridden) value else UseProfileSettingsCopy.OPTION, + onOptionSelected = { option -> + if (option == UseProfileSettingsCopy.OPTION) onUseProfileSetting() else onOptionSelected(option) + }, + ) +} + +/** + * A switch for a setting the profile also holds. A switch has no menu to put + * "Use profile setting" in, so while this device holds its own value the + * action sits in its own row right under the switch, as Cards & Posters' + * "Use Profile Default" does. + */ +@Composable +private fun ProfileLayeredSwitchRow( + label: String, + checked: Boolean, + overridden: Boolean, + onCheckedChange: (Boolean) -> Unit, + onUseProfileSetting: () -> Unit, +) { + SettingsSwitchRow(label = label, checked = checked, onCheckedChange = onCheckedChange) + if (overridden) { + SettingsNavigationRow( + label = UseProfileSettingsCopy.ROW, + onClick = onUseProfileSetting, + showChevron = false, + modifier = Modifier.semantics { contentDescription = UseProfileSettingsCopy.rowDescription(label) }, + ) + } +} + +/** + * Playback → Streaming (Apple `PlaybackSettingsView` "Streaming"): quality, + * audio language, Dolby Vision, and — where the Apple list ends with + * Background Playback — Android's picture-in-picture. The footer describes the + * chosen quality preset. + */ +@Composable +fun PlaybackStreamingSection( qualityResolution: String, maxBitrateKbps: Int?, audioLanguage: String, - audioLanguageSuggestions: List = emptyList(), - introSkipMode: IntroSkipMode, - autoSkipCredits: Boolean, - pictureInPictureEnabled: Boolean, + audioLanguageSuggestions: List, dolbyVisionEnabled: Boolean, dvProfile7HDR10Fallback: Boolean, - autoPlayNext: Boolean, - nextUpPromptSeconds: Int, - resumeRewindSeconds: Int, - passOutThreshold: Int, + pictureInPictureEnabled: Boolean, + /** Keys this device holds its own value for. */ + deviceOverrides: Set, /** Receives a [QualityPresets] preset id. */ onQualityPresetSelected: (String) -> Unit, onAudioLanguageChanged: (String) -> Unit, - onIntroSkipModeChanged: (IntroSkipMode) -> Unit, - onAutoSkipCreditsChanged: (Boolean) -> Unit, - onPictureInPictureEnabledChanged: (Boolean) -> Unit, onDolbyVisionEnabledChanged: (Boolean) -> Unit, onDvProfile7HDR10FallbackChanged: (Boolean) -> Unit, - onAutoPlayNextChanged: (Boolean) -> Unit, - onNextUpPromptSecondsChanged: (Int) -> Unit, - onResumeRewindSecondsChanged: (Int) -> Unit, - onPassOutThresholdChanged: (Int) -> Unit, - onResetPlaybackOverrides: () -> Unit, + onPictureInPictureEnabledChanged: (Boolean) -> Unit, + /** Receives the key of the control going back to the profile's value. */ + onUseProfileSetting: (String) -> Unit, modifier: Modifier = Modifier, ) { + // No "No preference" entry: on a device that means going back to the + // profile's language, which "Use profile setting" already says. val audioLanguageOptions = remember(audioLanguage, audioLanguageSuggestions) { - LanguageOptions.options( + LanguageOptions.namedOptions( key = SettingKeys.PLAYBACK_AUDIO_LANGUAGE, currentValue = audioLanguage, runtimeValues = audioLanguageSuggestions, ) } - val introSkipOptions = IntroSkipMode.entries.map { it to stringResource(introSkipModeLabel(it)) } - SettingsSection(title = "Playback", modifier = modifier) { - // A pair no preset covers (set through the API, or left by a legacy - // compound value) still gets a truthful label rather than a picker - // silently showing the wrong entry. - SettingsDropdownRow( - label = "Preferred quality", - description = "The quality Prairie requests when playback starts.", - value = QualityPresets.describe(qualityResolution, maxBitrateKbps), + // A pair no preset covers (set through the API, or left by a legacy + // compound value) still gets a truthful label rather than a picker + // silently showing the wrong entry. + val qualityLabel = QualityPresets.describe(qualityResolution, maxBitrateKbps) + val qualityFooter = QualityPresets.presetFor(qualityResolution, maxBitrateKbps)?.description + ?: "$qualityLabel." + SettingsSection(title = "Streaming", footer = qualityFooter, modifier = modifier) { + ProfileLayeredDropdownRow( + label = "Quality", + value = qualityLabel, options = QualityPresets.ALL.map { it.label }, + overridden = PlaybackSettingsKeys.hasDeviceOverride(deviceOverrides, PlaybackSettingsKeys.PreferredQuality), onOptionSelected = { label -> QualityPresets.ALL.firstOrNull { it.label == label } ?.let { onQualityPresetSelected(it.id) } }, + onUseProfileSetting = { onUseProfileSetting(PlaybackSettingsKeys.PreferredQuality) }, ) - SettingsDropdownRow( - label = "Audio language", - description = "Choose which spoken language Prairie should prefer first.", + ProfileLayeredDropdownRow( + label = "Audio Language", value = LanguageOptions.label(audioLanguage, SettingKeys.PLAYBACK_AUDIO_LANGUAGE), options = audioLanguageOptions.map { it.second }, + overridden = PlaybackSettingsKeys.hasDeviceOverride(deviceOverrides, PlaybackSettingsKeys.AudioLanguage), onOptionSelected = { label -> - onAudioLanguageChanged(LanguageOptions.wireValue(label, audioLanguageOptions)) - }, - ) - - // Three-way, not a switch: the boolean this replaced could not say - // "never". Labels and semantics are fixed by the contract. - SettingsDropdownRow( - label = stringResource(R.string.settings_intro_skip_title), - description = "What happens when a detected intro starts: leave it alone, " + - "offer a Skip Intro button, or skip it and offer an undo.", - value = stringResource(introSkipModeLabel(introSkipMode)), - options = introSkipOptions.map { it.second }, - onOptionSelected = { label -> - introSkipOptions.firstOrNull { it.second == label }?.let { onIntroSkipModeChanged(it.first) } + audioLanguageOptions.firstOrNull { it.second == label }?.let { onAudioLanguageChanged(it.first) } }, + onUseProfileSetting = { onUseProfileSetting(PlaybackSettingsKeys.AudioLanguage) }, ) - SettingsSwitchRow( - label = "Auto-skip credits", - description = "Move through end credits automatically when a skip is available.", - checked = autoSkipCredits, - onCheckedChange = onAutoSkipCreditsChanged, - ) - - // iOS PlaybackSettingsView parity: Dolby Vision (off plays the HDR10 - // base layer) with the Profile 7 fallback nested under it — the P7 row - // only shows while Dolby Vision is on. + // Dolby Vision (off plays the HDR10 base layer) with the Profile 7 + // fallback under it — the P7 row only shows while Dolby Vision is on. SettingsSwitchRow( label = "Dolby Vision", - description = "Allow Dolby Vision output on this device.", checked = dolbyVisionEnabled, onCheckedChange = onDolbyVisionEnabledChanged, ) if (dolbyVisionEnabled) { SettingsSwitchRow( - label = "Profile 7 HDR10 fallback", - description = "Play Profile 7 sources as HDR10 when this device cannot decode them natively.", + label = "Profile 7 HDR10 Fallback", checked = dvProfile7HDR10Fallback, onCheckedChange = onDvProfile7HDR10FallbackChanged, ) } SettingsSwitchRow( - label = "Picture-in-picture", - description = "Keep playing in a floating window when you leave the player.", + label = "Picture-in-Picture", checked = pictureInPictureEnabled, onCheckedChange = onPictureInPictureEnabledChanged, ) + } +} - SettingsSwitchRow( - label = "Auto-play next episode", - description = "Continue to the next episode automatically.", +/** + * Playback → Episodes (Apple "Episodes"), plus Android's two resume and + * auto-play limits at the end, explained in the footer. + */ +@Composable +fun PlaybackEpisodesSection( + autoPlayNext: Boolean, + nextUpPromptSeconds: Int, + introSkipMode: IntroSkipMode, + autoSkipCredits: Boolean, + resumeRewindSeconds: Int, + passOutThreshold: Int, + /** Keys this device holds its own value for. */ + deviceOverrides: Set, + onAutoPlayNextChanged: (Boolean) -> Unit, + onNextUpPromptSecondsChanged: (Int) -> Unit, + onIntroSkipModeChanged: (IntroSkipMode) -> Unit, + onAutoSkipCreditsChanged: (Boolean) -> Unit, + onResumeRewindSecondsChanged: (Int) -> Unit, + onPassOutThresholdChanged: (Int) -> Unit, + /** Receives the key of the control going back to the profile's value. */ + onUseProfileSetting: (String) -> Unit, + modifier: Modifier = Modifier, +) { + fun overridden(key: String) = PlaybackSettingsKeys.hasDeviceOverride(deviceOverrides, key) + val introSkipOptions = IntroSkipMode.entries.map { it to stringResource(introSkipModeLabel(it)) } + SettingsSection( + title = "Episodes", + footer = "Rewind on Resume skips back when you return to a partly watched title. Still Watching " + + "Prompt sets how many episodes play in a row before Prairie asks whether you're still watching.", + modifier = modifier, + ) { + ProfileLayeredSwitchRow( + label = "Auto-Play Next Episode", checked = autoPlayNext, + overridden = overridden(PlaybackSettingsKeys.AutoPlayNext), onCheckedChange = onAutoPlayNextChanged, + onUseProfileSetting = { onUseProfileSetting(PlaybackSettingsKeys.AutoPlayNext) }, ) - SettingsDropdownRow( - label = "Next up prompt", - description = "How long before the end of an episode the next-up prompt appears.", + ProfileLayeredDropdownRow( + label = "Show Next Up", value = nextUpPromptLabel(nextUpPromptSeconds), options = nextUpPromptOptions.map(::nextUpPromptLabel), + overridden = overridden(PlaybackSettingsKeys.NextUpPromptSeconds), onOptionSelected = { label -> - onNextUpPromptSecondsChanged(nextUpPromptOptions.first { nextUpPromptLabel(it) == label }) + nextUpPromptOptions.firstOrNull { nextUpPromptLabel(it) == label }?.let(onNextUpPromptSecondsChanged) }, + onUseProfileSetting = { onUseProfileSetting(PlaybackSettingsKeys.NextUpPromptSeconds) }, + ) + + // Three-way, not a switch: the boolean this replaced could not say + // "never". Option labels and semantics are fixed by the contract. + ProfileLayeredDropdownRow( + label = "Skip Intros", + value = stringResource(introSkipModeLabel(introSkipMode)), + options = introSkipOptions.map { it.second }, + overridden = overridden(PlaybackSettingsKeys.IntroSkipMode), + onOptionSelected = { label -> + introSkipOptions.firstOrNull { it.second == label }?.let { onIntroSkipModeChanged(it.first) } + }, + onUseProfileSetting = { onUseProfileSetting(PlaybackSettingsKeys.IntroSkipMode) }, + ) + + ProfileLayeredSwitchRow( + label = "Skip Credits", + checked = autoSkipCredits, + overridden = overridden(PlaybackSettingsKeys.AutoSkipCredits), + onCheckedChange = onAutoSkipCreditsChanged, + onUseProfileSetting = { onUseProfileSetting(PlaybackSettingsKeys.AutoSkipCredits) }, ) SettingsDropdownRow( - label = "Rewind on resume", - description = "Skip back this far when resuming a partly watched item.", + label = "Rewind on Resume", value = resumeRewindLabel(resumeRewindSeconds), options = resumeRewindOptions.map(::resumeRewindLabel), onOptionSelected = { label -> @@ -171,19 +261,43 @@ fun PlaybackSettings( ) SettingsDropdownRow( - label = "Still watching prompt", - description = "How many episodes auto-play before Prairie asks whether you are still watching.", + label = "Still Watching Prompt", value = passOutThresholdLabel(passOutThreshold), options = passOutThresholdOptions.map(::passOutThresholdLabel), onOptionSelected = { label -> onPassOutThresholdChanged(passOutThresholdOptions.first { passOutThresholdLabel(it) == label }) }, ) + } +} +/** + * Playback → "Use Profile Settings", the Apple page's last group. Asks first: + * it clears every playback setting changed on this device at once. + */ +@Composable +fun PlaybackResetSection(onResetPlaybackOverrides: () -> Unit, modifier: Modifier = Modifier) { + var confirming by rememberSaveable { mutableStateOf(false) } + SettingsSection( + title = null, + footer = UseProfileSettingsCopy.FOOTER, + modifier = modifier, + ) { SettingsDestructiveRow( - label = "Reset playback settings", - description = "Return this device's playback settings to their defaults.", - onClick = onResetPlaybackOverrides, + label = UseProfileSettingsCopy.RESET_ALL_ROW, + onClick = { confirming = true }, + ) + } + if (confirming) { + PrairieConfirmDialog( + title = UseProfileSettingsCopy.CONFIRM_TITLE, + body = UseProfileSettingsCopy.CONFIRM_MESSAGE, + confirmLabel = UseProfileSettingsCopy.CONFIRM_BUTTON, + onConfirm = { + confirming = false + onResetPlaybackOverrides() + }, + onDismiss = { confirming = false }, ) } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SeekIntervalSettings.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SeekIntervalSettings.kt index 99f21f366f..5890992a42 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SeekIntervalSettings.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SeekIntervalSettings.kt @@ -12,8 +12,9 @@ private const val CHECKING_SERVER = "Checking whether this server stores skip in /** * One "Video" or "Audiobooks" group of the profile-wide skip intervals - * (settings revision 9). On an older server the group explains the legacy - * behavior instead of offering pickers that could not be saved. + * (settings revision 9), with the Apple pages' labels and footers. On an older + * server the group explains the legacy behavior instead of offering pickers + * that could not be saved. */ @Composable fun SeekIntervalSettings( @@ -28,7 +29,14 @@ fun SeekIntervalSettings( SeekMedia.Video -> "Video" SeekMedia.Audiobook -> "Audiobooks" } - SettingsSection(title = title, modifier = modifier) { + val supported = state.support != SeekIntervalSupport.Unsupported && !state.checking && !state.checkFailed + val footer = when { + !supported -> null + media == SeekMedia.Video -> + "Used by the on-screen skip buttons and double-tap. Applies to every device signed in to this profile." + else -> "Used by the audiobook player's skip buttons. Applies to every device signed in to this profile." + } + SettingsSection(title = title, footer = footer, modifier = modifier) { if (state.support == SeekIntervalSupport.Unsupported) { SettingsProse( body = when (media) { @@ -59,7 +67,7 @@ fun SeekIntervalSettings( "Until it answers, intervals set in the audiobook player stay on this device." }, ) - SettingsNavigationRow(label = "Try again", onClick = onRetry) + SettingsNavigationRow(label = "Try Again", onClick = onRetry) return@SettingsSection } @@ -70,8 +78,7 @@ fun SeekIntervalSettings( val options = state.choices.map(SeekIntervals::label) SeekDirection.entries.forEach { direction -> SettingsDropdownRow( - label = if (direction == SeekDirection.Back) "Skip back" else "Skip forward", - description = if (direction == SeekDirection.Back) "Applies to every device on this profile." else null, + label = if (direction == SeekDirection.Back) "Skip Back" else "Skip Forward", value = SeekIntervals.label(pair.seconds(direction)), options = options, onOptionSelected = { label -> @@ -84,7 +91,7 @@ fun SeekIntervalSettings( if (media == SeekMedia.Audiobook) { if (state.showLegacyImport) { SettingsNavigationRow( - label = "Use this device's audiobook intervals", + label = "Use This Device's Audiobook Intervals", description = "${state.legacyAudiobookSummary}. Uploads them to this profile.", value = if (state.importInProgress) "Importing" else null, onClick = onImportLegacyAudiobook, diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/ServerInfoSection.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/ServerInfoSection.kt deleted file mode 100644 index 24f705ee50..0000000000 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/ServerInfoSection.kt +++ /dev/null @@ -1,75 +0,0 @@ -package org.prairieserver.prairie.android.ui.screens.settings - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalUriHandler -import org.koin.compose.koinInject -import org.prairieserver.prairie.android.BuildConfig -import org.prairieserver.prairie.common.network.clientVersionLabel -import org.prairieserver.prairie.update.AppUpdateChecker -import org.prairieserver.prairie.update.AppUpdateStatus -import org.prairieserver.prairie.update.changelogUrlOrNull -import org.prairieserver.prairie.update.latestVersionLabel -import org.prairieserver.prairie.update.releaseUrlOrNull -import org.prairieserver.prairie.update.statusLabel - -/** - * Connection section. Mirrors the iOS phone Settings `Server` row: a - * single teal-badged `server.rack` row whose trailing value is the - * active server label, with a disclosure chevron that opens the server - * list. - */ -@Composable -fun ServerInfoSection( - serverUrl: String, - onManageServersClick: () -> Unit = {}, - modifier: Modifier = Modifier, -) { - SettingsSectionCard(modifier = modifier) { - SettingsNavigationRow( - label = "Server", - description = "The Prairie server this device is signed in to.", - value = serverUrl.ifBlank { "Not connected" }, - onClick = onManageServersClick, - ) - SettingsNavigationRow( - label = "Version", - description = "The app build running on this device.", - // Includes the build number so a support report and the server's - // admin Activity page name the exact same build, in the "1.0.0 (5)" - // form Play, TestFlight and the server's own diagnostics page all - // use. Unstamped local builds show the bare version rather than a - // meaningless "(0)", matching what those builds report. - value = clientVersionLabel(BuildConfig.VERSION_NAME, BuildConfig.BUILD_NUMBER), - ) - AppUpdateStatusRow() - } -} - -/** - * Prairie-only (b3b04cbb): GitHub Releases update check for sideloaded builds. - * Self-contained (checker from Koin, no ViewModel plumbing) so an upstream - * rewrite of the settings ViewModel cannot silently drop it again. - */ -@Composable -private fun AppUpdateStatusRow() { - val checker: AppUpdateChecker = koinInject() - val uriHandler = LocalUriHandler.current - val status by produceState(AppUpdateStatus.Checking, checker) { - value = checker.check(BuildConfig.VERSION_NAME) - } - val targetUrl = status.releaseUrlOrNull() ?: status.changelogUrlOrNull() - val latest = status.latestVersionLabel() - SettingsNavigationRow( - label = "Updates", - description = if (status is AppUpdateStatus.UpdateAvailable && latest != null) { - "Prairie $latest is available." - } else { - "Checks Prairie releases on GitHub." - }, - value = status.statusLabel(), - onClick = targetUrl?.let { url -> { runCatching { uriHandler.openUri(url) } } }, - ) -} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsAccountCard.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsAccountCard.kt new file mode 100644 index 0000000000..57534c52cc --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsAccountCard.kt @@ -0,0 +1,139 @@ +package org.prairieserver.prairie.android.ui.screens.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.prairieserver.prairie.android.ui.screens.profiles.ProfileAvatar +import org.prairieserver.prairie.android.ui.theme.SettingsDimens +import org.prairieserver.prairie.android.ui.theme.SettingsTextStyles +import org.prairieserver.prairie.android.ui.theme.PrairieOnSurface +import org.prairieserver.prairie.android.ui.theme.PrairieSecondaryText +import org.prairieserver.prairie.android.ui.theme.PrairieSurfaceVariant +import org.prairieserver.prairie.common.ui.components.avatarRef +import org.prairieserver.prairie.model.auth.User +import org.prairieserver.prairie.model.profile.Profile + +/** + * The account card at the top of Settings (Apple `SettingsAccountCard`): the + * active profile's avatar and name, the account and server underneath, an + * "Admin" capsule for administrators, and a chevron. Tapping it switches + * profile. + * + * Sign Out sits at the foot of the overview and Pair Device under Connection, + * as on the Apple apps. Session management and the admin surface stay absent: + * both were removed from the Android clients outright. + */ +@Composable +fun SettingsAccountCard( + profile: Profile?, + user: User?, + serverHost: String?, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val name = profile?.name?.takeIf { it.isNotBlank() } + ?: user?.username?.takeIf { it.isNotBlank() } + ?: "Switch Profile" + val username = user?.username?.takeIf { it.isNotBlank() } + val subtitle = when { + username != null && username != name && serverHost != null -> "$username · $serverHost" + serverHost != null -> serverHost + username != null && username != name -> username + else -> "Tap to switch profile" + } + val isAdministrator = user?.role.equals("admin", ignoreCase = true) + + SettingsSectionCard(modifier = modifier) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = SettingsDimens.overviewRowMinHeight) + .clickable(role = Role.Button, onClickLabel = "Switch profile", onClick = onClick) + .padding(horizontal = SettingsDimens.rowHorizontalPadding, vertical = SettingsDimens.rowVerticalPadding), + verticalAlignment = Alignment.CenterVertically, + ) { + if (profile != null) { + ProfileAvatar( + avatar = profile.avatarRef(), + name = profile.name, + size = SettingsDimens.avatarSize, + ) + } else { + Box( + modifier = Modifier + .size(SettingsDimens.avatarSize) + .clip(CircleShape) + .background(PrairieSurfaceVariant), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.Person, + contentDescription = null, + tint = PrairieSecondaryText, + modifier = Modifier.size(SettingsDimens.avatarSize / 2), + ) + } + } + + Spacer(modifier = Modifier.width(SettingsDimens.avatarGap)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(SettingsDimens.rowLabelGap), + ) { + Text( + text = name, + style = SettingsTextStyles.accountName, + color = PrairieOnSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = subtitle, + style = SettingsTextStyles.rowDescription, + color = PrairieSecondaryText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + if (isAdministrator) { + Spacer(modifier = Modifier.width(SettingsDimens.rowTrailingGap)) + Text( + text = "Admin", + style = SettingsTextStyles.badge, + color = PrairieSecondaryText, + modifier = Modifier + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.14f)) + .padding(horizontal = 9.dp, vertical = 4.dp), + ) + } + + Spacer(modifier = Modifier.width(SettingsDimens.rowTrailingGap)) + SettingsRowChevron() + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsComponents.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsComponents.kt new file mode 100644 index 0000000000..d28a11b6b7 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsComponents.kt @@ -0,0 +1,698 @@ +package org.prairieserver.prairie.android.ui.screens.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.UnfoldMore +import androidx.compose.material3.Icon +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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 +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.prairieserver.prairie.android.ui.components.PrairieDropdownMenu +import org.prairieserver.prairie.android.ui.theme.MenuDimens +import org.prairieserver.prairie.android.ui.theme.SettingsDimens +import org.prairieserver.prairie.android.ui.theme.SettingsTextStyles +import org.prairieserver.prairie.android.ui.theme.PrairieDestructive +import org.prairieserver.prairie.android.ui.theme.PrairieForeground +import org.prairieserver.prairie.android.ui.theme.PrairieGroupedCell +import org.prairieserver.prairie.android.ui.theme.PrairieGroupedSeparator +import org.prairieserver.prairie.android.ui.theme.PrairieIconTile +import org.prairieserver.prairie.android.ui.theme.PrairieOnSurface +import org.prairieserver.prairie.android.ui.theme.PrairieSecondaryText +import org.prairieserver.prairie.android.ui.theme.PrairieSwitchOff +import org.prairieserver.prairie.android.ui.theme.PrairieSwitchOn +import kotlin.math.ceil + +// --- Shared Settings UI Components --- +// +// The Apple apps' Settings in Compose (prairie-apple #546): an inset-grouped list +// on a black ground, one-line rows with the current value trailing, a +// title-case header above each group and an optional footer below it. Overview +// rows carry a graphite icon tile; sub-page rows are text only, and anything a +// row would once have explained in a description line belongs in its group's +// footer. Metrics live in `ui.theme.SettingsDimens` / `SettingsTextStyles`. + +/** Opacity of the disclosure chevron. */ +private const val ChevronAlpha = 0.35f + +/** Opacity of a menu row's up/down indicator, a little stronger than a chevron. */ +private const val MenuIndicatorAlpha = 0.55f + +/** + * Draws the grouped-list hairline along this element's top edge, inset from + * the leading edge to where the row's label starts (past the icon tile on an + * overview row) and from the trailing edge by the row padding. + */ +fun Modifier.settingsRowDivider( + show: Boolean, + startInset: Dp = SettingsDimens.dividerStartInset, +): Modifier = + if (!show) { + this + } else { + drawBehind { + val start = startInset.toPx() + val end = SettingsDimens.dividerEndInset.toPx() + drawRect( + color = PrairieGroupedSeparator, + topLeft = Offset(start, 0f), + size = Size(size.width - start - end, SettingsDimens.separatorThickness.toPx()), + ) + } + } + +/** + * A settings group: a title-case header above the grouped rows and an + * optional footer below them, as in the Apple apps' inset-grouped lists. + */ +@Composable +fun SettingsSection( + title: String?, + modifier: Modifier = Modifier, + footer: String? = null, + footerColor: Color = PrairieSecondaryText, + content: @Composable ColumnScope.() -> Unit, +) { + Column(modifier = modifier.fillMaxWidth()) { + if (!title.isNullOrBlank()) { + SettingsSectionHeader(title) + } + SettingsSectionCard(content = content) + if (!footer.isNullOrBlank()) { + SettingsSectionFooter(footer, color = footerColor) + } + } +} + +/** + * Grouped rows. + * + * Every row draws a hairline along its top edge, and the card paints over its + * own top edge, so whichever row is first right now — after search filters the + * list or a row swaps in place — never shows one. Counting rows instead goes + * stale as soon as the first row changes while the card stays composed. + */ +@Composable +fun SettingsSectionCard( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(SettingsDimens.groupRadius)) + .background(PrairieGroupedCell) + .drawWithContent { + drawContent() + drawRect( + color = PrairieGroupedCell, + size = Size(size.width, ceil(SettingsDimens.separatorThickness.toPx())), + ) + }, + content = content, + ) +} + +/** Section header: title case, semibold, secondary, aligned with the row text. */ +@Composable +fun SettingsSectionHeader(title: String) { + Text( + text = title, + style = SettingsTextStyles.sectionHeader, + color = PrairieSecondaryText, + modifier = Modifier.padding( + start = SettingsDimens.headerStartInset, + end = SettingsDimens.headerStartInset, + bottom = SettingsDimens.headerBottomGap, + ), + ) +} + +/** Section footer: the explanation that would otherwise ride on each row. */ +@Composable +fun SettingsSectionFooter(text: String, color: Color = PrairieSecondaryText) { + Text( + text = text, + style = SettingsTextStyles.rowDescription, + color = color, + modifier = Modifier.padding( + start = SettingsDimens.headerStartInset, + end = SettingsDimens.headerStartInset, + top = SettingsDimens.footerTopGap, + ), + ) +} + +/** The graphite tile behind an overview row's glyph (Apple `prairieIconTile`). */ +@Composable +fun SettingsIconTile(icon: ImageVector, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(SettingsDimens.iconTileSize) + .clip(RoundedCornerShape(SettingsDimens.iconTileRadius)) + .background(PrairieIconTile), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(SettingsDimens.iconGlyphSize), + ) + } +} + +/** Disclosure chevron. */ +@Composable +fun SettingsRowChevron(enabled: Boolean = true) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = PrairieOnSurface.copy(alpha = ChevronAlpha * if (enabled) 1f else SettingsDimens.disabledAlpha), + modifier = Modifier.size(SettingsDimens.chevronSize), + ) +} + +/** + * The one settings row. + * + * Every other row type in this package is this one with a different trailing + * slot: an optional icon tile, the label, the current [value] trailing on the + * label's line, then the control. A hairline sits above every row but the + * group's first. + * + * @param description A second line under the label. The Apple lists put this + * in the group footer instead; it remains for rows whose explanation is + * per-item (a diagnostics report's date and size). + * @param showDivider Draws the hairline above the row. The group hides it on + * whichever row is first, so only a row that must never have one sets it + * false. + */ +@Composable +fun SettingsRow( + label: String, + modifier: Modifier = Modifier, + description: String? = null, + value: String? = null, + icon: ImageVector? = null, + labelColor: Color = PrairieOnSurface, + enabled: Boolean = true, + onClick: (() -> Unit)? = null, + showDivider: Boolean = true, + trailing: @Composable RowScope.() -> Unit = {}, +) { + val contentAlpha = if (enabled) 1f else SettingsDimens.disabledAlpha + val dividerStart = if (icon != null) { + SettingsDimens.rowHorizontalPadding + SettingsDimens.iconTileSize + SettingsDimens.iconTileGap + } else { + SettingsDimens.dividerStartInset + } + Row( + modifier = modifier + .fillMaxWidth() + .heightIn(min = if (icon != null) SettingsDimens.overviewRowMinHeight else SettingsDimens.rowMinHeight) + .settingsRowDivider(showDivider, startInset = dividerStart) + .then( + if (onClick != null) { + Modifier.clickable(enabled = enabled, onClick = onClick) + } else { + Modifier + }, + ) + .padding( + horizontal = SettingsDimens.rowHorizontalPadding, + vertical = SettingsDimens.rowVerticalPadding, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + if (icon != null) { + SettingsIconTile(icon) + Spacer(modifier = Modifier.width(SettingsDimens.iconTileGap)) + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(SettingsDimens.rowLabelGap), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = SettingsTextStyles.rowLabel, + color = labelColor.copy(alpha = labelColor.alpha * contentAlpha), + // Fills the line so the value stays trailing-aligned, and + // yields — by wrapping — when a capped value needs room. + modifier = Modifier.weight(1f), + ) + if (value != null) { + Spacer(modifier = Modifier.width(SettingsDimens.rowLabelValueGap)) + SettingsRowValue(value = value, enabled = enabled) + } + } + if (!description.isNullOrBlank()) { + Text( + text = description, + style = SettingsTextStyles.rowDescription, + color = PrairieSecondaryText.copy(alpha = PrairieSecondaryText.alpha * contentAlpha), + ) + } + } + trailing() + } +} + +/** A row that navigates somewhere, optionally showing the current value. */ +@Composable +fun SettingsNavigationRow( + label: String, + modifier: Modifier = Modifier, + description: String? = null, + value: String? = null, + icon: ImageVector? = null, + onClick: (() -> Unit)? = null, + showChevron: Boolean = onClick != null, + enabled: Boolean = true, + labelColor: Color = PrairieOnSurface, +) { + SettingsRow( + label = label, + modifier = modifier, + description = description, + value = value, + icon = icon, + labelColor = labelColor, + enabled = enabled, + onClick = onClick, + ) { + if (showChevron) { + Spacer(modifier = Modifier.width(SettingsDimens.rowTrailingGap)) + SettingsRowChevron(enabled = enabled) + } + } +} + +/** + * Destructive row: the label in [PrairieDestructive] and no chevron, like the + * Apple apps' "Reset Playback Overrides". + */ +@Composable +fun SettingsDestructiveRow( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + description: String? = null, + value: String? = null, + enabled: Boolean = true, +) { + SettingsNavigationRow( + label = label, + modifier = modifier, + description = description, + value = value, + onClick = onClick, + showChevron = false, + enabled = enabled, + labelColor = PrairieDestructive, + ) +} + +/** + * A full-width centred button row, the Apple apps' "Sign Out" group. + */ +@Composable +fun SettingsButtonRow( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + labelColor: Color = PrairieDestructive, + enabled: Boolean = true, +) { + Box( + modifier = modifier + .fillMaxWidth() + .heightIn(min = SettingsDimens.rowMinHeight) + .settingsRowDivider(show = true) + .clickable(enabled = enabled, role = Role.Button, onClick = onClick) + .padding(horizontal = SettingsDimens.rowHorizontalPadding), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + style = SettingsTextStyles.rowLabel, + color = labelColor.copy(alpha = if (enabled) 1f else SettingsDimens.disabledAlpha), + textAlign = TextAlign.Center, + ) + } +} + +/** + * Trailing value text in the secondary colour, so a picker's current choice + * does not read as a second label. + * + * Unweighted, so [SettingsRow]'s label line measures it first: it gets the + * width it asks for up to [SettingsDimens.rowValueMaxWidth], and the label + * takes what is left. + */ +@Composable +private fun SettingsRowValue(value: String, enabled: Boolean) { + Text( + text = value, + style = SettingsTextStyles.rowValue, + color = PrairieSecondaryText.copy( + alpha = PrairieSecondaryText.alpha * if (enabled) 1f else SettingsDimens.disabledAlpha, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.End, + modifier = Modifier.widthIn(max = SettingsDimens.rowValueMaxWidth), + ) +} + +/** + * Settings row with a switch. The whole row toggles, not just the thumb. + */ +@Composable +fun SettingsSwitchRow( + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + description: String? = null, + icon: ImageVector? = null, + enabled: Boolean = true, +) { + SettingsRow( + label = label, + description = description, + icon = icon, + enabled = enabled, + modifier = modifier.toggleable( + value = checked, + enabled = enabled, + role = Role.Switch, + onValueChange = onCheckedChange, + ), + ) { + Spacer(modifier = Modifier.width(SettingsDimens.rowTrailingGap)) + Switch( + checked = checked, + // The row owns the gesture; the switch is the indicator. + onCheckedChange = null, + enabled = enabled, + // An empty thumb slot keeps the thumb full size in both states, + // the way Apple's switch thumb never shrinks. + thumbContent = {}, + colors = settingsSwitchColors(), + ) + } +} + +/** Apple's switch: green track while on, a white thumb in both states. */ +@Composable +internal fun settingsSwitchColors() = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = PrairieSwitchOn, + checkedBorderColor = Color.Transparent, + uncheckedThumbColor = Color.White, + uncheckedTrackColor = PrairieSwitchOff, + uncheckedBorderColor = Color.Transparent, + disabledCheckedThumbColor = Color.White.copy(alpha = SettingsDimens.disabledAlpha), + disabledCheckedTrackColor = PrairieSwitchOn.copy(alpha = SettingsDimens.disabledAlpha), + disabledCheckedBorderColor = Color.Transparent, + disabledUncheckedThumbColor = Color.White.copy(alpha = SettingsDimens.disabledAlpha), + disabledUncheckedTrackColor = PrairieSwitchOff.copy(alpha = SettingsDimens.disabledAlpha), + disabledUncheckedBorderColor = Color.Transparent, +) + +/** + * One option in a mutually exclusive set, marked with a trailing checkmark + * the way Apple's selection lists are. Like the switch row, the whole row is + * the target. + */ +@Composable +fun SettingsChoiceRow( + label: String, + selected: Boolean, + onSelect: () -> Unit, + modifier: Modifier = Modifier, + description: String? = null, + enabled: Boolean = true, +) { + SettingsRow( + label = label, + description = description, + enabled = enabled, + modifier = modifier.selectable( + selected = selected, + enabled = enabled, + role = Role.RadioButton, + onClick = onSelect, + ), + ) { + Spacer(modifier = Modifier.width(SettingsDimens.rowTrailingGap)) + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + tint = PrairieOnSurface.copy(alpha = if (selected) 1f else 0f), + modifier = Modifier.size(SettingsDimens.chevronSize), + ) + } +} + +/** + * A row that opens a menu of options: the current value and Apple's up/down + * menu indicator trailing, and a popup at the trailing edge that checks the + * current choice. + */ +@Composable +fun SettingsDropdownRow( + label: String, + value: String, + options: List, + onOptionSelected: (String) -> Unit, + modifier: Modifier = Modifier, + description: String? = null, + icon: ImageVector? = null, + enabled: Boolean = true, + /** + * The option the menu checks. Defaults to [value]; differs when the row + * shows the value in effect but the choice is another option, such as + * "Use profile setting". + */ + selectedOption: String = value, +) { + var expanded by remember { mutableStateOf(false) } + + Box(modifier = modifier) { + SettingsRow( + label = label, + description = description, + value = value, + icon = icon, + enabled = enabled, + onClick = { expanded = true }, + ) { + Spacer(modifier = Modifier.width(SettingsDimens.rowTrailingGap)) + Icon( + imageVector = Icons.Filled.UnfoldMore, + contentDescription = null, + tint = PrairieOnSurface.copy( + alpha = MenuIndicatorAlpha * if (enabled) 1f else SettingsDimens.disabledAlpha, + ), + modifier = Modifier.size(SettingsDimens.menuIndicatorSize), + ) + } + + // Anchored at the row's trailing edge, beside the value it changes. + Box(modifier = Modifier.align(Alignment.BottomEnd)) { + PrairieDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + options.forEach { option -> + SettingsMenuItem( + label = option, + selected = option == selectedOption, + onClick = { + onOptionSelected(option) + expanded = false + }, + ) + } + } + } + } +} + +/** A popup-menu option with a leading checkmark on the current choice. */ +@Composable +private fun SettingsMenuItem(label: String, selected: Boolean, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .widthIn(min = MenuDimens.minWidth) + .heightIn(min = MenuDimens.rowMinHeight) + .clickable(onClick = onClick) + .padding(horizontal = MenuDimens.rowHorizontalPadding, vertical = MenuDimens.rowVerticalPadding), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(modifier = Modifier.width(SettingsDimens.menuCheckWidth)) { + if (selected) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = "Selected", + tint = PrairieForeground, + modifier = Modifier.size(SettingsDimens.menuIndicatorSize), + ) + } + } + Text( + text = label, + style = SettingsTextStyles.menuLabel, + color = PrairieForeground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** + * A prose block inside a group, for the notices that are explanation rather + * than setting. Carries a row's hairline. + */ +@Composable +fun SettingsProse( + body: String, + modifier: Modifier = Modifier, + title: String? = null, +) { + Column( + modifier = modifier + .fillMaxWidth() + .settingsRowDivider(show = true) + .padding( + horizontal = SettingsDimens.proseHorizontalPadding, + vertical = SettingsDimens.proseVerticalPadding, + ), + verticalArrangement = Arrangement.spacedBy(SettingsDimens.rowLabelGap), + ) { + if (title != null) { + Text( + text = title, + style = SettingsTextStyles.rowLabel, + color = PrairieOnSurface, + ) + } + Text( + text = body, + style = SettingsTextStyles.rowDescription, + color = PrairieSecondaryText, + ) + } +} + +/** + * The overview's search capsule (Apple `SettingsSearchField`): a magnifier, a + * "Search" placeholder, and a clear button once there is text. + */ +@Composable +fun SettingsSearchField( + query: String, + onQueryChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .height(SettingsDimens.searchFieldHeight) + .clip(CircleShape) + .background(PrairieGroupedCell) + // Apple's search capsule insets its glyph 14pt. + .padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = null, + tint = PrairieSecondaryText, + modifier = Modifier.size(SettingsDimens.menuIndicatorSize), + ) + Spacer(modifier = Modifier.width(SettingsDimens.rowTrailingGap)) + Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { + if (query.isEmpty()) { + Text(text = "Search", style = SettingsTextStyles.rowLabel, color = PrairieSecondaryText) + } + BasicTextField( + value = query, + onValueChange = onQueryChange, + singleLine = true, + textStyle = SettingsTextStyles.rowLabel.copy(color = PrairieOnSurface), + cursorBrush = SolidColor(PrairieOnSurface), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + imeAction = ImeAction.Search, + ), + modifier = Modifier.fillMaxWidth(), + ) + } + if (query.isNotEmpty()) { + Icon( + imageVector = Icons.Filled.Cancel, + contentDescription = "Clear search", + tint = PrairieSecondaryText, + modifier = Modifier + .size(SettingsDimens.menuIndicatorSize + 4.dp) + .clip(CircleShape) + .clickable { onQueryChange("") }, + ) + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsPages.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsPages.kt new file mode 100644 index 0000000000..8b05abcf65 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsPages.kt @@ -0,0 +1,426 @@ +package org.prairieserver.prairie.android.ui.screens.settings + +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import org.koin.compose.koinInject +import org.prairieserver.prairie.android.ui.components.PrairieConfirmDialog +import org.prairieserver.prairie.android.ui.components.PrairieTopBar +import org.prairieserver.prairie.android.ui.screens.downloads.DownloadsViewModel +import org.prairieserver.prairie.android.ui.screens.home.HomeSectionsEditor +import org.prairieserver.prairie.android.ui.theme.SettingsDimens +import org.prairieserver.prairie.android.ui.theme.PrairieSettingsBackground +import org.prairieserver.prairie.android.ui.util.formatBytes +import org.prairieserver.prairie.common.profiles.ProfileLaunchBehavior +import org.prairieserver.prairie.common.profiles.ProfileLaunchPreferences +import org.prairieserver.prairie.common.settings.TitleArtStore +import org.prairieserver.prairie.common.settings.UseProfileSettingsCopy +import org.prairieserver.prairie.model.feature.MetadataAiFeatureStore +import org.prairieserver.prairie.model.metadata.MetadataAiOnView +import org.prairieserver.prairie.model.settings.SeekMedia + +// The sub-pages the Settings overview pushes, one per Apple Settings page. +// Each shares the overview's SettingsViewModel (AppNavigation hands it the +// overview's back-stack entry), so a change made here is the value the +// overview row shows on the way back. + +/** A Settings sub-page: a centred inline title and a grouped list. */ +@Composable +internal fun SettingsPageScaffold( + title: String, + onBackClick: () -> Unit, + content: LazyListScope.() -> Unit, +) { + Scaffold( + topBar = { + PrairieTopBar( + title = title, + onBackClick = onBackClick, + containerColor = PrairieSettingsBackground, + centerTitle = true, + ) + }, + containerColor = PrairieSettingsBackground, + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentPadding = PaddingValues( + start = SettingsDimens.pageGutter, + end = SettingsDimens.pageGutter, + top = SettingsDimens.pageTopPadding, + bottom = SettingsDimens.pageBottomSpacer, + ), + verticalArrangement = Arrangement.spacedBy(SettingsDimens.sectionGap), + content = content, + ) + } +} + +/** + * Interface (Apple `InterfaceCustomizationView`): Cards & Posters, Home, then + * Title Pages. + */ +@Composable +fun SettingsInterfaceScreen(viewModel: SettingsViewModel, onBackClick: () -> Unit) { + val state by viewModel.uiState.collectAsState() + var showHomeSectionsEditor by rememberSaveable { mutableStateOf(false) } + val titleArtStore: TitleArtStore = koinInject() + // Opening Interface is a refresh edge, so a title art choice made on + // another device shows here without waiting for the next foreground. Kept + // out of the LazyColumn item, which re-enters composition on scroll. + LaunchedEffect(titleArtStore) { titleArtStore.refresh() } + + SettingsPageScaffold(title = "Interface", onBackClick = onBackClick) { + item(key = "cards") { + MediaCardsSettings( + state = state.cardPresentation, + onPresetSelected = viewModel::setCardPreset, + onPosterSizeSelected = viewModel::setCardPosterSize, + onCaptionSelected = viewModel::setCardCaption, + onDeviceOnlyChanged = viewModel::setCardDeviceOnly, + onUseProfileDefault = viewModel::useCardProfileDefault, + ) + } + item(key = "home") { + SettingsSection( + title = "Home", + footer = "Choose which Home rows are visible and the order they appear in.", + ) { + SettingsNavigationRow( + label = "Home Sections", + onClick = { showHomeSectionsEditor = true }, + ) + } + } + item(key = "title-art") { + TitleArtSettingsSection(store = titleArtStore) + } + } + + if (showHomeSectionsEditor) { + HomeSectionsEditor(onDismiss = { showHomeSectionsEditor = false }) + } +} + +/** + * Playback (Apple `PlaybackSettingsView`): Streaming, Episodes, the Video and + * Audiobooks skip intervals, and the reset. + */ +@Composable +fun SettingsPlaybackScreen(viewModel: SettingsViewModel, onBackClick: () -> Unit) { + val state by viewModel.uiState.collectAsState() + val seekIntervals by viewModel.seekIntervals.state.collectAsState() + val context = LocalContext.current + LaunchedEffect(state.playbackOverridesReset) { + val landed = state.playbackOverridesReset ?: return@LaunchedEffect + Toast.makeText(context, UseProfileSettingsCopy.notice(landed), Toast.LENGTH_SHORT).show() + viewModel.onPlaybackOverridesResetShown() + } + + SettingsPageScaffold(title = "Playback", onBackClick = onBackClick) { + item(key = "streaming") { + PlaybackStreamingSection( + qualityResolution = state.qualityResolution, + maxBitrateKbps = state.maxBitrateKbps, + audioLanguage = state.audioLanguage, + audioLanguageSuggestions = state.audioLanguageSuggestions, + dolbyVisionEnabled = state.dolbyVisionEnabled, + dvProfile7HDR10Fallback = state.dvProfile7HDR10Fallback, + pictureInPictureEnabled = state.pictureInPictureEnabled, + deviceOverrides = state.deviceOverrides, + onQualityPresetSelected = viewModel::setQualityPreset, + onAudioLanguageChanged = viewModel::setAudioLanguage, + onDolbyVisionEnabledChanged = viewModel::setDolbyVisionEnabled, + onDvProfile7HDR10FallbackChanged = viewModel::setDvProfile7HDR10Fallback, + onPictureInPictureEnabledChanged = viewModel::setPictureInPictureEnabled, + onUseProfileSetting = viewModel::useProfileSetting, + ) + } + item(key = "episodes") { + PlaybackEpisodesSection( + autoPlayNext = state.autoPlayNext, + nextUpPromptSeconds = state.nextUpPromptSeconds, + introSkipMode = state.introSkipMode, + autoSkipCredits = state.autoSkipCredits, + resumeRewindSeconds = state.resumeRewindSeconds, + passOutThreshold = state.passOutThreshold, + deviceOverrides = state.deviceOverrides, + onAutoPlayNextChanged = viewModel::setAutoPlayNext, + onNextUpPromptSecondsChanged = viewModel::setNextUpPromptSeconds, + onIntroSkipModeChanged = viewModel::setIntroSkipMode, + onAutoSkipCreditsChanged = viewModel::setAutoSkipCredits, + onResumeRewindSecondsChanged = viewModel::setResumeRewindSeconds, + onPassOutThresholdChanged = viewModel::setPassOutThreshold, + onUseProfileSetting = viewModel::useProfileSetting, + ) + } + if (state.episodeSpoilers.isSupported) { + item(key = "spoilers") { + EpisodeSpoilerSettings( + hideImages = state.episodeSpoilers.hideImages, + hideOverviews = state.episodeSpoilers.hideOverviews, + onHideImagesChanged = viewModel::setHideUnwatchedEpisodeImages, + onHideOverviewsChanged = viewModel::setHideUnwatchedEpisodeOverviews, + saveError = state.episodeSpoilerSaveError, + ) + } + } + SeekMedia.entries.forEach { media -> + item(key = "seek-$media") { + SeekIntervalSettings( + media = media, + state = seekIntervals, + onIntervalSelected = viewModel.seekIntervals::select, + onImportLegacyAudiobook = viewModel.seekIntervals::importLegacyAudiobook, + onRetry = viewModel.seekIntervals::refresh, + ) + } + } + item(key = "reset") { + PlaybackResetSection(onResetPlaybackOverrides = viewModel::resetPlaybackOverrides) + } + } +} + +/** + * Subtitles (Apple `SubtitleSettingsView`): Profile, Metadata, Appearance, then + * the Text, Background, and Layout groups, which dim while the device caption + * style is in use. + */ +@Composable +fun SettingsSubtitlesScreen(viewModel: SettingsViewModel, onBackClick: () -> Unit) { + val state by viewModel.uiState.collectAsState() + val metadataAiStore: MetadataAiFeatureStore = koinInject() + val metadataAiStatus by metadataAiStore.status.collectAsState() + val metadataLanguageEnabled = metadataAiStatus.enabled && metadataAiStatus.onView != MetadataAiOnView.Off + val customAppearance = !state.subtitleMatchesDevice + + SettingsPageScaffold(title = "Subtitles", onBackClick = onBackClick) { + item(key = "profile") { + SubtitleProfileSection( + subtitleLanguage = state.subtitleLanguage, + subtitleLanguageSuggestions = state.subtitleLanguageSuggestions, + subtitleMode = state.subtitleMode, + showForcedSubtitles = state.showForcedSubtitles, + onLanguageChanged = viewModel::setSubtitleLanguage, + onModeChanged = viewModel::setSubtitleMode, + onForcedSubtitlesChanged = viewModel::setShowForcedSubtitles, + ) + } + if (metadataLanguageEnabled) { + item(key = "metadata") { + SubtitleMetadataSection( + metadataLanguage = state.metadataLanguage, + metadataLanguageSuggestions = state.metadataLanguageSuggestions, + onMetadataLanguageChanged = viewModel::setMetadataLanguage, + ) + } + } + item(key = "appearance") { + SubtitleAppearanceSection( + previewAppearance = state.effectiveSubtitleAppearance, + subtitleMatchesDevice = state.subtitleMatchesDevice, + onSubtitleMatchesDeviceChanged = viewModel::setSubtitleMatchesDevice, + ) + } + item(key = "text") { + SubtitleTextSection( + appearance = state.subtitleAppearance, + enabled = customAppearance, + showTextOpacity = state.subtitleTextOpacitySupported, + onEdit = viewModel::editSubtitleAppearance, + ) + } + item(key = "background") { + SubtitleBackgroundSection( + appearance = state.subtitleAppearance, + enabled = customAppearance, + onEdit = viewModel::editSubtitleAppearance, + ) + } + item(key = "layout") { + SubtitleLayoutSection( + appearance = state.subtitleAppearance, + enabled = customAppearance, + onEdit = viewModel::editSubtitleAppearance, + ) + } + } +} + +/** Downloads (Apple `DownloadsSettingsView`): Downloads, Cleanup, Storage. */ +@Composable +fun SettingsDownloadsScreen( + viewModel: SettingsViewModel, + downloadsViewModel: DownloadsViewModel, + onBackClick: () -> Unit, +) { + val state by viewModel.uiState.collectAsState() + val downloadsState by downloadsViewModel.uiState.collectAsState() + var showRemoveAllDownloadsConfirm by rememberSaveable { mutableStateOf(false) } + + SettingsPageScaffold(title = "Downloads", onBackClick = onBackClick) { + item(key = "downloads") { + SettingsSection(title = "Downloads", footer = "Quality used for new downloads.") { + SettingsSwitchRow( + label = "Download over Wi-Fi Only", + checked = state.downloadsWifiOnly, + onCheckedChange = viewModel::setDownloadsWifiOnly, + ) + SettingsDropdownRow( + label = "Quality", + value = state.defaultDownloadQuality, + options = state.downloadQualityOptions, + onOptionSelected = viewModel::setDefaultDownloadQuality, + ) + } + } + item(key = "cleanup") { + SettingsSection( + title = "Cleanup", + footer = "When off, the Downloads tab suggests freeing up space by removing items " + + "you've finished watching.", + ) { + SettingsSwitchRow( + label = "Keep Watched Downloads", + checked = state.keepWatchedDownloads, + onCheckedChange = viewModel::setKeepWatchedDownloads, + ) + } + } + item(key = "storage") { + SettingsSection(title = "Storage") { + SettingsNavigationRow(label = "Used", value = formatBytes(downloadsState.totalBytesUsed)) + if (!downloadsState.isEmpty || downloadsState.totalBytesUsed > 0L) { + SettingsDestructiveRow( + label = if (downloadsState.isRemovingAllDownloads) { + "Removing Downloads…" + } else { + "Remove All Downloads" + }, + enabled = !downloadsState.isRemovingAllDownloads, + onClick = { showRemoveAllDownloadsConfirm = true }, + ) + } + } + } + } + + if (showRemoveAllDownloadsConfirm) { + PrairieConfirmDialog( + title = "Remove all downloads?", + body = "This removes ${formatBytes(downloadsState.totalBytesUsed)} of downloaded files " + + "from this device. Your library and server media stay intact.", + confirmLabel = if (downloadsState.isRemovingAllDownloads) "Removing…" else "Remove all", + confirmEnabled = !downloadsState.isRemovingAllDownloads, + onConfirm = { + showRemoveAllDownloadsConfirm = false + downloadsViewModel.removeAllDownloads() + }, + onDismiss = { showRemoveAllDownloadsConfirm = false }, + ) + } +} + +/** + * Notifications. The Apple apps have no in-app notification preferences; + * Android keeps them on their own page under Preferences. + */ +@Composable +fun SettingsNotificationsScreen(viewModel: SettingsViewModel, onBackClick: () -> Unit) { + val state by viewModel.uiState.collectAsState() + + SettingsPageScaffold(title = "Notifications", onBackClick = onBackClick) { + item(key = "in-app") { + SettingsSection( + title = null, + footer = "Show alerts inside Prairie as new releases arrive.", + ) { + SettingsSwitchRow( + label = "In-App Notifications", + checked = state.notificationsEnabled, + onCheckedChange = viewModel::setNotificationsEnabled, + ) + } + } + if (state.notificationsEnabled) { + item(key = "topics") { + SettingsSection( + title = "Notify Me About", + footer = "Favorites and Next Up cover new episodes of series you follow. Watchlist covers " + + "titles that become available. Continue Watching covers titles you started but " + + "haven't finished.", + ) { + SettingsSwitchRow( + label = "Favorites", + checked = state.notifyFavorites, + onCheckedChange = viewModel::setNotifyFavorites, + ) + SettingsSwitchRow( + label = "Watchlist", + checked = state.notifyWatchlist, + onCheckedChange = viewModel::setNotifyWatchlist, + ) + SettingsSwitchRow( + label = "Continue Watching", + checked = state.notifyContinueWatching, + onCheckedChange = viewModel::setNotifyContinueWatching, + ) + SettingsSwitchRow( + label = "Next Up", + checked = state.notifyNextUp, + onCheckedChange = viewModel::setNotifyNextUp, + ) + } + } + } + } +} + +/** How Profile Selection's descriptions name this device. */ +internal const val PROFILE_LAUNCH_DEVICE = "this device" + +/** + * Profile Selection (Apple Settings → General → Profile Selection): whether + * Prairie reopens the last profile or asks who's watching. The choice is kept on + * this phone or tablet only and never sent to the server. + */ +@Composable +fun SettingsProfileSelectionScreen(onBackClick: () -> Unit) { + val preferences: ProfileLaunchPreferences = koinInject() + val launchState by preferences.state.collectAsState() + + SettingsPageScaffold(title = "Profile Selection", onBackClick = onBackClick) { + item(key = "profile-selection") { + SettingsSection( + title = null, + footer = "Choose when Prairie asks who's watching. This applies only to this device.", + ) { + ProfileLaunchBehavior.entries.forEach { behavior -> + SettingsChoiceRow( + label = behavior.title, + description = behavior.description(PROFILE_LAUNCH_DEVICE), + selected = behavior == launchState.behavior, + onSelect = { preferences.setBehavior(behavior) }, + ) + } + } + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsScreen.kt index ad1162e735..699ea87f32 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsScreen.kt @@ -1,94 +1,99 @@ package org.prairieserver.prairie.android.ui.screens.settings -import org.prairieserver.prairie.model.settings.SeekMedia -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.selection.selectable -import androidx.compose.foundation.selection.toggleable -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.RadioButton -import androidx.compose.material3.RadioButtonDefaults +import androidx.compose.material.icons.automirrored.filled.MenuBook +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.ArrowCircleDown +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material.icons.filled.CollectionsBookmark +import androidx.compose.material.icons.filled.ConnectedTv +import androidx.compose.material.icons.filled.Dashboard +import androidx.compose.material.icons.filled.Dns +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.MonitorHeart +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.PanTool +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Subtitles +import androidx.compose.material.icons.filled.SwitchAccount +import androidx.compose.material.icons.filled.SystemUpdate import androidx.compose.material3.Scaffold -import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.Stable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import org.prairieserver.prairie.android.ui.components.PrairieConfirmDialog +import androidx.compose.ui.unit.dp +import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.android.BuildConfig +import org.prairieserver.prairie.android.ui.components.SignOutConfirmDialog import org.prairieserver.prairie.android.ui.components.PrairieTopBar -import org.prairieserver.prairie.android.ui.screens.downloads.DownloadsViewModel -import org.prairieserver.prairie.android.ui.screens.home.HomeSectionsEditor import org.prairieserver.prairie.android.ui.screens.settings.diagnostics.DiagnosticsViewModel import org.prairieserver.prairie.android.ui.screens.settings.diagnostics.shouldShowDiagnosticsEntry import org.prairieserver.prairie.android.ui.theme.SettingsDimens import org.prairieserver.prairie.android.ui.theme.SettingsTextStyles -import org.prairieserver.prairie.android.ui.theme.PrairieBorder -import org.prairieserver.prairie.android.ui.theme.PrairieDestructive -import org.prairieserver.prairie.android.ui.theme.PrairieForeground -import org.prairieserver.prairie.android.ui.theme.PrairieMutedText +import org.prairieserver.prairie.android.ui.theme.PrairieOnSurface +import org.prairieserver.prairie.android.ui.theme.PrairieSecondaryText import org.prairieserver.prairie.android.ui.theme.PrairieSettingsBackground -import org.prairieserver.prairie.android.ui.theme.PrairieSurfaceContainer -import org.prairieserver.prairie.android.ui.theme.PrairieSurfaceContainerHigh -import org.prairieserver.prairie.android.ui.theme.siloRowTopDivider -import org.prairieserver.prairie.android.ui.util.formatBytes -import org.prairieserver.prairie.model.download.DownloadQuality -import org.koin.compose.koinInject -import org.koin.compose.viewmodel.koinViewModel -import org.prairieserver.prairie.model.feature.MetadataAiFeatureStore -import org.prairieserver.prairie.model.metadata.MetadataAiOnView +import org.prairieserver.prairie.common.diagnostics.DiagnosticsAvailabilityUi +import org.prairieserver.prairie.common.network.clientVersionLabel +import org.prairieserver.prairie.common.settings.CardPresentationSupport +import org.prairieserver.prairie.model.settings.LanguageOptions +import org.prairieserver.prairie.model.settings.QualityPresets +import org.prairieserver.prairie.model.settings.SettingKeys +import org.prairieserver.prairie.update.AppUpdateChecker +import org.prairieserver.prairie.update.AppUpdateStatus +import org.prairieserver.prairie.update.changelogUrlOrNull +import org.prairieserver.prairie.update.latestVersionLabel +import org.prairieserver.prairie.update.releaseUrlOrNull +import org.prairieserver.prairie.update.statusLabel + +/** Prairie's privacy policy, linked from About and from Diagnostics. */ +internal const val PRAIRIE_PRIVACY_POLICY_URL = "https://prairieserver.org/privacy" /** - * Main settings screen organized in grouped sections. + * The Settings overview, laid out like the Apple apps' (prairie-apple + * `IOSSettingsOverview`): a large title, the account card, a search field, + * then grouped one-line rows with graphite icon tiles that open the sub-pages. * - * This screen is used as tab content within MainScreen (Settings tab) - * and does NOT have its own top bar back button since it's a tab root. - * - * @param onLoggedOut Called after the user signs out. - * @param showTopBar Whether to show the top bar (false when inside MainScreen tab). - * @param onBackClick Back navigation handler for standalone mode. + * Android adds what the Apple apps keep elsewhere: Notifications, the Library + * shortcuts (Watchlist, Favorites, History, Collections — reachable only from + * here on the phone) and Sign in a TV. Each sits in the group it belongs to + * rather than in a group of its own. The Sign-in group + * (the server's external sign-in providers) appears only on servers that + * offer one. */ @Composable fun SettingsScreen( onLoggedOut: () -> Unit, + onBackClick: (() -> Unit)? = null, + onOpenInterface: () -> Unit = {}, + onOpenPlayback: () -> Unit = {}, + onOpenSubtitles: () -> Unit = {}, + onOpenDownloads: () -> Unit = {}, + onOpenNotifications: () -> Unit = {}, + onOpenProfileSelection: () -> Unit = {}, onNavigateToServers: () -> Unit = {}, onPairDevice: () -> Unit = {}, onSwitchProfile: () -> Unit = {}, @@ -97,25 +102,26 @@ fun SettingsScreen( onNavigateToHistory: () -> Unit = {}, onNavigateToCollections: () -> Unit = {}, onNavigateToDiagnostics: () -> Unit = {}, - showTopBar: Boolean = false, - onBackClick: (() -> Unit)? = null, viewModel: SettingsViewModel = koinViewModel(), - downloadsViewModel: DownloadsViewModel = koinViewModel(), diagnosticsViewModel: DiagnosticsViewModel = koinViewModel(), + signInViewModel: SignInSettingsViewModel = koinViewModel(), ) { - val recovery = org.prairieserver.prairie.common.player.rememberPlaybackRecoverySettings(org.koin.compose.koinInject()) val state by viewModel.uiState.collectAsState() - var subtitleStyleVisible by remember { mutableStateOf(false) } - org.prairieserver.prairie.android.ui.screens.player.SubtitleStyleSheet( - isVisible = subtitleStyleVisible, - appearance = state.subtitleAppearance, - onUpdate = viewModel::setSubtitleAppearance, - onDismiss = { subtitleStyleVisible = false }, - ) - val downloadsState by downloadsViewModel.uiState.collectAsState() + val signInState by signInViewModel.uiState.collectAsState() val diagnosticsState by diagnosticsViewModel.state.collectAsState() - var showRemoveAllDownloadsConfirm by remember { mutableStateOf(false) } - var showHomeSectionsEditor by remember { mutableStateOf(false) } + val uriHandler = LocalUriHandler.current + var query by rememberSaveable { mutableStateOf("") } + var confirmSignOut by rememberSaveable { mutableStateOf(false) } + val listState = rememberLazyListState() + // The large title scrolls away and the bar takes the title over, the way + // a large navigation title collapses to an inline one. + val titleScrolledAway by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } } + val watchPartyExperiment: org.prairieserver.prairie.common.watchparty.WatchPartyExperiment = + org.koin.compose.koinInject() + val watchPartyEnabled by watchPartyExperiment.enabled.collectAsState() + val profileLaunchPreferences: org.prairieserver.prairie.common.profiles.ProfileLaunchPreferences = + org.koin.compose.koinInject() + val profileLaunchState by profileLaunchPreferences.state.collectAsState() LaunchedEffect(state.loggedOut) { if (state.loggedOut) { @@ -124,783 +130,371 @@ fun SettingsScreen( } } + val search = SettingsSearch(query) + val versionLabel = clientVersionLabel(BuildConfig.VERSION_NAME, BuildConfig.BUILD_NUMBER) + val serverLabel = state.serverName.ifBlank { serverHost(state.serverUrl) ?: "Not connected" } + val showDiagnostics = shouldShowDiagnosticsEntry(diagnosticsState) && + search.matches("diagnostics", "support", "reports", "debug", "crash") + + val matchesInterface = search.matches( + "interface", "appearance", "cards", "posters", "captions", "home", "sections", + "title art", "logos", + ) + val matchesNotifications = state.notificationsAvailable && search.matches( + "notifications", "alerts", "favorites", "watchlist", "continue watching", "next up", + ) + val matchesProfileSelection = search.matches( + "profile selection", "profiles", "who's watching", "launch", "pin", + ) + val matchesPlayback = search.matches( + "playback", "quality", "audio", "dolby vision", "picture-in-picture", "episodes", "next up", + "skip", "intros", "credits", "rewind", "still watching", "skip interval", "audiobooks", + "spoilers", "unwatched", "blur", "descriptions", + ) + val matchesSubtitles = search.matches( + "subtitles", "captions", "language", "behavior", "appearance", "forced", "metadata", + ) + val matchesDownloads = search.matches("downloads", "offline", "quality", "wi-fi", "cleanup", "storage") + val matchesWatchlist = search.matches("library", "watchlist") + val matchesFavorites = search.matches("library", "favorites") + val matchesHistory = search.matches("library", "history", "watch history") + val matchesCollections = search.matches("library", "collections") + val matchesServer = search.matches("server", "connection", serverLabel) + val matchesPairDevice = search.matches("connection", "pair", "device", "tv", "link", "sign in", "quick connect") + // External sign-in (OIDC/LDAP): shown only on servers that have it. + val matchesSignIn = signInState.visible && + search.matches("sign-in", "sign in", "account", "provider", "connect", "disconnect", "sso") + val matchesExperimental = search.matches( + "experimental", "beta", "testing", "audiobooks", "navigation", "watch party", "party", + ) + val matchesAbout = search.matches("about", "version", versionLabel, "privacy", "policy", "information", "update", "updates", "release") + val matchesSignOut = search.matches("sign out", "account") + + val hasResults = matchesInterface || matchesNotifications || matchesProfileSelection || matchesPlayback || matchesSubtitles || + matchesDownloads || matchesWatchlist || matchesFavorites || matchesHistory || matchesCollections || + showDiagnostics || matchesServer || matchesPairDevice || matchesExperimental || + matchesAbout || matchesSignOut || matchesSignIn + Scaffold( topBar = { - if (showTopBar) { - PrairieTopBar( - title = "Settings", - onBackClick = onBackClick, - containerColor = PrairieSettingsBackground, - ) - } + PrairieTopBar( + title = if (titleScrolledAway) "Settings" else "", + onBackClick = onBackClick, + containerColor = PrairieSettingsBackground, + centerTitle = true, + ) }, containerColor = PrairieSettingsBackground, ) { padding -> LazyColumn( + state = listState, modifier = Modifier .fillMaxSize() .padding(padding), - contentPadding = PaddingValues(SettingsDimens.pageGutter), + contentPadding = PaddingValues( + start = SettingsDimens.pageGutter, + end = SettingsDimens.pageGutter, + bottom = SettingsDimens.pageBottomSpacer, + ), verticalArrangement = Arrangement.spacedBy(SettingsDimens.sectionGap), ) { - item { - if (!showTopBar) { + item(key = "title") { + Column { Text( text = "Settings", - style = MaterialTheme.typography.displayMedium, - color = PrairieForeground, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding( - top = SettingsDimens.pageTopPadding, - bottom = SettingsDimens.headerStartInset, - ), + style = SettingsTextStyles.largeTitle, + color = PrairieOnSurface, + ) + Spacer(modifier = Modifier.height(SettingsDimens.headerBottomGap)) + SettingsAccountCard( + profile = state.activeProfile, + user = state.user, + serverHost = serverHost(state.serverUrl), + onClick = onSwitchProfile, ) } } - item { - AccountSection( - onSwitchProfile = onSwitchProfile, - user = state.user, - isLoadingUser = state.isLoadingUser, - onPairDevice = onPairDevice, - onSignOut = viewModel::logout, - ) + item(key = "search") { + SettingsSearchField(query = query, onQueryChange = { query = it }) } - if (recovery.visible) { - item { - SettingsSectionCard { - SettingsNavigationRow( - label = if (recovery.busy) "Recovering playback…" else "Retry pending playback stops", - description = recovery.message, - onClick = recovery.retry, - enabled = !recovery.busy, - ) - } + if (!hasResults) { + item(key = "no-results") { + Text( + text = "No Results for “${query.trim()}”", + style = SettingsTextStyles.rowLabel, + color = PrairieSecondaryText, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 36.dp), + ) } } - if (shouldShowDiagnosticsEntry(diagnosticsState)) { - item { - SettingsSectionCard { - SettingsNavigationRow( - label = "Diagnostics", - description = "Capture and review a report when something goes wrong.", - value = when (diagnosticsState.availability) { - org.prairieserver.prairie.common.diagnostics.DiagnosticsAvailabilityUi.AVAILABLE -> "Available" - org.prairieserver.prairie.common.diagnostics.DiagnosticsAvailabilityUi.DISABLED -> "Disabled" - org.prairieserver.prairie.common.diagnostics.DiagnosticsAvailabilityUi.STORAGE_UNAVAILABLE -> "Unavailable" - org.prairieserver.prairie.common.diagnostics.DiagnosticsAvailabilityUi.OFFLINE -> "Offline" - org.prairieserver.prairie.common.diagnostics.DiagnosticsAvailabilityUi.INELIGIBLE -> null - }, - onClick = onNavigateToDiagnostics, - ) + + if (matchesInterface || matchesNotifications || matchesProfileSelection) { + item(key = "preferences") { + SettingsSection(title = "Preferences") { + if (matchesInterface) { + SettingsNavigationRow( + label = "Interface", + icon = Icons.Filled.Dashboard, + value = state.cardPresentation.takeIf { + it.support != CardPresentationSupport.Unsupported + }?.let { it.presentation.preset?.displayName ?: "Custom" }, + onClick = onOpenInterface, + ) + } + if (matchesNotifications) { + SettingsNavigationRow( + label = "Notifications", + icon = Icons.Filled.Notifications, + value = if (state.notificationsEnabled) "On" else "Off", + onClick = onOpenNotifications, + ) + } + if (matchesProfileSelection) { + SettingsNavigationRow( + label = "Profile Selection", + icon = Icons.Filled.SwitchAccount, + value = profileLaunchState.behavior.title, + onClick = onOpenProfileSelection, + ) + } } } } - item { - PlaybackSettings( - qualityResolution = state.qualityResolution, - maxBitrateKbps = state.maxBitrateKbps, - audioLanguage = state.audioLanguage, - audioLanguageSuggestions = state.audioLanguageSuggestions, - introSkipMode = state.introSkipMode, - autoSkipCredits = state.autoSkipCredits, - pictureInPictureEnabled = state.pictureInPictureEnabled, - dolbyVisionEnabled = state.dolbyVisionEnabled, - dvProfile7HDR10Fallback = state.dvProfile7HDR10Fallback, - autoPlayNext = state.autoPlayNext, - nextUpPromptSeconds = state.nextUpPromptSeconds, - resumeRewindSeconds = state.resumeRewindSeconds, - passOutThreshold = state.passOutThreshold, - onQualityPresetSelected = viewModel::setQualityPreset, - onAudioLanguageChanged = viewModel::setAudioLanguage, - onIntroSkipModeChanged = viewModel::setIntroSkipMode, - onAutoSkipCreditsChanged = viewModel::setAutoSkipCredits, - onPictureInPictureEnabledChanged = viewModel::setPictureInPictureEnabled, - onDolbyVisionEnabledChanged = viewModel::setDolbyVisionEnabled, - onDvProfile7HDR10FallbackChanged = viewModel::setDvProfile7HDR10Fallback, - onAutoPlayNextChanged = viewModel::setAutoPlayNext, - onNextUpPromptSecondsChanged = viewModel::setNextUpPromptSeconds, - onResumeRewindSecondsChanged = viewModel::setResumeRewindSeconds, - onPassOutThresholdChanged = viewModel::setPassOutThreshold, - onResetPlaybackOverrides = viewModel::resetPlaybackOverrides, - ) - } - - item { - val seekIntervals by viewModel.seekIntervals.state.collectAsState() - SeekIntervalSettings( - media = SeekMedia.Video, - state = seekIntervals, - onIntervalSelected = viewModel.seekIntervals::select, - onImportLegacyAudiobook = viewModel.seekIntervals::importLegacyAudiobook, - onRetry = viewModel.seekIntervals::refresh, - ) - } - - item { - val seekIntervals by viewModel.seekIntervals.state.collectAsState() - SeekIntervalSettings( - media = SeekMedia.Audiobook, - state = seekIntervals, - onIntervalSelected = viewModel.seekIntervals::select, - onImportLegacyAudiobook = viewModel.seekIntervals::importLegacyAudiobook, - onRetry = viewModel.seekIntervals::refresh, - ) + if (matchesPlayback || matchesSubtitles || matchesDownloads) { + item(key = "playback") { + SettingsSection(title = "Playback") { + if (matchesPlayback) { + SettingsNavigationRow( + label = "Playback", + icon = Icons.Filled.PlayArrow, + value = QualityPresets.describe(state.qualityResolution, state.maxBitrateKbps), + onClick = onOpenPlayback, + ) + } + if (matchesSubtitles) { + SettingsNavigationRow( + label = "Subtitles", + icon = Icons.Filled.Subtitles, + value = LanguageOptions.label(state.subtitleLanguage, SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE), + onClick = onOpenSubtitles, + ) + } + if (matchesDownloads) { + SettingsNavigationRow( + label = "Downloads", + icon = Icons.Filled.ArrowCircleDown, + onClick = onOpenDownloads, + ) + } + } + } } - item { - val metadataAiStore: MetadataAiFeatureStore = koinInject() - val metadataAiStatus by metadataAiStore.status.collectAsState() - SubtitleSettings( - subtitleLanguage = state.subtitleLanguage, - subtitleLanguageSuggestions = state.subtitleLanguageSuggestions, - subtitleMode = state.subtitleMode, - showForcedSubtitles = state.showForcedSubtitles, - onLanguageChanged = viewModel::setSubtitleLanguage, - onModeChanged = viewModel::setSubtitleMode, - onForcedSubtitlesChanged = viewModel::setShowForcedSubtitles, - subtitleMatchesDevice = state.subtitleMatchesDevice, - onSubtitleMatchesDeviceChanged = viewModel::setSubtitleMatchesDevice, - onOpenSubtitleAppearance = { subtitleStyleVisible = true }, - metadataLanguageEnabled = metadataAiStatus.enabled && - metadataAiStatus.onView != MetadataAiOnView.Off, - metadataLanguage = state.metadataLanguage, - metadataLanguageSuggestions = state.metadataLanguageSuggestions, - onMetadataLanguageChanged = viewModel::setMetadataLanguage, - ) + if (matchesWatchlist || matchesFavorites || matchesHistory || matchesCollections) { + item(key = "library") { + SettingsSection(title = "Library") { + if (matchesWatchlist) { + SettingsNavigationRow( + label = "Watchlist", + icon = Icons.Filled.Bookmark, + onClick = onNavigateToWatchlist, + ) + } + if (matchesFavorites) { + SettingsNavigationRow( + label = "Favorites", + icon = Icons.Filled.Favorite, + onClick = onNavigateToFavorites, + ) + } + if (matchesHistory) { + SettingsNavigationRow( + label = "Watch History", + icon = Icons.Filled.History, + onClick = onNavigateToHistory, + ) + } + if (matchesCollections) { + SettingsNavigationRow( + label = "Collections", + icon = Icons.Filled.CollectionsBookmark, + onClick = onNavigateToCollections, + ) + } + } + } } - item { - SettingsSection(title = "Library") { - SettingsNavigationRow( - label = "Watchlist", - description = "Titles you saved to watch later.", - onClick = onNavigateToWatchlist, - ) - SettingsNavigationRow( - label = "Favorites", - description = "Titles you marked as favorites.", - onClick = onNavigateToFavorites, - ) - SettingsNavigationRow( - label = "Watch history", - description = "Everything you have played, most recent first.", - onClick = onNavigateToHistory, - ) - SettingsNavigationRow( - label = "Collections", - description = "Curated groups of titles from your libraries.", - onClick = onNavigateToCollections, - ) - SettingsSwitchRow( - label = "Show audiobooks", - description = "Show the Audiobooks section in navigation.", - checked = state.showAudiobooks, - onCheckedChange = viewModel::setShowAudiobooks, - ) + if (showDiagnostics) { + item(key = "support") { + SettingsSection(title = "Support") { + SettingsNavigationRow( + label = "Diagnostics", + icon = Icons.Filled.MonitorHeart, + value = diagnosticsAvailabilityLabel(diagnosticsState.availability), + onClick = onNavigateToDiagnostics, + ) + } } } - item { - SettingsSection(title = "Interface") { - SettingsNavigationRow( - label = "Home Sections", - description = "Choose which Home rows are visible and the order they appear in.", - onClick = { showHomeSectionsEditor = true }, - ) + if (matchesServer || matchesPairDevice) { + item(key = "connection") { + SettingsSection(title = "Connection") { + if (matchesServer) { + SettingsNavigationRow( + label = "Server", + icon = Icons.Filled.Dns, + value = serverLabel, + onClick = onNavigateToServers, + ) + } + if (matchesPairDevice) { + // Prairie: "Quick Connect" naming (12925ada), anchored in + // scripts/prairie-invariants.txt. + SettingsNavigationRow( + label = "Quick Connect", + description = "Link a TV or another device to this account.", + icon = Icons.Filled.ConnectedTv, + onClick = onPairDevice, + ) + } + } } } - item { - MediaCardsSettings( - state = state.cardPresentation, - onPresetSelected = viewModel::setCardPreset, - onPosterSizeSelected = viewModel::setCardPosterSize, - onCaptionSelected = viewModel::setCardCaption, - onDeviceOnlyChanged = viewModel::setCardDeviceOnly, - onUseProfileDefault = viewModel::useCardProfileDefault, - ) + if (matchesSignIn) { + item(key = "sign-in") { + SignInSection(viewModel = signInViewModel) + } } - if (state.notificationsAvailable) { - item { - SettingsSection(title = "Notifications") { + if (matchesExperimental) { + item(key = "experimental") { + SettingsSection(title = "Experimental") { SettingsSwitchRow( - label = "In-app notifications", - description = "Show alerts inside Prairie as new releases arrive.", - checked = state.notificationsEnabled, - onCheckedChange = viewModel::setNotificationsEnabled, + label = "Show Audiobooks", + icon = Icons.AutoMirrored.Filled.MenuBook, + checked = state.showAudiobooks, + onCheckedChange = viewModel::setShowAudiobooks, + ) + // Device-local; never synced with the server's settings. + SettingsSwitchRow( + label = "Watch Party", + description = "Try Watch Party before it's finished. Turning it off leaves any party you're in.", + icon = Icons.Filled.Groups, + checked = watchPartyEnabled, + onCheckedChange = watchPartyExperiment::setEnabled, ) - if (state.notificationsEnabled) { - SettingsSwitchRow( - label = "Favorites", - description = "Notify when something you favorited has a new episode.", - checked = state.notifyFavorites, - onCheckedChange = viewModel::setNotifyFavorites, - ) - SettingsSwitchRow( - label = "Watchlist", - description = "Notify when something on your watchlist becomes available.", - checked = state.notifyWatchlist, - onCheckedChange = viewModel::setNotifyWatchlist, - ) - SettingsSwitchRow( - label = "Continue watching", - description = "Notify about titles you started but have not finished.", - checked = state.notifyContinueWatching, - onCheckedChange = viewModel::setNotifyContinueWatching, - ) - SettingsSwitchRow( - label = "Next up", - description = "Notify when the next episode of a series you watch arrives.", - checked = state.notifyNextUp, - onCheckedChange = viewModel::setNotifyNextUp, - ) - } } } } - item { - SettingsSection(title = "Downloads") { - SettingsDropdownRow( - label = "Download quality", - description = "Quality preset used for new downloads.", - value = state.defaultDownloadQuality, - options = state.downloadQualityOptions, - onOptionSelected = viewModel::setDefaultDownloadQuality, - ) - SettingsSwitchRow( - label = "Download over Wi-Fi only", - description = "Only download while connected to Wi-Fi.", - checked = state.downloadsWifiOnly, - onCheckedChange = viewModel::setDownloadsWifiOnly, - ) - SettingsSwitchRow( - label = "Keep watched downloads", - description = "Do not suggest reclaiming space from downloads you have finished.", - checked = state.keepWatchedDownloads, - onCheckedChange = viewModel::setKeepWatchedDownloads, - ) - if (!downloadsState.isEmpty || downloadsState.totalBytesUsed > 0L) { - SettingsDestructiveRow( - label = if (downloadsState.isRemovingAllDownloads) { - "Removing downloads…" - } else { - "Remove all downloads" - }, - description = "Delete every downloaded file from this device.", - value = formatBytes(downloadsState.totalBytesUsed), - enabled = !downloadsState.isRemovingAllDownloads, - onClick = { showRemoveAllDownloadsConfirm = true }, + if (matchesAbout) { + item(key = "about") { + SettingsSection(title = "About") { + // Includes the build number so a support report and the + // server's admin Activity page name the exact same build, + // in the "1.0.0 (5)" form Play, TestFlight and the server's + // diagnostics page all use. + SettingsNavigationRow( + label = "Version", + icon = Icons.Filled.Info, + value = versionLabel, + ) + AppUpdateStatusRow() + SettingsNavigationRow( + label = "Privacy Policy", + icon = Icons.Filled.PanTool, + onClick = { uriHandler.openUri(PRAIRIE_PRIVACY_POLICY_URL) }, ) } } } - item { - ServerInfoSection( - serverUrl = state.serverUrl, - onManageServersClick = onNavigateToServers, - ) + if (matchesSignOut) { + item(key = "sign-out") { + SettingsSectionCard { + SettingsButtonRow(label = "Sign Out", onClick = { confirmSignOut = true }) + } + } } - - // Bottom spacing - item { Spacer(modifier = Modifier.height(SettingsDimens.pageBottomSpacer)) } } } - if (showRemoveAllDownloadsConfirm) { - PrairieConfirmDialog( - title = "Remove all downloads?", - body = "This removes ${formatBytes(downloadsState.totalBytesUsed)} of downloaded files " + - "from this device. Your library and server media stay intact.", - confirmLabel = if (downloadsState.isRemovingAllDownloads) "Removing…" else "Remove all", - confirmEnabled = !downloadsState.isRemovingAllDownloads, - onConfirm = { - showRemoveAllDownloadsConfirm = false - downloadsViewModel.removeAllDownloads() - }, - onDismiss = { showRemoveAllDownloadsConfirm = false }, - ) - } - - if (showHomeSectionsEditor) { - HomeSectionsEditor(onDismiss = { showHomeSectionsEditor = false }) - } -} - -// --- Shared Settings UI Components --- -// -// The grouped surface these build is the Silo web client's, adapted to Android -// row mechanics: an opaque card on a lifted page ground, a lettered heading -// above rather than inside it, a label over a muted description, and the -// control kept trailing rather than stacked underneath the way the web layout -// stacks it. Metrics live in `ui.theme.SettingsDimens` / `SettingsTextStyles`. -// -// There is deliberately no leading icon on any row. The web client puts icons -// only in its settings *sidebar*, never on a row; Android has no sidebar, so -// its destination rows sit inline among the value rows and an icon on half of -// them is exactly the "iOS Settings at the ends, unstyled form in the middle" -// split this pass removed. The description line is the scanning aid now, and -// the trailing affordance (chevron / value / switch) is what separates a -// destination from a setting. - -/** - * Per-card row counter backing the "no divider above the first row" rule. - * - * Rows claim a slot on first composition and remember it, so the index is - * stable across recomposition and follows source order within the card. A card - * whose *first* row is conditional would need [SettingsRow]'s `showDivider` - * override — no section does that today, since every card's opening row is - * unconditional. - */ -@Stable -internal class SettingsSectionSlots { - private var next = 0 - - fun claim(): Int = next++ -} - -internal val LocalSettingsSectionSlots = staticCompositionLocalOf { null } - -/** True for the first row composed into the enclosing [SettingsSectionCard]. */ -@Composable -private fun isFirstSettingsRow(): Boolean { - val slots = LocalSettingsSectionSlots.current ?: return true - return remember(slots) { slots.claim() } == 0 -} - -/** - * Claims a row slot for a card child that is not a [SettingsRow] — the account - * header, a prose pane — and reports whether it should draw a hairline above - * itself. A custom child that skips this is invisible to the divider rule, and - * the row after it would wrongly believe it is the card's first. - * - * Pair with [settingsRowDivider]. - */ -@Composable -fun settingsRowDividerVisible(): Boolean = !isFirstSettingsRow() - -/** Draws the standard inter-row hairline along this element's top edge. */ -fun Modifier.settingsRowDivider(show: Boolean): Modifier = settingsRowTopDivider(show) - -// The hairline itself lives in `ui.theme` beside the tokens it draws with: -// the popup menus rule their rows the same way, and one line rendered by two -// implementations is how two lines end up different. -private fun Modifier.settingsRowTopDivider(show: Boolean): Modifier = siloRowTopDivider(show) - -/** - * A settings group: a lettered heading sitting above its card. - * - * The heading used to render *inside* the card as its first child, which is - * what made every section start with a stray caps line on the same surface as - * the rows. Its own KDoc always described the intended placement. - */ -@Composable -fun SettingsSection( - title: String?, - modifier: Modifier = Modifier, - content: @Composable ColumnScope.() -> Unit, -) { - Column(modifier = modifier.fillMaxWidth()) { - if (!title.isNullOrBlank()) { - SettingsSectionHeader(title) - } - SettingsSectionCard(content = content) - } -} - -/** - * Card container for a settings section, and the owner of the row dividers. - */ -@Composable -fun SettingsSectionCard( - modifier: Modifier = Modifier, - content: @Composable ColumnScope.() -> Unit, -) { - val slots = remember { SettingsSectionSlots() } - CompositionLocalProvider(LocalSettingsSectionSlots provides slots) { - Column( - modifier = modifier - .fillMaxWidth() - .clip(RoundedCornerShape(SettingsDimens.cardRadius)) - .background(PrairieSurfaceContainer), - content = content, - ) - } -} - -/** - * Section heading — uppercased, letter-spaced, muted, sitting above the card - * with a small inset. - */ -@Composable -fun SettingsSectionHeader(title: String) { - Text( - text = title.uppercase(), - style = SettingsTextStyles.sectionHeader, - color = PrairieMutedText, - modifier = Modifier.padding( - start = SettingsDimens.headerStartInset, - end = SettingsDimens.headerStartInset, - bottom = SettingsDimens.headerBottomGap, - ), + // Same dialog the profile menu raises, so the answer to "are you sure?" + // does not depend on which of the two routes was taken. + SignOutConfirmDialog( + visible = confirmSignOut, + accountName = state.user?.username, + onConfirm = { + confirmSignOut = false + viewModel.logout() + }, + onDismiss = { confirmSignOut = false }, ) } /** - * Disclosure chevron. + * The overview search (Apple `IOSSettingsOverview.matches`): a row shows when + * any of its keywords contains the trimmed query, case-insensitively. An + * empty query shows everything. */ -@Composable -fun SettingsRowChevron(enabled: Boolean = true) { - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - tint = PrairieMutedText.copy(alpha = if (enabled) 1f else SettingsDimens.disabledAlpha), - modifier = Modifier.size(SettingsDimens.chevronSize), - ) -} +private class SettingsSearch(query: String) { + private val needle = query.trim() -/** - * The one settings row. - * - * Every other row type in this package is this one with a different trailing - * slot: label over an optional description, control trailing, a 60dp floor so - * a described row and a bare row still read as the same list, and a hairline - * above every row but the card's first. - * - * A trailing [value] shares the *label's* line rather than sitting beside the - * whole text block. That is the structural half of a real defect: with the - * value beside the block, a long one ("30 seconds before end") squeezed the - * description into a narrow column whose last line then ended a few dp from - * the value, and the two read as touching. On the label's line the value can - * never abut the description — the description runs the full width beneath it - * — and the wider column costs a line rather than adding one, so the page gets - * shorter, not taller. Only [trailing] controls (chevron, switch, radio) sit - * beside the block now, and an icon at [SettingsDimens.rowTrailingGap] does - * not read as a collision the way text does. - * - * @param showDivider Overrides the automatic first-row rule. Only needed in a - * card whose opening row is conditional. - */ -@Composable -fun SettingsRow( - label: String, - modifier: Modifier = Modifier, - description: String? = null, - value: String? = null, - labelColor: Color = PrairieForeground, - enabled: Boolean = true, - onClick: (() -> Unit)? = null, - showDivider: Boolean? = null, - trailing: @Composable RowScope.() -> Unit = {}, -) { - val contentAlpha = if (enabled) 1f else SettingsDimens.disabledAlpha - val divider = showDivider ?: !isFirstSettingsRow() - Row( - modifier = modifier - .fillMaxWidth() - .heightIn(min = SettingsDimens.rowMinHeight) - .settingsRowTopDivider(divider) - .then( - if (onClick != null) { - Modifier.clickable(enabled = enabled, onClick = onClick) - } else { - Modifier - }, - ) - .padding( - horizontal = SettingsDimens.rowHorizontalPadding, - vertical = SettingsDimens.rowVerticalPadding, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(SettingsDimens.rowLabelGap), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = label, - style = SettingsTextStyles.rowLabel, - color = labelColor.copy(alpha = contentAlpha), - // Fills the line so the value stays trailing-aligned, and - // yields — by wrapping — when a capped value needs room. - modifier = Modifier.weight(1f), - ) - if (value != null) { - Spacer(modifier = Modifier.width(SettingsDimens.rowLabelValueGap)) - SettingsRowValue(value = value, enabled = enabled) - } - } - if (!description.isNullOrBlank()) { - Text( - text = description, - style = SettingsTextStyles.rowDescription, - color = PrairieMutedText.copy(alpha = contentAlpha), - ) - } - } - trailing() - } + fun matches(vararg terms: String): Boolean = + needle.isEmpty() || terms.any { it.contains(needle, ignoreCase = true) } } +/** The host of [serverUrl], or null when there is no server. */ /** - * A row that navigates somewhere, optionally showing the current value. - * - * Replaces the old `SettingsRowLabel` (iOS coloured badge) and - * `SettingsClickableRow` (bare 20dp icon), which differed only in their - * leading treatment. + * Prairie-only (b3b04cbb): GitHub Releases update check for sideloaded builds. + * Self-contained (checker from Koin, no ViewModel plumbing) so an upstream + * rewrite of the settings ViewModel cannot silently drop it again. Lived in + * ServerInfoSection until upstream's Settings re-layout (#445) deleted that file. */ @Composable -fun SettingsNavigationRow( - label: String, - modifier: Modifier = Modifier, - description: String? = null, - value: String? = null, - onClick: (() -> Unit)? = null, - showChevron: Boolean = onClick != null, - enabled: Boolean = true, - labelColor: Color = PrairieForeground, -) { - SettingsRow( - label = label, - modifier = modifier, - description = description, - value = value, - labelColor = labelColor, - enabled = enabled, - onClick = onClick, - ) { - if (showChevron) { - Spacer(modifier = Modifier.width(SettingsDimens.rowTrailingGap)) - SettingsRowChevron(enabled = enabled) - } +private fun AppUpdateStatusRow() { + val checker: AppUpdateChecker = org.koin.compose.koinInject() + val uriHandler = LocalUriHandler.current + val status by produceState(AppUpdateStatus.Checking, checker) { + value = checker.check(BuildConfig.VERSION_NAME) } -} - -/** - * Destructive row. One tint, [PrairieDestructive], for every destructive action - * on this surface — Sign out, Reset playback settings, Remove all downloads — - * which previously used three different layouts and two different reds. - */ -@Composable -fun SettingsDestructiveRow( - label: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, - description: String? = null, - value: String? = null, - enabled: Boolean = true, -) { + val targetUrl = status.releaseUrlOrNull() ?: status.changelogUrlOrNull() + val latest = status.latestVersionLabel() SettingsNavigationRow( - label = label, - modifier = modifier, - description = description, - value = value, - onClick = onClick, - showChevron = false, - enabled = enabled, - labelColor = PrairieDestructive, - ) -} - -/** - * Trailing value text — smaller and muted, so a picker's current choice does - * not read as a second label. - * - * Unweighted, so [SettingsRow]'s label line measures it first: it gets the - * width it asks for up to [SettingsDimens.rowValueMaxWidth], and the label - * takes what is left. - */ -@Composable -private fun SettingsRowValue(value: String, enabled: Boolean) { - Text( - text = value, - style = SettingsTextStyles.rowValue, - color = PrairieMutedText.copy(alpha = if (enabled) 1f else SettingsDimens.disabledAlpha), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.End, - modifier = Modifier.widthIn(max = SettingsDimens.rowValueMaxWidth), + label = "Updates", + description = if (status is AppUpdateStatus.UpdateAvailable && latest != null) { + "Prairie $latest is available." + } else { + "Checks Prairie releases on GitHub." + }, + value = status.statusLabel(), + icon = Icons.Filled.SystemUpdate, + onClick = targetUrl?.let { url -> { runCatching { uriHandler.openUri(url) } } }, ) } -/** - * Settings row with a switch toggle. The whole row toggles, not just the - * thumb, and [enabled] now exists — the diagnostics screen used to hand-roll - * its own copy of this row purely to get a disabled switch. - */ -@Composable -fun SettingsSwitchRow( - label: String, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, - modifier: Modifier = Modifier, - description: String? = null, - enabled: Boolean = true, -) { - SettingsRow( - label = label, - description = description, - enabled = enabled, - modifier = modifier.toggleable( - value = checked, - enabled = enabled, - role = Role.Switch, - onValueChange = onCheckedChange, - ), - ) { - Spacer(modifier = Modifier.width(SettingsDimens.rowTrailingGap)) - Switch( - checked = checked, - // The row owns the gesture; the switch is the indicator. - onCheckedChange = null, - enabled = enabled, - colors = settingsSwitchColors(), - ) - } -} - -@Composable -private fun settingsSwitchColors() = SwitchDefaults.colors( - checkedThumbColor = PrairieSurfaceContainer, - checkedTrackColor = PrairieForeground, - checkedBorderColor = Color.Transparent, - uncheckedThumbColor = PrairieMutedText, - uncheckedTrackColor = PrairieSurfaceContainerHigh, - uncheckedBorderColor = PrairieBorder, - disabledCheckedThumbColor = PrairieSurfaceContainer, - disabledCheckedTrackColor = PrairieForeground.copy(alpha = SettingsDimens.disabledAlpha), - disabledCheckedBorderColor = Color.Transparent, - disabledUncheckedThumbColor = PrairieMutedText.copy(alpha = SettingsDimens.disabledAlpha), - disabledUncheckedTrackColor = PrairieSurfaceContainerHigh.copy(alpha = SettingsDimens.disabledAlpha), - disabledUncheckedBorderColor = PrairieBorder.copy(alpha = SettingsDimens.disabledAlpha), -) - -/** - * Settings row for one option in a mutually exclusive set. Like the switch - * row, the whole row is the target. - */ -@Composable -fun SettingsChoiceRow( - label: String, - selected: Boolean, - onSelect: () -> Unit, - modifier: Modifier = Modifier, - description: String? = null, - enabled: Boolean = true, -) { - SettingsRow( - label = label, - description = description, - enabled = enabled, - modifier = modifier.selectable( - selected = selected, - enabled = enabled, - role = Role.RadioButton, - onClick = onSelect, - ), - ) { - Spacer(modifier = Modifier.width(SettingsDimens.rowTrailingGap)) - RadioButton( - selected = selected, - // The row owns the gesture; the button is the indicator. - onClick = null, - enabled = enabled, - colors = RadioButtonDefaults.colors( - selectedColor = PrairieForeground, - unselectedColor = PrairieMutedText, - disabledSelectedColor = PrairieForeground.copy(alpha = SettingsDimens.disabledAlpha), - disabledUnselectedColor = PrairieMutedText.copy(alpha = SettingsDimens.disabledAlpha), - ), - ) - } -} - -/** - * A settings row that opens a menu of options. - * - * Trailing value plus a chevron, so a picker reads as something you can open - * rather than as a read-only fact. - */ -@Composable -fun SettingsDropdownRow( - label: String, - value: String, - options: List, - onOptionSelected: (String) -> Unit, - modifier: Modifier = Modifier, - description: String? = null, - enabled: Boolean = true, -) { - var expanded by remember { mutableStateOf(false) } - - Box(modifier = modifier) { - SettingsNavigationRow( - label = label, - description = description, - value = value, - enabled = enabled, - onClick = { expanded = true }, - ) - - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - options.forEach { option -> - DropdownMenuItem( - text = { Text(option) }, - onClick = { - onOptionSelected(option) - expanded = false - }, - ) - } - } - } +private fun serverHost(serverUrl: String): String? { + if (serverUrl.isBlank()) return null + return serverUrl + .substringAfter("://", serverUrl) + .substringBefore('/') + .ifBlank { serverUrl } } -/** - * A prose block inside a card, for the notices that are explanation rather - * than setting. Carries the same divider rule as a row. - */ -@Composable -fun SettingsProse( - body: String, - modifier: Modifier = Modifier, - title: String? = null, -) { - val divider = !isFirstSettingsRow() - Column( - modifier = modifier - .fillMaxWidth() - .settingsRowTopDivider(divider) - .padding( - horizontal = SettingsDimens.proseHorizontalPadding, - vertical = SettingsDimens.proseVerticalPadding, - ), - verticalArrangement = Arrangement.spacedBy(SettingsDimens.rowLabelGap), - ) { - if (title != null) { - Text( - text = title, - style = SettingsTextStyles.rowLabel, - color = PrairieForeground, - ) - } - Text( - text = body, - style = SettingsTextStyles.rowDescription, - color = PrairieMutedText, - ) - } +/** The Diagnostics row's value, in the Apple apps' wording. */ +private fun diagnosticsAvailabilityLabel(availability: DiagnosticsAvailabilityUi): String? = when (availability) { + DiagnosticsAvailabilityUi.AVAILABLE -> "Available" + DiagnosticsAvailabilityUi.DISABLED -> "Disabled by server" + DiagnosticsAvailabilityUi.STORAGE_UNAVAILABLE -> "Storage unavailable" + DiagnosticsAvailabilityUi.OFFLINE -> "Offline" + DiagnosticsAvailabilityUi.INELIGIBLE -> null } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsViewModel.kt index 1fdf9b0e10..01d429914d 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsViewModel.kt @@ -3,9 +3,13 @@ package org.prairieserver.prairie.android.ui.screens.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import org.prairieserver.prairie.model.profile.ActiveProfileStore +import org.prairieserver.prairie.android.auth.SignOutTeardown import org.prairieserver.prairie.common.settings.CardPresentationSource import org.prairieserver.prairie.common.settings.CardPresentationStore import org.prairieserver.prairie.common.settings.CardPresentationUiState +import org.prairieserver.prairie.common.settings.EpisodeSpoilerSetting +import org.prairieserver.prairie.common.settings.EpisodeSpoilerState +import org.prairieserver.prairie.common.settings.EpisodeSpoilerStore import org.prairieserver.prairie.common.settings.LibraryPlaybackPrefsStore import org.prairieserver.prairie.common.settings.OverlayPrefsStore import org.prairieserver.prairie.common.settings.PlayerSettingsStore @@ -15,6 +19,7 @@ import org.prairieserver.prairie.common.player.AudiobookSettingsStore import org.prairieserver.prairie.domain.player.IntroSkipMode import org.prairieserver.prairie.domain.settings.ProfileSettingsController import org.prairieserver.prairie.model.auth.User +import org.prairieserver.prairie.model.profile.Profile import org.prairieserver.prairie.model.download.DownloadQuality import org.prairieserver.prairie.model.download.effectiveDefault import org.prairieserver.prairie.model.download.labelFor @@ -26,6 +31,7 @@ import org.prairieserver.prairie.model.settings.CardPresentation import org.prairieserver.prairie.model.settings.CardPresentationPreset import org.prairieserver.prairie.model.settings.QualityPresets import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.ServerRegistry import org.prairieserver.prairie.repository.AuthRepository import org.prairieserver.prairie.repository.NotificationsRepository import kotlinx.coroutines.flow.MutableStateFlow @@ -56,6 +62,10 @@ data class SettingsUiState( // Account val user: User? = null, val serverUrl: String = "", + // The account card shows the active profile's name and avatar, and the + // Server row the server's display name, as the Apple apps do. + val activeProfile: Profile? = null, + val serverName: String = "", val isLoadingUser: Boolean = false, val loggedOut: Boolean = false, @@ -79,15 +89,35 @@ data class SettingsUiState( val autoSkipCredits: Boolean = false, val pictureInPictureEnabled: Boolean = true, val dolbyVisionEnabled: Boolean = true, - val dvProfile7HDR10Fallback: Boolean = true, + val dvProfile7HDR10Fallback: Boolean = false, val subtitleMatchesDevice: Boolean = false, val showAudiobooks: Boolean = false, val subtitleAppearance: org.prairieserver.prairie.model.settings.SubtitleAppearance = org.prairieserver.prairie.model.settings.SubtitleAppearance.DEFAULT, + /** + * What playback draws: [subtitleAppearance], or the device caption style + * while Use Device Settings is on. The preview shows this; the editors + * edit [subtitleAppearance]. + */ + val effectiveSubtitleAppearance: org.prairieserver.prairie.model.settings.SubtitleAppearance = + org.prairieserver.prairie.model.settings.SubtitleAppearance.DEFAULT, + /** False when the server is known to discard subtitle text opacity. */ + val subtitleTextOpacitySupported: Boolean = true, // Up Next card: auto-play the next episode at countdown expiry, and how // many seconds before the end to surface the card (0 = only at end). val autoPlayNext: Boolean = true, val nextUpPromptSeconds: Int = 30, + /** + * Keys this device holds its own value for. A profile-layered control + * whose key is absent shows "Use profile setting" as its choice. + */ + val deviceOverrides: Set = emptySet(), + /** + * Outcome of the last "Use Profile Settings", until the screen has told the + * user: true when the server confirmed every clear, false when it + * couldn't (offline, refused, or interrupted). + */ + val playbackOverridesReset: Boolean? = null, // Seconds to skip back on resume (0 = off); consecutive auto-advances // before the "Still watching?" prompt (0 = off). val resumeRewindSeconds: Int = 7, @@ -115,6 +145,10 @@ data class SettingsUiState( // Media cards: the effective `ui.card_presentation` value plus where it // resolved from and whether the server supports the key at all. val cardPresentation: CardPresentationUiState = CardPresentationUiState(), + /** Spoiler protection for unwatched episodes; toggles show only when supported. */ + val episodeSpoilers: EpisodeSpoilerState = EpisodeSpoilerState(), + /** Why the last spoiler switch change wasn't saved. */ + val episodeSpoilerSaveError: String? = null, // Notifications (in-app). Section is hidden entirely unless the server // reports in-app notifications are enabled AND preferences load. @@ -129,15 +163,16 @@ data class SettingsUiState( class SettingsViewModel( private val authRepository: AuthRepository, private val playerSettingsStore: PlayerSettingsStore, - private val libraryPlaybackPrefsStore: LibraryPlaybackPrefsStore, - private val overlayPrefsStore: OverlayPrefsStore, private val activeProfileStore: ActiveProfileStore, private val notificationsRepository: NotificationsRepository, private val profileSettings: ProfileSettingsController, private val cardPresentationStore: CardPresentationStore, private val seekIntervalStore: SeekIntervalStore, + private val episodeSpoilerStore: EpisodeSpoilerStore, + private val signOutTeardown: SignOutTeardown, audiobookSettingsStore: AudiobookSettingsStore, private val downloadsRepository: DownloadsRepository? = null, + private val serverRegistry: ServerRegistry? = null, ) : ViewModel() { private val _uiState = MutableStateFlow(SettingsUiState()) @@ -155,14 +190,27 @@ class SettingsViewModel( init { loadUserInfo() + observeAccountCard() observePlayerSettings() observePlaybackBehaviorSettings() observeNotifications() observeCardPresentation() + observeEpisodeSpoilers() // Opening Settings is a refresh edge for the seek-interval support probe. seekIntervals.refresh() } + private fun observeAccountCard() { + activeProfileStore.activeProfile.onEach { profile -> + _uiState.update { it.copy(activeProfile = profile) } + }.launchIn(viewModelScope) + serverRegistry?.activeEntry?.onEach { entry -> + _uiState.update { it.copy(serverName = entry?.displayName.orEmpty()) } + }?.launchIn(viewModelScope) + // Cached after the first fetch, so this is cheap on every later visit. + viewModelScope.launch { activeProfileStore.refresh() } + } + private fun loadUserInfo() { viewModelScope.launch { _uiState.update { it.copy(isLoadingUser = true) } @@ -279,7 +327,17 @@ class SettingsViewModel( }.launchIn(viewModelScope) playerSettingsStore.subtitleAppearanceFlow.onEach { appearance -> _uiState.update { it.copy(subtitleAppearance = appearance) } - }.launchIn(viewModelScope) } + }.launchIn(viewModelScope) + playerSettingsStore.effectiveSubtitleAppearanceFlow.onEach { appearance -> + _uiState.update { it.copy(effectiveSubtitleAppearance = appearance) } + }.launchIn(viewModelScope) + playerSettingsStore.subtitleTextOpacitySupportedFlow.onEach { supported -> + _uiState.update { it.copy(subtitleTextOpacitySupported = supported) } + }.launchIn(viewModelScope) + playerSettingsStore.deviceOverrideKeysFlow.onEach { keys -> + _uiState.update { it.copy(deviceOverrides = keys) } + }.launchIn(viewModelScope) + } fun setDownloadsWifiOnly(value: Boolean) { viewModelScope.launch { playerSettingsStore.setDownloadsWifiOnly(value) } @@ -424,6 +482,28 @@ class SettingsViewModel( viewModelScope.launch { cardPresentationStore.useProfileDefault() } } + // -- Spoilers -- + + private fun observeEpisodeSpoilers() { + episodeSpoilerStore.state.onEach { state -> + _uiState.update { it.copy(episodeSpoilers = state) } + }.launchIn(viewModelScope) + episodeSpoilerStore.saveError.onEach { error -> + _uiState.update { it.copy(episodeSpoilerSaveError = error) } + }.launchIn(viewModelScope) + // Opening Settings is a refresh edge, so a change made on another + // device shows here without waiting for the next foreground. + viewModelScope.launch { episodeSpoilerStore.refresh() } + } + + fun setHideUnwatchedEpisodeImages(enabled: Boolean) { + episodeSpoilerStore.set(EpisodeSpoilerSetting.Images, enabled) + } + + fun setHideUnwatchedEpisodeOverviews(enabled: Boolean) { + episodeSpoilerStore.set(EpisodeSpoilerSetting.Overviews, enabled) + } + fun setNotificationsEnabled(value: Boolean) { updateNotificationPreferences(NotificationPreferencesUpdate(enabled = value)) } @@ -454,16 +534,9 @@ class SettingsViewModel( fun logout() { viewModelScope.launch { - // Push any in-flight settings before tearing down the session. - playerSettingsStore.flushPendingDeviceSettings() - authRepository.logout() - // Drop per-profile cached prefs so the next user doesn't see - // stale rows flash before the fresh fetch lands. - libraryPlaybackPrefsStore.clear() - overlayPrefsStore.clear() - activeProfileStore.reset() - cardPresentationStore.clear() - seekIntervalStore.clear() + // Pushes in-flight settings, then drops per-profile cached prefs so + // the next user doesn't see stale rows flash before the fresh fetch. + signOutTeardown.signOut() _uiState.update { it.copy(loggedOut = true) } } } @@ -520,18 +593,38 @@ class SettingsViewModel( viewModelScope.launch { playerSettingsStore.setShowAudiobooks(enabled) } } - fun setSubtitleAppearance(value: org.prairieserver.prairie.model.settings.SubtitleAppearance) { + /** + * Commits a subtitle-appearance change via a transform rather than a + * precomputed value (replaced the former `setSubtitleAppearance`). + * [PlayerSettingsStore.updateSubtitleAppearance][org.prairieserver.prairie.common.settings.PlayerSettingsStore.updateSubtitleAppearance] + * applies it atomically inside the store's own write transaction, so two + * edits committing around the same time (e.g. two opacity fields as the + * sheet is dismissed) can't race on a snapshot read before either writes. + */ + fun editSubtitleAppearance( + transform: (org.prairieserver.prairie.model.settings.SubtitleAppearance) -> org.prairieserver.prairie.model.settings.SubtitleAppearance, + ) { viewModelScope.launch { - playerSettingsStore.setSubtitleAppearance(value) - // The granular subtitle.* fields are client-local — the contract - // carries appearance as one object — so a per-field edit only - // reaches the server once projected into the composite. + playerSettingsStore.updateSubtitleAppearance(transform) playerSettingsStore.flushProjectedSubtitleAppearance() } } + /** "Use Profile Settings": clears every setting this device holds its own value for. */ fun resetPlaybackOverrides() { - viewModelScope.launch { playerSettingsStore.resetAllDeviceSettings() } + viewModelScope.launch { + val landed = playerSettingsStore.resetAllDeviceSettings() + _uiState.update { it.copy(playbackOverridesReset = landed) } + } + } + + fun onPlaybackOverridesResetShown() { + _uiState.update { it.copy(playbackOverridesReset = null) } + } + + /** Goes back to the profile's value for one setting's control on this device. */ + fun useProfileSetting(key: String) { + viewModelScope.launch { playerSettingsStore.resetDeviceSetting(key) } } /** Lifecycle hook — call from ON_STOP so debounced writes survive. */ diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SignInSection.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SignInSection.kt new file mode 100644 index 0000000000..71d883cde1 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SignInSection.kt @@ -0,0 +1,267 @@ +package org.prairieserver.prairie.android.ui.screens.settings + +import org.prairieserver.prairie.android.ui.components.PrairieDialogActionStyle +import org.prairieserver.prairie.android.ui.components.PrairieDialogAction +import org.prairieserver.prairie.android.ui.components.PrairieDialog +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.android.auth.openNativeSignIn +import org.prairieserver.prairie.android.ui.components.PrairieConfirmDialog +import org.prairieserver.prairie.android.ui.theme.SettingsDimens +import org.prairieserver.prairie.android.ui.theme.PrairieDestructive +import org.prairieserver.prairie.android.ui.theme.PrairieForeground +import org.prairieserver.prairie.android.ui.theme.PrairieMutedText +import org.prairieserver.prairie.android.ui.theme.PrairieSurfaceContainer +import org.prairieserver.prairie.model.auth.AccountIdentity + +/** + * Account settings "Sign-in": the external provider identity linked to this + * account, Connect (after re-entering the account password) and Disconnect. + * Hidden on servers without external sign-in. + */ +@Composable +fun SignInSection( + modifier: Modifier = Modifier, + viewModel: SignInSettingsViewModel = koinViewModel(), +) { + val state by viewModel.uiState.collectAsState() + val context = LocalContext.current + LaunchedEffect(state.browserLaunch) { + val url = state.browserLaunch ?: return@LaunchedEffect + viewModel.onBrowserLaunchHandled(context.openNativeSignIn(url)) + } + if (!state.visible) return + + SettingsSection(title = "Sign-in", modifier = modifier) { + state.identities.forEach { identity -> + SettingsRow( + label = identity.providerName.ifBlank { "Sign-in provider (turned off)" }, + description = identityDescription(identity), + ) + // The server says up front when this is the only way to sign in. + if (state.canUnlink != false) { + SettingsDestructiveRow( + label = "Disconnect", + description = "Stop signing in to this account with ${SignInSettingsViewModel.providerLabel(identity)}.", + onClick = { viewModel.onDisconnect(identity) }, + enabled = !state.busy, + ) + } + } + if (state.canUnlink == false) { + SettingsProse(body = SignInSettingsViewModel.ONLY_SIGN_IN_METHOD) + } + state.connectable.forEach { provider -> + SettingsNavigationRow( + label = "Connect ${provider.displayName}", + description = SignInSettingsViewModel.connectDescription(provider.displayName), + onClick = { viewModel.onConnect(provider) }, + enabled = !state.busy, + ) + } + state.directory?.let { provider -> + SettingsNavigationRow( + label = "Connect ${provider.displayName}", + description = SignInSettingsViewModel.connectDirectoryDescription(provider.displayName), + onClick = { viewModel.onConnectDirectory(provider) }, + enabled = !state.busy, + ) + } + state.network?.let { provider -> + SettingsNavigationRow( + label = "Connect ${provider.displayName}", + description = SignInSettingsViewModel.connectNetworkDescription(provider.displayName, state.networkLinkKeepsPassword), + onClick = { viewModel.onConnectNetwork(provider) }, + enabled = !state.busy, + ) + } + // Connect and disconnect finish after the browser or a dialog closes: + // announce the outcome, which appears away from where focus is. + val announced = Modifier.semantics { liveRegion = LiveRegionMode.Polite } + state.message?.let { SettingsProse(body = it, modifier = announced) } + state.error?.let { SettingsProse(body = it, title = "Couldn't finish", modifier = announced) } + } + + state.passwordPrompt?.let { provider -> + ConfirmPasswordDialog( + providerName = provider.displayName, + body = SignInSettingsViewModel.connectPrompt(provider.displayName), + confirmLabel = "Continue", + error = state.passwordError, + busy = state.busy, + onConfirm = viewModel::onConfirmPassword, + onDismiss = viewModel::onDismissPasswordPrompt, + ) + } + state.networkPrompt?.let { provider -> + ConfirmPasswordDialog( + providerName = provider.displayName, + body = SignInSettingsViewModel.connectNetworkPrompt( + provider.displayName, + provider.networkIdentity?.label, + state.networkLinkKeepsPassword, + ), + confirmLabel = "Connect", + error = state.passwordError, + busy = state.busy, + onConfirm = viewModel::onConfirmNetwork, + onDismiss = viewModel::onDismissNetworkPrompt, + ) + } + state.directoryPrompt?.let { provider -> + ConnectDirectoryDialog( + providerName = provider.displayName, + error = state.passwordError, + busy = state.busy, + onConfirm = viewModel::onConfirmDirectory, + onDismiss = viewModel::onDismissDirectoryPrompt, + ) + } + state.confirmDisconnect?.let { identity -> + PrairieConfirmDialog( + title = "Disconnect ${SignInSettingsViewModel.providerLabel(identity)}?", + body = SignInSettingsViewModel.disconnectConfirmBody(identity, state.canUnlink), + confirmLabel = "Disconnect", + onConfirm = viewModel::onConfirmDisconnect, + onDismiss = viewModel::onDismissDisconnect, + ) + } +} + +private fun identityDescription(identity: AccountIdentity): String = buildString { + val who = identity.accountLabel + if (who.isNotBlank()) append("Signed in as ").append(who) + formatDate(identity.linkedAt)?.let { + if (isNotEmpty()) append(" · ") + append("Connected ").append(it) + } +} + +private fun formatDate(instant: String): String? = runCatching { + DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) + .withZone(ZoneId.systemDefault()) + .format(Instant.parse(instant)) +}.getOrNull() + +@Composable +private fun ConfirmPasswordDialog( + providerName: String, + body: String, + confirmLabel: String, + error: String?, + busy: Boolean, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var password by remember { mutableStateOf("") } + PrairieDialog( + title = "Connect $providerName", + message = body, + onDismissRequest = onDismiss, + actions = listOf( + PrairieDialogAction( + label = if (busy) "Checking…" else confirmLabel, + onClick = { onConfirm(password) }, + style = PrairieDialogActionStyle.Primary, + enabled = !busy && password.isNotEmpty(), + ), + PrairieDialogAction("Cancel", onDismiss), + ), + ) { + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Password") }, + singleLine = true, + isError = error != null, + supportingText = error?.let { { Text(it, color = PrairieDestructive) } }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { if (!busy && password.isNotEmpty()) onConfirm(password) }), + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun ConnectDirectoryDialog( + providerName: String, + error: String?, + busy: Boolean, + onConfirm: (password: String, username: String, directoryPassword: String) -> Unit, + onDismiss: () -> Unit, +) { + var password by remember { mutableStateOf("") } + var username by remember { mutableStateOf("") } + var directoryPassword by remember { mutableStateOf("") } + val complete = !busy && password.isNotEmpty() && username.isNotBlank() && directoryPassword.isNotEmpty() + PrairieDialog( + title = "Connect $providerName", + message = SignInSettingsViewModel.connectDirectoryPrompt(providerName), + onDismissRequest = onDismiss, + actions = listOf( + PrairieDialogAction( + label = if (busy) "Checking…" else "Connect", + onClick = { onConfirm(password, username, directoryPassword) }, + style = PrairieDialogActionStyle.Primary, + enabled = complete, + ), + PrairieDialogAction("Cancel", onDismiss), + ), + ) { + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("This account's password") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next), + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = username, + onValueChange = { username = it }, + label = { Text("$providerName username") }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = directoryPassword, + onValueChange = { directoryPassword = it }, + label = { Text("$providerName password") }, + singleLine = true, + isError = error != null, + supportingText = error?.let { { Text(it, color = PrairieDestructive) } }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { if (complete) onConfirm(password, username, directoryPassword) }), + modifier = Modifier.fillMaxWidth(), + ) + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SignInSettingsViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SignInSettingsViewModel.kt new file mode 100644 index 0000000000..5f9f67eb56 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SignInSettingsViewModel.kt @@ -0,0 +1,598 @@ +package org.prairieserver.prairie.android.ui.screens.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.prairieserver.prairie.android.auth.NativeSignInCoordinator +import org.prairieserver.prairie.android.auth.NativeSignInMessages +import org.prairieserver.prairie.android.auth.NativeSignInPurpose +import org.prairieserver.prairie.android.auth.NativeSignInResult +import org.prairieserver.prairie.android.auth.NativeSignInStart +import org.prairieserver.prairie.android.ui.screens.auth.ProviderRefusalText +import org.prairieserver.prairie.model.auth.AccountIdentity +import org.prairieserver.prairie.model.auth.NetworkSignInFailure +import org.prairieserver.prairie.model.auth.OAuthHandshakeCapabilities +import org.prairieserver.prairie.model.auth.SignInOptions +import org.prairieserver.prairie.model.auth.SignInProvider +import org.prairieserver.prairie.model.auth.SignInProviders +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.ServerRegistry +import org.prairieserver.prairie.network.TokenManager +import org.prairieserver.prairie.network.api.DefaultExternalSignInApi +import org.prairieserver.prairie.repository.ExternalSignInRepository +import org.prairieserver.prairie.repository.ServerIdentityRepository + +data class SignInSettingsUiState( + val loading: Boolean = true, + /** Identities linked to this account, with what the server says about each. */ + val identities: List = emptyList(), + /** + * The server's `can_unlink`: false hides Disconnect (the identity is the + * account's only way to sign in), null when the server doesn't say. + */ + val canUnlink: Boolean? = null, + /** Providers this account can connect from the app (OAuth, with linking served). */ + val connectable: List = emptyList(), + /** A directory (LDAP) provider this account can connect with its directory credentials. */ + val directory: SignInProvider? = null, + /** Asking for the account password and the directory credentials before connecting [directory]. */ + val directoryPrompt: SignInProvider? = null, + /** + * A network provider (such as Tailscale) this account can connect: the + * server lists one only to a device on that provider's network, and links + * the person who owns this device there. + */ + val network: SignInProvider? = null, + /** The server keeps the account's password when [network] is connected (`network_link_keeps_password`). */ + val networkLinkKeepsPassword: Boolean = false, + /** Asking for the account password before connecting [network]. */ + val networkPrompt: SignInProvider? = null, + /** Asking for the local password before connecting this provider. */ + val passwordPrompt: SignInProvider? = null, + val passwordError: String? = null, + /** Asking before disconnecting this identity. */ + val confirmDisconnect: AccountIdentity? = null, + /** A connect or disconnect is under way. */ + val busy: Boolean = false, + val message: String? = null, + val error: String? = null, + /** A start URL for the screen to open in a Custom Tab, once. */ + val browserLaunch: String? = null, +) { + /** The section shows once the server has an external provider or the account has an identity. */ + val visible: Boolean get() = identities.isNotEmpty() || connectable.isNotEmpty() || directory != null || network != null +} + +/** + * Account settings "Sign-in" section (prairie-server external sign-in spec): + * the provider identity linked to this account, "Connect " after + * the account re-enters its local password (a link ticket, then the native + * browser flow with `link_ticket`; for a directory or a network provider, one + * call with no browser), and "Disconnect", which the server allows only while + * the account can still sign in another way. + */ +class SignInSettingsViewModel( + private val externalSignIn: ExternalSignInRepository, + private val tokenManager: TokenManager, + private val serverRegistry: ServerRegistry, + private val serverIdentities: ServerIdentityRepository, + private val nativeSignIn: NativeSignInCoordinator, +) : ViewModel() { + + private val _uiState = MutableStateFlow(SignInSettingsUiState()) + + /** + * The open prompt's work: the ticket request and flow start until the + * browser opens, or a directory or network link until it answers. + */ + private var connectJob: Job? = null + + /** Bumped by each [load]; only the newest read publishes, so an older one can't overwrite it. */ + private var loadGeneration = 0 + val uiState: StateFlow = _uiState.asStateFlow() + + init { + refresh() + viewModelScope.launch { + nativeSignIn.result.filterNotNull().collect(::onNativeResult) + } + } + + fun refresh() { + viewModelScope.launch { load() } + } + + /** + * Reads the section for the signed-in account. Identities belong to the + * account, so an answer is shown only while the same login is still + * signed in on the same server; otherwise the section is read again. + */ + private suspend fun load() { + val generation = ++loadGeneration + val scope = tokenManager.snapshotCurrentScope() + val loginSessionId = scope?.let { tokenManager.loginSessionId(it.serverId) } + if (scope == null) { + if (generation != loadGeneration) return + _uiState.update { + it.copy( + loading = false, + identities = emptyList(), + canUnlink = null, + connectable = emptyList(), + directory = null, + network = null, + networkLinkKeepsPassword = false, + ) + } + return + } + val section = coroutineScope { + val capabilities = async { externalSignIn.externalSignInCapabilities(scope) } + val identities = async { externalSignIn.identities(scope) } + val providers = async { externalSignIn.providers(scope.serverUrl) } + val handshake = async { externalSignIn.oauthCapabilities(scope.serverUrl) } + val served = (capabilities.await() as? ApiResult.Success)?.data + val listed = (identities.await() as? ApiResult.Success)?.data + sectionOf( + identitiesServed = served?.identities == true, + identities = listed?.items.orEmpty(), + providers = (providers.await() as? ApiResult.Success)?.data?.providers.orEmpty(), + handshake = (handshake.await() as? ApiResult.Success)?.data ?: OAuthHandshakeCapabilities.None, + credentialsLinking = served?.credentialsLinking == true, + canUnlink = listed?.canUnlink, + networkSignIn = served?.networkSignIn == true, + networkLinkKeepsPassword = served?.networkLinkKeepsPassword == true, + ) + } + val now = tokenManager.snapshotCurrentScope() + val sameLogin = now?.serverId == scope.serverId && tokenManager.loginSessionId(scope.serverId) == loginSessionId + // A later read (a reload after a link, say) started meanwhile: it has the newer answer. + if (generation != loadGeneration) return + if (!sameLogin) { + load() + return + } + _uiState.update { + it.copy( + loading = false, + identities = section.identities, + canUnlink = section.canUnlink, + connectable = section.connectable, + directory = section.directory, + network = section.network, + networkLinkKeepsPassword = section.networkLinkKeepsPassword, + ) + } + } + + fun onConnectDirectory(provider: SignInProvider) { + _uiState.update { it.copy(directoryPrompt = provider, passwordError = null, message = null, error = null) } + } + + fun onDismissDirectoryPrompt() = dismissPrompt { it.copy(directoryPrompt = null) } + + /** + * Links the directory (LDAP) account whose credentials the person typed, + * after confirming this account's own password. No browser is involved: + * the server checks both and links in one call. + */ + fun onConfirmDirectory(password: String, username: String, directoryPassword: String) { + val provider = _uiState.value.directoryPrompt ?: return + val installationId = provider.installationId ?: return + if (_uiState.value.busy) return + if (password.isEmpty() || username.isBlank() || directoryPassword.isEmpty()) { + _uiState.update { it.copy(passwordError = "Fill in all three fields.") } + return + } + linkInOneCall( + provider = provider, + closePrompt = { it.copy(directoryPrompt = null) }, + refusal = { directoryLinkMessage(it, provider.displayName) }, + ) { scope -> externalSignIn.linkWithCredentials(scope, installationId, password, username.trim(), directoryPassword) } + } + + fun onConnectNetwork(provider: SignInProvider) { + _uiState.update { it.copy(networkPrompt = provider, passwordError = null, message = null, error = null) } + } + + fun onDismissNetworkPrompt() = dismissPrompt { it.copy(networkPrompt = null) } + + /** + * Links the person who owns this device at the network provider, after + * confirming this account's own password. Like a directory link, the + * server checks both in one call; the device says who the person is. + */ + fun onConfirmNetwork(password: String) { + val provider = _uiState.value.networkPrompt ?: return + val installationId = provider.installationId ?: return + if (_uiState.value.busy) return + if (password.isEmpty()) { + _uiState.update { it.copy(passwordError = "Enter your password.") } + return + } + linkInOneCall( + provider = provider, + closePrompt = { it.copy(networkPrompt = null) }, + refusal = { networkLinkMessage(it, provider.displayName) }, + ) { scope -> externalSignIn.linkWithNetwork(scope, installationId, password) } + } + + /** + * A link the server makes in one call, with no browser (directory and + * network providers): the open prompt closes on success and on refusals + * that typing again won't fix, and stays open for the rest. + */ + private fun linkInOneCall( + provider: SignInProvider, + closePrompt: (SignInSettingsUiState) -> SignInSettingsUiState, + refusal: (ApiResult.Error) -> String, + link: suspend (AuthScopeSnapshot) -> ApiResult, + ) { + _uiState.update { it.copy(busy = true, passwordError = null) } + connectJob = viewModelScope.launch { + val scope = tokenManager.snapshotCurrentScope() + if (scope == null) { + _uiState.update { closePrompt(it).copy(busy = false, error = ACCOUNT_CHANGED) } + return@launch + } + when (val linked = link(scope)) { + is ApiResult.Success -> { + load() + _uiState.update { closePrompt(it).copy(busy = false, message = "Connected ${provider.displayName}.") } + } + is ApiResult.Error -> { + val message = refusal(linked) + if (keepsPromptOpen(linked)) { + _uiState.update { it.copy(busy = false, passwordError = message) } + } else { + _uiState.update { closePrompt(it).copy(busy = false, error = message) } + } + } + is ApiResult.NetworkError -> _uiState.update { + it.copy(busy = false, passwordError = NativeSignInMessages.forNetworkError(linked, provider.displayName)) + } + } + } + } + + fun onConnect(provider: SignInProvider) { + _uiState.update { it.copy(passwordPrompt = provider, passwordError = null, message = null, error = null) } + } + + fun onDismissPasswordPrompt() = dismissPrompt { it.copy(passwordPrompt = null) } + + /** + * Cancel also stops the prompt's work still under way: no browser opens + * and nothing reports back once the prompt is gone. A link the server + * made before the cancel shows when the section reloads. + */ + private fun dismissPrompt(close: (SignInSettingsUiState) -> SignInSettingsUiState) { + val stopped = connectJob?.isActive == true + connectJob?.cancel() + connectJob = null + _uiState.update { close(it).copy(passwordError = null, busy = it.busy && !stopped) } + if (stopped) refresh() + } + + /** + * The local password confirmed: get a link ticket, then start the native + * linking flow for this account and server. + */ + fun onConfirmPassword(password: String) { + val provider = _uiState.value.passwordPrompt ?: return + val installationId = provider.installationId ?: return + val startPath = provider.nativeStartPath ?: return + if (_uiState.value.busy) return + if (password.isEmpty()) { + _uiState.update { it.copy(passwordError = "Enter your password.") } + return + } + _uiState.update { it.copy(busy = true, passwordError = null) } + connectJob = viewModelScope.launch { + val scope = tokenManager.snapshotCurrentScope() + val entry = serverRegistry.activeEntry.value + val loginSessionId = scope?.let { tokenManager.loginSessionId(it.serverId) } + if (scope == null || entry == null || entry.id != scope.serverId || loginSessionId == null) { + _uiState.update { it.copy(busy = false, passwordPrompt = null, error = ACCOUNT_CHANGED) } + return@launch + } + val ticket = when (val issued = externalSignIn.linkTicket(scope, installationId, password)) { + is ApiResult.Success -> issued.data + is ApiResult.Error -> { + val message = linkTicketMessage(issued, provider.displayName) + if (keepsPromptOpen(issued)) { + _uiState.update { it.copy(busy = false, passwordError = message) } + } else { + _uiState.update { it.copy(busy = false, passwordPrompt = null, error = message) } + } + return@launch + } + is ApiResult.NetworkError -> { + _uiState.update { + it.copy(busy = false, passwordError = NativeSignInMessages.forNetworkError(issued, provider.displayName)) + } + return@launch + } + } + val serverId = serverIdentities.identityOf(entry, refresh = true) + if (serverId == null) { + _uiState.update { + it.copy(busy = false, passwordPrompt = null, error = "Couldn't confirm this server's identity. Try again.") + } + return@launch + } + val start = nativeSignIn.begin( + purpose = NativeSignInPurpose.Link, + serverEntryId = entry.id, + serverUrl = scope.serverUrl, + verifiedServerId = serverId, + providerName = provider.displayName, + nativeStartPath = startPath, + linkTicket = ticket.ticket, + loginSessionId = loginSessionId, + ) + when (start) { + is NativeSignInStart.Open -> _uiState.update { it.copy(passwordPrompt = null, browserLaunch = start.url) } + is NativeSignInStart.Refused -> + _uiState.update { it.copy(busy = false, passwordPrompt = null, error = start.message) } + } + } + } + + /** The browser owns the flow now; the redirect brings the outcome. */ + fun onBrowserLaunchHandled(opened: Boolean) { + _uiState.update { + it.copy( + browserLaunch = null, + busy = false, + error = if (opened) it.error else NativeSignInMessages.forReason(NativeSignInMessages.NO_BROWSER), + ) + } + } + + fun onDisconnect(identity: AccountIdentity) { + _uiState.update { it.copy(confirmDisconnect = identity, message = null, error = null) } + } + + fun onDismissDisconnect() { + _uiState.update { it.copy(confirmDisconnect = null) } + } + + fun onConfirmDisconnect() { + val identity = _uiState.value.confirmDisconnect ?: return + if (_uiState.value.busy) return + _uiState.update { it.copy(confirmDisconnect = null, busy = true) } + viewModelScope.launch { + val scope: AuthScopeSnapshot? = tokenManager.snapshotCurrentScope() + if (scope == null) { + _uiState.update { it.copy(busy = false, error = ACCOUNT_CHANGED) } + return@launch + } + when (val result = externalSignIn.disconnect(scope, identity.id)) { + is ApiResult.Success -> { + load() + _uiState.update { it.copy(busy = false, message = "Disconnected ${providerLabel(identity)}.") } + } + is ApiResult.Error -> _uiState.update { it.copy(busy = false, error = disconnectMessage(result, identity)) } + is ApiResult.NetworkError -> _uiState.update { it.copy(busy = false, error = NativeSignInMessages.forNetworkError(result)) } + } + } + } + + private suspend fun onNativeResult(result: NativeSignInResult) { + if (result.purpose != NativeSignInPurpose.Link) return + // A link finished for another saved server belongs to that server's settings. + if (result.serverEntryId != serverRegistry.activeServerId.value) return + when (result) { + is NativeSignInResult.Finishing -> _uiState.update { it.copy(busy = true, error = null, message = null) } + is NativeSignInResult.Linked -> { + nativeSignIn.consume(result) + val loginSessionId = tokenManager.loginSessionId(result.serverEntryId) + load() + // Still the server and login the link was made for once the + // section has reloaded. `busy` came from this flow's Finishing, + // and nothing else can start while it's set, so it clears either way. + val here = result.serverEntryId == serverRegistry.activeServerId.value && + tokenManager.loginSessionId(result.serverEntryId) == loginSessionId + _uiState.update { + it.copy(busy = false, message = if (here) "Connected ${result.providerName}." else it.message) + } + } + is NativeSignInResult.Failed -> { + nativeSignIn.consume(result) + _uiState.update { it.copy(busy = false, error = result.message) } + } + is NativeSignInResult.SignedIn -> Unit + } + } + + /** What the section shows, from the server's answers. */ + data class Section( + val identities: List, + val connectable: List, + val directory: SignInProvider?, + val canUnlink: Boolean? = null, + val network: SignInProvider? = null, + val networkLinkKeepsPassword: Boolean = false, + ) + + companion object { + const val ACCOUNT_CHANGED = "The account changed. Open Settings again and retry." + private const val CHECK_ENTRIES = "Check what you entered, then try again." + + /** "Connect ": what the account gives up by connecting. */ + fun connectDescription(providerName: String): String = + "After connecting, you sign in with $providerName instead of your password. You'll confirm your password first." + + /** + * "Connect " for a network provider. The account keeps its + * password only on a server that says so ([keepsPassword]). + */ + fun connectNetworkDescription(providerName: String, keepsPassword: Boolean): String = + if (keepsPassword) { + "After connecting, you can sign in with $providerName or your password. You'll confirm your password first." + } else { + connectDescription(providerName) + } + + fun connectDirectoryDescription(providerName: String): String = + "After connecting, you sign in with your $providerName username and password instead of this account's password." + + fun connectPrompt(providerName: String): String = + "Enter this account's password, then sign in with $providerName in your browser. " + + "After connecting, you sign in with $providerName instead of your password." + + fun connectDirectoryPrompt(providerName: String): String = + "Confirm this account's password, then enter your $providerName username and password. " + + "After connecting, you sign in with your $providerName username and password instead." + + /** + * The password prompt for a network provider. [owner] is who the + * provider says owns this device, the person being connected. The + * account keeps its password only on a server that says so + * ([keepsPassword]). + */ + fun connectNetworkPrompt(providerName: String, owner: String?, keepsPassword: Boolean): String { + val who = owner?.let { "$it on $providerName" } ?: "the $providerName account this device uses" + return "Enter this account's password to connect $who. " + + if (keepsPassword) { + "You can then sign in with $providerName or your password." + } else { + "After connecting, you sign in with $providerName instead of your password." + } + } + + /** + * Linking can turn the account's own password off, so Disconnect can't + * promise it unless the server said up front ([canUnlink]) that the + * account keeps another way to sign in. + */ + fun disconnectConfirmBody(identity: AccountIdentity, canUnlink: Boolean? = null): String { + val lost = "You won't be able to sign in to this account with ${providerLabel(identity)} until you connect it again." + return if (canUnlink == true) { + lost + } else { + "$lost Disconnecting works only while the account has another way to sign in; otherwise an admin must set one up first." + } + } + + /** `can_unlink` is false: the identity is the account's only way to sign in. */ + const val ONLY_SIGN_IN_METHOD = + "This is your only way to sign in, so it can't be disconnected. Ask an admin to set a password for your account first." + + /** + * The section on a server that serves the account's identities + * ([identitiesServed]); nothing otherwise. A provider the account + * already has an identity at isn't offered again. The providers are + * the ones the sign-in screen would offer ([SignInOptions.of]): an + * OAuth provider is connectable through the browser when the OAuth + * handshake also serves app linking, a directory when the external + * sign-in document serves [credentialsLinking] (directory linking + * needs no handshake). A network provider is connectable when the + * document serves [networkSignIn] and the server listed one, which + * it does only to a device on that provider's network. + */ + fun sectionOf( + identitiesServed: Boolean, + identities: List, + providers: List, + handshake: OAuthHandshakeCapabilities, + credentialsLinking: Boolean, + canUnlink: Boolean? = null, + networkSignIn: Boolean = false, + networkLinkKeepsPassword: Boolean = false, + ): Section { + if (!identitiesServed) return Section(emptyList(), emptyList(), null) + val linked = identities.map { it.installationId }.toSet() + val unlinked = providers.filter { it.installationId != null && it.installationId !in linked } + val options = SignInOptions.of(SignInProviders(unlinked, passwordLogin = false), handshake) + val connectable = if (handshake.linking) options.oauthProviders else emptyList() + val directory = options.directoryProvider.takeIf { credentialsLinking } + val network = options.networkProvider.takeIf { networkSignIn } + return Section( + identities, + connectable, + directory, + canUnlink.takeIf { identities.isNotEmpty() }, + network, + networkLinkKeepsPassword = network != null && networkLinkKeepsPassword, + ) + } + + fun providerLabel(identity: AccountIdentity): String = + identity.providerName.ifBlank { "this sign-in" } + + /** Whether a refused connect leaves the password dialog open to correct what was typed. */ + internal fun keepsPromptOpen(error: ApiResult.Error): Boolean = error.code == 422 || error.code == 429 + + private fun noLocalPassword(providerName: String) = + "This account has no password to confirm with. Ask an admin to connect it to $providerName." + + private fun noLongerAvailable(providerName: String) = ProviderRefusalText.providerGone(providerName) + + /** Text for a refused link ticket (`createAccountIdentityLinkTicket`). */ + fun linkTicketMessage(error: ApiResult.Error, providerName: String): String = when { + error.error == DefaultExternalSignInApi.WRONG_PASSWORD -> "That password is incorrect." + error.code == 422 -> CHECK_ENTRIES + error.error == "local_password_required" || error.code == 409 -> noLocalPassword(providerName) + error.code == 404 -> noLongerAvailable(providerName) + else -> NativeSignInMessages.forProblem(error, providerName) ?: "Couldn't connect $providerName. Try again." + } + + /** + * Text for a refused directory link (`linkAccountIdentityWithCredentials`), + * by problem code: the contract names one for every refusal. + */ + fun directoryLinkMessage(error: ApiResult.Error, providerName: String): String = when (error.error) { + DefaultExternalSignInApi.WRONG_PASSWORD -> "That password is incorrect." + DefaultExternalSignInApi.DIRECTORY_REFUSED -> "$providerName didn't accept that username and password." + "local_password_required" -> noLocalPassword(providerName) + "account_disabled" -> "Your $providerName account is disabled or locked." + "password_expired" -> "Your $providerName password has expired. Change it, then try again." + "not_found" -> noLongerAvailable(providerName) + else -> when { + error.code == 422 -> CHECK_ENTRIES + error.code == 404 -> noLongerAvailable(providerName) + else -> NativeSignInMessages.forProblem(error, providerName) ?: "Couldn't connect $providerName. Try again." + } + } + + /** + * Text for a refused network link (`linkAccountIdentityWithNetwork`), + * by problem code: the contract names one for every refusal. + */ + fun networkLinkMessage(error: ApiResult.Error, providerName: String): String = when (error.error) { + DefaultExternalSignInApi.WRONG_PASSWORD -> "That password is incorrect." + "local_password_required" -> noLocalPassword(providerName) + else -> when (NetworkSignInFailure.of(error.code, error.error)) { + NetworkSignInFailure.NetworkIdentityRequired -> ProviderRefusalText.addressRequired(providerName, "connect $providerName") + NetworkSignInFailure.NotPermitted -> ProviderRefusalText.notPermitted(providerName) + NetworkSignInFailure.NotFound -> ProviderRefusalText.providerGone(providerName) + else -> if (error.code == 422) { + CHECK_ENTRIES + } else { + NativeSignInMessages.forProblem(error, providerName) ?: "Couldn't connect $providerName. Try again." + } + } + } + + /** Text for a refused disconnect (`deleteAccountIdentity`). */ + fun disconnectMessage(error: ApiResult.Error, identity: AccountIdentity): String = when { + error.error == "last_sign_in_method" || error.code == 409 -> + "${providerLabel(identity).replaceFirstChar { it.uppercase() }} is this account's only way to sign in, " + + "so it can't be disconnected. Ask an admin to set a password for your account first." + error.code == 404 -> "That sign-in is already disconnected." + error.code == 503 -> "The server can't be reached right now. Try again later." + else -> NativeSignInMessages.forProblem(error) ?: "Couldn't disconnect. Try again." + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SubtitleSettings.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SubtitleSettings.kt index 25b710fad4..374c1571d0 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SubtitleSettings.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SubtitleSettings.kt @@ -1,32 +1,52 @@ package org.prairieserver.prairie.android.ui.screens.settings +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.graphics.toColorInt +import org.prairieserver.prairie.android.ui.theme.SettingsDimens import org.prairieserver.prairie.model.settings.LanguageOptions import org.prairieserver.prairie.model.settings.SettingKeys +import org.prairieserver.prairie.model.settings.SubtitleAppearance +import org.prairieserver.prairie.model.settings.SubtitleBackgroundStylePreset +import org.prairieserver.prairie.model.settings.SubtitleFontSizePreset +import org.prairieserver.prairie.model.settings.SubtitlePositionPreset +import org.prairieserver.prairie.model.settings.pointSize /** - * Subtitle settings section with language, display mode, and forced subtitles toggle. + * Subtitles → Profile (Apple `SubtitleSettingsView` "Profile"): language, + * behavior, and forced subtitles, with the Apple footer. */ @Composable -fun SubtitleSettings( +fun SubtitleProfileSection( subtitleLanguage: String, - subtitleLanguageSuggestions: List = emptyList(), + subtitleLanguageSuggestions: List, subtitleMode: SubtitleMode, showForcedSubtitles: Boolean, onLanguageChanged: (String) -> Unit, onModeChanged: (SubtitleMode) -> Unit, onForcedSubtitlesChanged: (Boolean) -> Unit, - subtitleMatchesDevice: Boolean = false, - onSubtitleMatchesDeviceChanged: (Boolean) -> Unit = {}, - onOpenSubtitleAppearance: () -> Unit = {}, modifier: Modifier = Modifier, - // Metadata AI description translation (server-gated; row hidden when off). - metadataLanguageEnabled: Boolean = false, - metadataLanguage: String = "", - metadataLanguageSuggestions: List = emptyList(), - onMetadataLanguageChanged: (String) -> Unit = {}, ) { val subtitleLanguageOptions = remember(subtitleLanguage, subtitleLanguageSuggestions) { LanguageOptions.options( @@ -35,17 +55,14 @@ fun SubtitleSettings( runtimeValues = subtitleLanguageSuggestions, ) } - val metadataLanguageOptions = remember(metadataLanguage, metadataLanguageSuggestions) { - LanguageOptions.options( - key = SettingKeys.CATALOG_METADATA_LANGUAGE, - currentValue = metadataLanguage, - runtimeValues = metadataLanguageSuggestions, - ) - } - SettingsSection(title = "Subtitles", modifier = modifier) { + SettingsSection( + title = "Profile", + footer = "Used to pick a matching track when one is available. Forced subtitles cover " + + "foreign-language dialogue even when subtitles are off or set to auto.", + modifier = modifier, + ) { SettingsDropdownRow( - label = "Subtitle language", - description = "Choose which subtitle language Prairie should prefer first.", + label = "Language", value = LanguageOptions.label(subtitleLanguage, SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE), options = subtitleLanguageOptions.map { it.second }, onOptionSelected = { label -> @@ -54,8 +71,7 @@ fun SubtitleSettings( ) SettingsDropdownRow( - label = "Subtitle behavior", - description = "When Prairie should turn subtitles on.", + label = "Behavior", value = subtitleMode.label, options = SubtitleMode.entries.map { it.label }, onOptionSelected = { label -> @@ -64,39 +80,368 @@ fun SubtitleSettings( ) SettingsSwitchRow( - label = "Show forced subtitles", - description = "Show subtitles for foreign-language dialogue even when subtitles are off.", + label = "Show Forced Subtitles", checked = showForcedSubtitles, onCheckedChange = onForcedSubtitlesChanged, ) + } +} - // iOS SubtitleSettingsView APPEARANCE parity: Match Device Settings - // (appearance follows the OS captioning preferences) + the custom - // appearance editor (the same sheet the player uses). - SettingsSwitchRow( - label = "Match device caption settings", - description = "Use the operating system's caption style instead of Prairie's.", - checked = subtitleMatchesDevice, - onCheckedChange = onSubtitleMatchesDeviceChanged, +/** + * Subtitles → Metadata, shown while the server's Metadata AI is on. The footer + * is the settings contract's own description of the key. + */ +@Composable +fun SubtitleMetadataSection( + metadataLanguage: String, + metadataLanguageSuggestions: List, + onMetadataLanguageChanged: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val metadataLanguageOptions = remember(metadataLanguage, metadataLanguageSuggestions) { + LanguageOptions.options( + key = SettingKeys.CATALOG_METADATA_LANGUAGE, + currentValue = metadataLanguage, + runtimeValues = metadataLanguageSuggestions, + ) + } + SettingsSection( + title = "Metadata", + footer = "Fallback language Prairie prefers for titles, descriptions, and artwork.", + modifier = modifier, + ) { + SettingsDropdownRow( + label = "Metadata Language", + value = LanguageOptions.label(metadataLanguage, SettingKeys.CATALOG_METADATA_LANGUAGE), + options = metadataLanguageOptions.map { it.second }, + onOptionSelected = { label -> + onMetadataLanguageChanged(LanguageOptions.wireValue(label, metadataLanguageOptions)) + }, ) - if (!subtitleMatchesDevice) { - SettingsNavigationRow( - label = "Subtitle appearance", - description = "How subtitles are drawn during playback.", - onClick = onOpenSubtitleAppearance, + } +} + +/** + * Subtitles → Appearance: the live preview, then "Use Device Settings", whose + * footer says where the style comes from. The preview draws [previewAppearance], + * the style playback uses, so it shows the device caption style while Use + * Device Settings is on. The Text, Background, and Layout groups below edit the + * same appearance the player's Subtitle Style sheet does. + */ +@Composable +fun SubtitleAppearanceSection( + previewAppearance: SubtitleAppearance, + subtitleMatchesDevice: Boolean, + onSubtitleMatchesDeviceChanged: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth()) { + SettingsSectionHeader("Appearance") + SubtitleAppearancePreview(previewAppearance) + Spacer(modifier = Modifier.height(SettingsDimens.sectionGap)) + SettingsSection( + title = null, + footer = if (subtitleMatchesDevice) { + "Subtitles follow this device's caption style from Android's accessibility settings." + } else { + "Saved on the server for this profile on this device." + }, + ) { + SettingsSwitchRow( + label = "Use Device Settings", + checked = subtitleMatchesDevice, + onCheckedChange = onSubtitleMatchesDeviceChanged, ) } + } +} - if (metadataLanguageEnabled) { +/** + * Subtitles → Text. Dimmed and disabled while the device caption style is in + * use, as on the Apple page. + */ +@Composable +fun SubtitleTextSection( + appearance: SubtitleAppearance, + enabled: Boolean, + showTextOpacity: Boolean, + onEdit: ((SubtitleAppearance) -> SubtitleAppearance) -> Unit, + modifier: Modifier = Modifier, +) { + SettingsSection(title = "Text", modifier = modifier) { + SettingsDropdownRow( + label = "Font Size", + value = SubtitleOptionLabels.fontSize(appearance.fontSize), + options = SubtitleOptionLabels.FONT_SIZES.map { it.second }, + enabled = enabled, + onOptionSelected = { label -> + SubtitleOptionLabels.FONT_SIZES.firstOrNull { it.second == label } + ?.let { (size, _) -> onEdit { it.copy(fontSize = size) } } + }, + ) + SettingsDropdownRow( + label = "Font Family", + value = SubtitleOptionLabels.fontFamily(appearance.fontFamily), + options = SubtitleOptionLabels.FONT_FAMILIES.map { it.second }, + enabled = enabled, + onOptionSelected = { label -> + SubtitleOptionLabels.FONT_FAMILIES.firstOrNull { it.second == label } + ?.let { (family, _) -> onEdit { it.copy(fontFamily = family) } } + }, + ) + SettingsDropdownRow( + label = "Font Color", + value = SubtitleOptionLabels.colorName(appearance.fontColor, SubtitleOptionLabels.FONT_COLORS), + options = SubtitleOptionLabels.FONT_COLORS.map { it.second }, + enabled = enabled, + onOptionSelected = { label -> + SubtitleOptionLabels.FONT_COLORS.firstOrNull { it.second == label } + ?.let { (hex, _) -> onEdit { it.copy(fontColor = hex) } } + }, + ) + if (showTextOpacity) { + val steps = SubtitleOptionLabels.percentSteps(5, appearance.textOpacity) SettingsDropdownRow( - label = "Metadata language", - description = "Fallback language Prairie prefers for titles, descriptions, and artwork.", - value = LanguageOptions.label(metadataLanguage, SettingKeys.CATALOG_METADATA_LANGUAGE), - options = metadataLanguageOptions.map { it.second }, + label = "Opacity", + value = "${appearance.textOpacity}%", + options = steps.map { "$it%" }, + enabled = enabled, onOptionSelected = { label -> - onMetadataLanguageChanged(LanguageOptions.wireValue(label, metadataLanguageOptions)) + label.removeSuffix("%").toIntOrNull()?.let { percent -> onEdit { it.copy(textOpacity = percent) } } }, ) } + SettingsSwitchRow( + label = "Text Outline", + checked = appearance.textOutline, + enabled = enabled, + onCheckedChange = { outline -> onEdit { it.copy(textOutline = outline) } }, + ) + SettingsDropdownRow( + label = "Outline Color", + value = SubtitleOptionLabels.colorName(appearance.textOutlineColor, SubtitleOptionLabels.DARK_COLORS), + options = SubtitleOptionLabels.DARK_COLORS.map { it.second }, + enabled = enabled && appearance.textOutline, + onOptionSelected = { label -> + SubtitleOptionLabels.DARK_COLORS.firstOrNull { it.second == label } + ?.let { (hex, _) -> onEdit { it.copy(textOutlineColor = hex) } } + }, + ) + } +} + +/** Subtitles → Background. Opacity and colour apply only to the box style. */ +@Composable +fun SubtitleBackgroundSection( + appearance: SubtitleAppearance, + enabled: Boolean, + onEdit: ((SubtitleAppearance) -> SubtitleAppearance) -> Unit, + modifier: Modifier = Modifier, +) { + val boxed = appearance.backgroundStyle == SubtitleBackgroundStylePreset.Box + SettingsSection(title = "Background", modifier = modifier) { + SettingsDropdownRow( + label = "Style", + value = SubtitleOptionLabels.backgroundStyle(appearance.backgroundStyle), + options = SubtitleOptionLabels.BACKGROUND_STYLES.map { it.second }, + enabled = enabled, + onOptionSelected = { label -> + SubtitleOptionLabels.BACKGROUND_STYLES.firstOrNull { it.second == label } + ?.let { (style, _) -> onEdit { it.copy(backgroundStyle = style) } } + }, + ) + val steps = SubtitleOptionLabels.percentSteps(0, appearance.backgroundOpacity) + SettingsDropdownRow( + label = "Opacity", + value = "${appearance.backgroundOpacity}%", + options = steps.map { "$it%" }, + enabled = enabled && boxed, + onOptionSelected = { label -> + label.removeSuffix("%").toIntOrNull()?.let { percent -> onEdit { it.copy(backgroundOpacity = percent) } } + }, + ) + SettingsDropdownRow( + label = "Color", + value = SubtitleOptionLabels.colorName(appearance.backgroundColor, SubtitleOptionLabels.DARK_COLORS), + options = SubtitleOptionLabels.DARK_COLORS.map { it.second }, + enabled = enabled && boxed, + onOptionSelected = { label -> + SubtitleOptionLabels.DARK_COLORS.firstOrNull { it.second == label } + ?.let { (hex, _) -> onEdit { it.copy(backgroundColor = hex) } } + }, + ) + } +} + +/** Subtitles → Layout. */ +@Composable +fun SubtitleLayoutSection( + appearance: SubtitleAppearance, + enabled: Boolean, + onEdit: ((SubtitleAppearance) -> SubtitleAppearance) -> Unit, + modifier: Modifier = Modifier, +) { + SettingsSection(title = "Layout", modifier = modifier) { + SettingsDropdownRow( + label = "Position", + value = SubtitleOptionLabels.position(appearance.position), + options = SubtitleOptionLabels.POSITIONS.map { it.second }, + enabled = enabled, + onOptionSelected = { label -> + SubtitleOptionLabels.POSITIONS.firstOrNull { it.second == label } + ?.let { (position, _) -> onEdit { it.copy(position = position) } } + }, + ) } } + +private const val SubtitlePreviewText = "Subtitles will look like this" + +/** + * The Apple page's live preview: sample text over a dark gradient, drawn with + * the current font, colour, opacity, outline, background, and position. + */ +@Composable +private fun SubtitleAppearancePreview(appearance: SubtitleAppearance) { + val safe = appearance.sanitized() + val fontFamily = when (safe.fontFamily.lowercase()) { + SubtitleAppearance.SERIF -> FontFamily.Serif + SubtitleAppearance.MONOSPACE -> FontFamily.Monospace + else -> FontFamily.SansSerif + } + val fontSize = (safe.fontSize.pointSize * 0.36).sp + val foreground = previewColor(safe.fontColor).copy(alpha = safe.textOpacity.coerceIn(1, 100) / 100f) + val outline = previewColor(safe.textOutlineColor) + val showOutline = safe.textOutline || safe.backgroundStyle == SubtitleBackgroundStylePreset.Outline + val boxed = safe.backgroundStyle == SubtitleBackgroundStylePreset.Box + val boxColor = previewColor(safe.backgroundColor).copy( + alpha = if (boxed) safe.backgroundOpacity.coerceIn(0, 100) / 100f else 0f, + ) + val shadow = if (safe.backgroundStyle == SubtitleBackgroundStylePreset.Shadow) { + Shadow(color = Color.Black, blurRadius = 6f) + } else { + null + } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(118.dp) + .clip(RoundedCornerShape(SettingsDimens.groupRadius)) + .background(Brush.linearGradient(colors = listOf(Color(0xFF55575A), Color(0xFF101113)))) + .padding(horizontal = 14.dp, vertical = 12.dp), + contentAlignment = when (safe.position) { + SubtitlePositionPreset.Top -> Alignment.TopCenter + SubtitlePositionPreset.LowerThird -> Alignment.Center + SubtitlePositionPreset.Bottom -> Alignment.BottomCenter + }, + ) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(boxColor) + .padding(horizontal = if (boxed) 8.dp else 0.dp, vertical = if (boxed) 3.dp else 0.dp), + contentAlignment = Alignment.Center, + ) { + if (showOutline) { + listOf(-1 to -1, -1 to 1, 1 to -1, 1 to 1).forEach { (x, y) -> + Text( + text = SubtitlePreviewText, + color = outline, + fontFamily = fontFamily, + fontSize = fontSize, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.offset(x.dp, y.dp), + ) + } + } + Text( + text = SubtitlePreviewText, + color = foreground, + fontFamily = fontFamily, + fontSize = fontSize, + fontWeight = FontWeight.SemiBold, + style = TextStyle(shadow = shadow), + ) + } + } +} + +private fun previewColor(hex: String): Color = + runCatching { Color(hex.toColorInt()) }.getOrDefault(Color.White) + +/** + * Option labels for the Text / Background / Layout groups, in the Apple apps' + * wording. The palettes match prairie-apple `SubtitleAppearance` and the TV app's + * `TvSubtitleAppearanceOptions`. Android also offers the outline background + * style, which the Apple apps draw as a text outline. + */ +private object SubtitleOptionLabels { + val FONT_SIZES = listOf( + SubtitleFontSizePreset.Small to "Small", + SubtitleFontSizePreset.Medium to "Medium", + SubtitleFontSizePreset.Large to "Large", + SubtitleFontSizePreset.XLarge to "X-Large", + SubtitleFontSizePreset.XXLarge to "XX-Large", + ) + + val FONT_FAMILIES = listOf( + SubtitleAppearance.SANS_SERIF to "Sans-serif", + SubtitleAppearance.SERIF to "Serif", + SubtitleAppearance.MONOSPACE to "Monospace", + ) + + val FONT_COLORS = listOf( + "#ffffff" to "White", + "#facc15" to "Yellow", + "#22c55e" to "Green", + "#06b6d4" to "Cyan", + "#d946ef" to "Magenta", + "#ef4444" to "Red", + "#3b82f6" to "Blue", + "#9ca3af" to "Gray", + "#000000" to "Black", + ) + + /** Background and outline palette (Apple `backgroundColors` / `outlineColors`). */ + val DARK_COLORS = listOf( + "#000000" to "Black", + "#374151" to "Dark Gray", + "#1e3a5f" to "Navy", + "#7f1d1d" to "Dark Red", + "#14532d" to "Dark Green", + ) + + val BACKGROUND_STYLES = listOf( + SubtitleBackgroundStylePreset.Box to "Box", + SubtitleBackgroundStylePreset.Shadow to "Drop Shadow", + SubtitleBackgroundStylePreset.Outline to "Outline", + SubtitleBackgroundStylePreset.None to "None", + ) + + val POSITIONS = listOf( + SubtitlePositionPreset.Bottom to "Bottom", + SubtitlePositionPreset.LowerThird to "Lower Third", + SubtitlePositionPreset.Top to "Top", + ) + + fun fontSize(value: SubtitleFontSizePreset) = FONT_SIZES.first { it.first == value }.second + + fun fontFamily(value: String) = + FONT_FAMILIES.firstOrNull { it.first.equals(value, ignoreCase = true) }?.second ?: value + + fun backgroundStyle(value: SubtitleBackgroundStylePreset) = BACKGROUND_STYLES.first { it.first == value }.second + + fun position(value: SubtitlePositionPreset) = POSITIONS.first { it.first == value }.second + + /** A palette name for [hex], or the hex itself for a colour set elsewhere. */ + fun colorName(hex: String, palette: List>) = + palette.firstOrNull { it.first.equals(hex, ignoreCase = true) }?.second ?: hex + + /** + * Percent steps of five from [from] to 100, plus the saved value so one set + * off the five-point cadence (the player sheet types any percent) is still + * the checked option rather than silently replaced. + */ + fun percentSteps(from: Int, current: Int): List = ((from..100 step 5) + current).toSortedSet().toList() +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/TitleArtSettings.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/TitleArtSettings.kt new file mode 100644 index 0000000000..b3b3de7ee7 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/TitleArtSettings.kt @@ -0,0 +1,51 @@ +package org.prairieserver.prairie.android.ui.screens.settings + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalConfiguration +import org.prairieserver.prairie.common.settings.TitleArtState +import org.prairieserver.prairie.common.settings.TitleArtStore + +/** + * "Show title art" and its "Apply to all devices" companion, as the Title + * Pages group of the Interface page. Renders nothing until the server has + * confirmed the key (settings revision 16); older servers keep logos on, as + * before. The switches stay disabled until this session's read lands. + * [SettingsInterfaceScreen] refreshes the store when it opens. + */ +@Composable +internal fun TitleArtSettingsSection(store: TitleArtStore) { + val state by store.state.collectAsState() + val saveError by store.saveError.collectAsState() + if (!state.isSupported) return + + val device = if (LocalConfiguration.current.smallestScreenWidthDp >= 600) "tablet" else "phone" + SettingsSection( + title = "Title Pages", + footer = "Use logo artwork as the title when available. " + titleArtScopeFooter(state, device), + ) { + SettingsSwitchRow( + label = "Show title art", + checked = state.showTitleArt, + onCheckedChange = store::setShowTitleArt, + enabled = state.canEdit, + ) + SettingsSwitchRow( + label = "Apply to all devices", + checked = state.appliesToAllDevices, + onCheckedChange = store::setAppliesToAllDevices, + enabled = state.canEdit, + ) + saveError?.let { SettingsProse(body = it) } + } +} + +internal fun titleArtScopeFooter(state: TitleArtState, device: String): String = + if (state.appliesToAllDevices) { + "Title art is ${if (state.showTitleArt) "on" else "off"} on every device signed into " + + "this profile. Changing it here changes it everywhere. Turn off “Apply to all " + + "devices” to choose for this $device only." + } else { + "This choice only affects this $device. Your other devices keep their own setting." + } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/diagnostics/DiagnosticsPrompt.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/diagnostics/DiagnosticsPrompt.kt index dcbc41127d..3dd5aa3a91 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/diagnostics/DiagnosticsPrompt.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/diagnostics/DiagnosticsPrompt.kt @@ -1,13 +1,14 @@ package org.prairieserver.prairie.android.ui.screens.settings.diagnostics -import androidx.compose.material3.AlertDialog -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 org.prairieserver.prairie.android.ui.components.PrairieConfirmDialog +import org.prairieserver.prairie.android.ui.components.PrairieDialog +import org.prairieserver.prairie.android.ui.components.PrairieDialogAction +import org.prairieserver.prairie.android.ui.components.PrairieDialogActionStyle import org.prairieserver.prairie.common.diagnostics.DiagnosticsPrompt @Composable @@ -21,61 +22,41 @@ fun DiagnosticsPromptDialog( ) { var confirmAlways by remember { mutableStateOf(false) } if (confirmAlways && allowAlwaysSend) { - AlertDialog( - onDismissRequest = { confirmAlways = false }, - title = { Text("Always send crash reports?") }, - text = { Text("Future eligible reports may be uploaded automatically until you change this setting.") }, - confirmButton = { - TextButton(onClick = onAlwaysSend) { Text("Always send") } - }, - dismissButton = { - TextButton(onClick = { confirmAlways = false }) { Text("Cancel") } - }, + PrairieConfirmDialog( + title = "Always send crash reports?", + body = "Future eligible reports may be uploaded automatically until you change this setting.", + confirmLabel = "Always send", + destructive = false, + onConfirm = onAlwaysSend, + onDismiss = { confirmAlways = false }, ) return } - AlertDialog( - onDismissRequest = onDontSend, - title = { Text("Prairie encountered a problem") }, - text = { - val reportDescription = - if (prompt.reportCount == 1) { - "A ${prompt.reportType.displayName().lowercase()} report is ready. " + - "Review it before deciding whether to send it." - } else { - "${prompt.reportCount} diagnostics reports are ready. " + - "Review them before deciding whether to send them." - } - Text( - if (allowAlwaysSend) { - reportDescription - } else { - "$reportDescription\n\nThe report includes the Prairie app version and build, Android version, " + - "device model, crash details, and diagnostic logs. Its pseudonymous credential is not " + - "linked to an account on your self-hosted server. Username, email, profile, server " + - "address, and playback session IDs are omitted. It never sends automatically and may " + - "be retained for up to 30 days." - }, - ) - }, - confirmButton = { - TextButton(onClick = onReview) { Text("Review") } + val reportDescription = + if (prompt.reportCount == 1) { + "A ${prompt.reportType.displayName().lowercase()} report is ready. " + + "Review it before deciding whether to send it." + } else { + "${prompt.reportCount} diagnostics reports are ready. " + + "Review them before deciding whether to send them." + } + PrairieDialog( + title = "Prairie encountered a problem", + message = if (allowAlwaysSend) { + reportDescription + } else { + "$reportDescription\n\nThe report includes the Prairie app version and build, Android version, " + + "device model, crash details, and diagnostic logs. Its pseudonymous credential is not " + + "linked to an account on your self-hosted server. Username, email, profile, server " + + "address, and playback session IDs are omitted. It never sends automatically and may " + + "be retained for up to 30 days." }, - dismissButton = { - ColumnButtons(onSend, if (allowAlwaysSend) ({ confirmAlways = true }) else null, onDontSend) + onDismissRequest = onDontSend, + actions = buildList { + add(PrairieDialogAction("Review", onReview, PrairieDialogActionStyle.Primary)) + add(PrairieDialogAction("Send", onSend)) + if (allowAlwaysSend) add(PrairieDialogAction("Always send", { confirmAlways = true })) + add(PrairieDialogAction("Don't send", onDontSend)) }, ) } - -@Composable -private fun ColumnButtons( - onSend: () -> Unit, - onAlwaysSend: (() -> Unit)?, - onDontSend: () -> Unit, -) { - TextButton(onClick = onSend) { Text("Send") } - onAlwaysSend?.let { action -> - TextButton(onClick = action) { Text("Always send") } - } - TextButton(onClick = onDontSend) { Text("Don't send") } -} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/diagnostics/DiagnosticsReportScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/diagnostics/DiagnosticsReportScreen.kt index a17f447104..354b1f3c4b 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/diagnostics/DiagnosticsReportScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/diagnostics/DiagnosticsReportScreen.kt @@ -1,5 +1,6 @@ package org.prairieserver.prairie.android.ui.screens.settings.diagnostics +import org.prairieserver.prairie.android.ui.components.PrairieConfirmDialog import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -13,7 +14,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ContentCopy -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon @@ -22,7 +22,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -195,26 +194,20 @@ fun DiagnosticsReportScreen( } if (confirmDelete && report != null) { - AlertDialog( - onDismissRequest = { confirmDelete = false }, - title = { Text("Delete this report?") }, - text = { - Text( - if (report.destinationKind == DiagnosticsDestinationKind.HOSTED) { - "The local evidence will be removed from this device. If this report was already " + - "submitted, its copy in Prairie Diagnostics will also be permanently deleted." - } else { - "The local evidence will be permanently removed from this device." - }, - ) + PrairieConfirmDialog( + title = "Delete this report?", + body = if (report.destinationKind == DiagnosticsDestinationKind.HOSTED) { + "The local evidence will be removed from this device. If this report was already " + + "submitted, its copy in Prairie Diagnostics will also be permanently deleted." + } else { + "The local evidence will be permanently removed from this device." }, - confirmButton = { - TextButton(onClick = { - confirmDelete = false - viewModel.delete(report.id, onBackClick) - }) { Text("Delete") } + confirmLabel = "Delete", + onConfirm = { + confirmDelete = false + viewModel.delete(report.id, onBackClick) }, - dismissButton = { TextButton(onClick = { confirmDelete = false }) { Text("Cancel") } }, + onDismiss = { confirmDelete = false }, ) } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/diagnostics/DiagnosticsSettingsScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/diagnostics/DiagnosticsSettingsScreen.kt index c8371875d6..f8a8a77b19 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/diagnostics/DiagnosticsSettingsScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/diagnostics/DiagnosticsSettingsScreen.kt @@ -1,26 +1,17 @@ package org.prairieserver.prairie.android.ui.screens.settings.diagnostics -import androidx.compose.foundation.layout.Arrangement +import org.prairieserver.prairie.android.ui.components.PrairieConfirmDialog 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.fillMaxSize -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ContentCopy -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -36,22 +27,23 @@ import java.text.DateFormat import java.util.Date import org.koin.compose.viewmodel.koinViewModel import org.prairieserver.prairie.android.ui.components.PrairieTopBar -import org.prairieserver.prairie.android.ui.screens.settings.SettingsChoiceRow +import org.prairieserver.prairie.android.ui.screens.settings.PRAIRIE_PRIVACY_POLICY_URL +import org.prairieserver.prairie.android.ui.screens.settings.SettingsDestructiveRow +import org.prairieserver.prairie.android.ui.screens.settings.SettingsDropdownRow import org.prairieserver.prairie.android.ui.screens.settings.SettingsNavigationRow -import org.prairieserver.prairie.android.ui.screens.settings.SettingsProse +import org.prairieserver.prairie.android.ui.screens.settings.SettingsPageScaffold import org.prairieserver.prairie.android.ui.screens.settings.SettingsRow import org.prairieserver.prairie.android.ui.screens.settings.SettingsSection -import org.prairieserver.prairie.android.ui.screens.settings.SettingsSectionCard import org.prairieserver.prairie.android.ui.screens.settings.SettingsSwitchRow -import org.prairieserver.prairie.android.ui.theme.SettingsDimens -import org.prairieserver.prairie.android.ui.theme.SettingsTextStyles import org.prairieserver.prairie.android.ui.theme.PrairieForeground -import org.prairieserver.prairie.android.ui.theme.PrairieMutedText +import org.prairieserver.prairie.android.ui.theme.PrairieSecondaryText import org.prairieserver.prairie.android.ui.theme.PrairieSettingsBackground +import org.prairieserver.prairie.android.ui.theme.PrairieWarning import org.prairieserver.prairie.android.ui.theme.Spacing import org.prairieserver.prairie.common.diagnostics.DiagnosticsAvailabilityUi import org.prairieserver.prairie.common.diagnostics.DiagnosticsConsentMode import org.prairieserver.prairie.common.diagnostics.DiagnosticsDestinationKind +import org.prairieserver.prairie.model.diagnostics.DiagnosticsReportType import org.prairieserver.prairie.common.diagnostics.DiagnosticsUiState import org.prairieserver.prairie.common.diagnostics.TimedCaptureStatus @@ -80,6 +72,12 @@ fun DiagnosticsSettingsScreen( ) } +/** + * Diagnostics, in the Apple page's order (prairie-apple `DiagnosticsSettingsView`): + * Feature State, Capture, Pending Reports, the send action, and Sent History. + * Android's timed capture sits beside "Send Diagnostics Now"; it has no Apple + * twin. + */ @Composable internal fun DiagnosticsSettingsContent( state: DiagnosticsUiState, @@ -94,8 +92,10 @@ internal fun DiagnosticsSettingsContent( onReportSelected: (String) -> Unit, ) { var confirmAlways by remember { mutableStateOf(false) } + val clipboard = LocalClipboardManager.current val uriHandler = LocalUriHandler.current val model = diagnosticsPhoneScreenModel(state) + val hosted = state.destinationKind == DiagnosticsDestinationKind.HOSTED val effectiveConsent = if ( state.consent == DiagnosticsConsentMode.ALWAYS && !state.allowsAutomaticUpload ) { @@ -103,210 +103,196 @@ internal fun DiagnosticsSettingsContent( } else { state.consent } - Scaffold( - topBar = { - PrairieTopBar( - title = "Diagnostics", - onBackClick = onBackClick, - containerColor = PrairieSettingsBackground, - ) - }, - containerColor = PrairieSettingsBackground, - ) { padding -> - LazyColumn( - modifier = Modifier.fillMaxSize().padding(padding), - contentPadding = PaddingValues(SettingsDimens.pageGutter), - verticalArrangement = Arrangement.spacedBy(SettingsDimens.sectionGap), - ) { - item { - SettingsSection(title = "Send reports to") { - DiagnosticsDestinationKind.entries.forEach { destination -> - val label = when (destination) { - DiagnosticsDestinationKind.HOSTED -> "Prairie Diagnostics" - DiagnosticsDestinationKind.SELF_HOSTED -> "This Prairie server" - } - SettingsChoiceRow( - label = label, - selected = state.destinationKind == destination, - onSelect = { onDestinationChanged(destination) }, - ) - } - SettingsProse( - body = if (state.destinationKind == DiagnosticsDestinationKind.HOSTED) { - "Reports include the Prairie app version and build, Android version, device model, " + - "crash details, and diagnostic logs you review. A pseudonymous installation " + - "credential is not linked to an account on your self-hosted server. Username, " + - "email, profile, server address, and playback session IDs are omitted. Reports " + - "are never sent automatically and may be retained for up to " + - "${state.retentionDays} days." - } else { - "Compatibility mode sends reports to the diagnostics endpoint on your active server." - }, - ) - TextButton( - onClick = { uriHandler.openUri(PRIVACY_POLICY_URL) }, - modifier = Modifier.padding( - start = SettingsDimens.rowHorizontalPadding - 12.dp, - bottom = SettingsDimens.rowVerticalPadding, - ), - ) { - Text("Privacy Policy") - } - } + val consentOptions = DiagnosticsConsentMode.entries + .filter { it != DiagnosticsConsentMode.ALWAYS || state.allowsAutomaticUpload } + val captureActive = state.timedCapture.status == TimedCaptureStatus.ACTIVE + SettingsPageScaffold(title = "Diagnostics", onBackClick = onBackClick) { + item(key = "state") { + SettingsSection(title = "Feature State", footer = availabilityDetail(state)) { + SettingsNavigationRow(label = "Status", value = availabilityTitle(state.availability)) + SettingsNavigationRow(label = "Destination", value = destinationLabel(state.destinationKind)) } - item { DiagnosticsStatusCard(state) } - item { - SettingsSection(title = "Crash reports") { - DiagnosticsConsentMode.entries - .filter { it != DiagnosticsConsentMode.ALWAYS || state.allowsAutomaticUpload } - .forEach { mode -> - val label = when (mode) { - DiagnosticsConsentMode.ASK -> "Ask before sending" - DiagnosticsConsentMode.ALWAYS -> "Always send" - DiagnosticsConsentMode.NEVER -> "Never send" + } + item(key = "capture") { + SettingsSection( + title = "Capture", + footer = if (hosted) { + "Reports include the Prairie app version and build, Android version, device model, " + + "crash details, and diagnostic logs you review. A pseudonymous installation " + + "credential is not linked to an account on your self-hosted server. Username, " + + "email, profile, server address, and playback session IDs are omitted. Reports " + + "are never sent automatically and may be retained for up to " + + "${state.retentionDays} days." + } else { + "Crash report consent is tied to this server account. Debug logging is a setting " + + "for this device." + }, + ) { + SettingsDropdownRow( + label = "Send Reports To", + value = destinationLabel(state.destinationKind), + options = DiagnosticsDestinationKind.entries.map(::destinationLabel), + onOptionSelected = { label -> + DiagnosticsDestinationKind.entries + .firstOrNull { destinationLabel(it) == label } + ?.let(onDestinationChanged) + }, + ) + SettingsSwitchRow( + label = "Debug Logging", + checked = state.debugLogging, + enabled = state.consent != DiagnosticsConsentMode.NEVER, + onCheckedChange = onDebugLoggingChanged, + ) + SettingsDropdownRow( + label = "Crash Reports", + value = consentLabel(effectiveConsent), + options = consentOptions.map(::consentLabel), + onOptionSelected = { label -> + consentOptions.firstOrNull { consentLabel(it) == label }?.let { mode -> + if (consentActionModel(state.consent, mode).requiresConfirmation) { + confirmAlways = true + } else { + onConsentChanged(mode) } - SettingsChoiceRow( - label = label, - selected = effectiveConsent == mode, - onSelect = { - if (consentActionModel(state.consent, mode).requiresConfirmation) { - confirmAlways = true - } else { - onConsentChanged(mode) - } - }, - ) } - // The shared switch row now carries `enabled`, so this no - // longer needs its own hand-rolled copy of it. - SettingsSwitchRow( - label = "Debug logging", - description = "Record extra detail so a report can explain what went wrong.", - checked = state.debugLogging, - enabled = state.consent != DiagnosticsConsentMode.NEVER, - onCheckedChange = onDebugLoggingChanged, + }, + ) + // The policy behind the consent above, one tap away where the + // choice is made (Settings → About links it too). + SettingsNavigationRow( + label = "Privacy Policy", + onClick = { uriHandler.openUri(PRAIRIE_PRIVACY_POLICY_URL) }, + ) + } + } + item(key = "pending") { + SettingsSection(title = "Pending Reports (${state.pending.size})") { + if (state.pending.isEmpty()) { + SettingsNavigationRow(label = "No pending reports", labelColor = PrairieSecondaryText) + } + state.pending.forEach { report -> + SettingsNavigationRow( + label = report.type.displayName(), + description = "${report.capturedAt} · ${formatDiagnosticBytes(report.evidenceBytes)}", + onClick = { onReportSelected(report.id) }, ) } } - item { - SettingsSection(title = "Capture") { - val paneModifier = Modifier.padding( - horizontal = SettingsDimens.proseHorizontalPadding, - vertical = SettingsDimens.proseVerticalPadding, + } + item(key = "send") { + SettingsSection( + title = null, + footer = when { + captureActive -> "Capture is running. Reproduce the issue, then stop to review exactly " + + "what will be sent." + !state.debugLogging -> "Debug logging is off. This report contains only the last few " + + "minutes of basic logs. Timed capture records more detail until you stop it." + else -> "A one-time report uses the recent in-memory log. Timed capture records more " + + "detail until you stop it." + }, + footerColor = if (!captureActive && !state.debugLogging) PrairieWarning else PrairieSecondaryText, + ) { + if (captureActive) { + SettingsNavigationRow(label = "Stop & Review", onClick = onStopCapture, showChevron = false) + SettingsDestructiveRow(label = "Cancel Capture", onClick = onCancelCapture) + } else { + SettingsNavigationRow( + label = "Send Diagnostics Now", + onClick = onSendNow, + enabled = model.canCapture, + showChevron = false, + ) + SettingsNavigationRow( + label = "Start Diagnostic Capture", + onClick = onStartCapture, + enabled = model.canCapture, + showChevron = false, ) - if (state.timedCapture.status == TimedCaptureStatus.ACTIVE) { - Column(paneModifier) { - Text( - "Diagnostic capture is running", - style = SettingsTextStyles.rowLabel, - color = PrairieForeground, - ) - Text( - "Reproduce the issue, then stop to review exactly what will be sent.", - color = PrairieMutedText, - style = SettingsTextStyles.rowDescription, - ) - Spacer(Modifier.height(Spacing.md)) - Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { - Button(onClick = onStopCapture) { Text("Stop & review") } - OutlinedButton(onClick = onCancelCapture) { Text("Cancel") } - } - } - } else { - Column(paneModifier) { - Button(onClick = onSendNow, enabled = model.canCapture) { - Text("Send diagnostics now") - } - Spacer(Modifier.height(Spacing.sm)) - OutlinedButton(onClick = onStartCapture, enabled = model.canCapture) { - Text("Start diagnostic capture") - } - Text( - "A one-time report uses the recent in-memory log. Timed capture records more detail until you stop it.", - color = PrairieMutedText, - style = SettingsTextStyles.rowDescription, - modifier = Modifier.padding(top = Spacing.sm), - ) - } - } } } - if (model.showPending) { - item { - SettingsSection(title = "Pending reports") { - state.pending.forEach { report -> - SettingsNavigationRow( - label = report.type.displayName(), - description = "${report.capturedAt} · ${formatDiagnosticBytes(report.evidenceBytes)}", - onClick = { onReportSelected(report.id) }, - ) - } - } + } + item(key = "sent") { + SettingsSection( + title = "Sent History", + footer = if (state.sentHistory.isEmpty()) { + null + } else { + "Sent reports are removed from this device once the selected destination has a copy. " + + "Use the reference ID when asking for help." + }, + ) { + if (state.sentHistory.isEmpty()) { + SettingsNavigationRow(label = "No reports sent yet", labelColor = PrairieSecondaryText) } - } - if (state.sentHistory.isNotEmpty()) { - item { - val clipboard = LocalClipboardManager.current - Column { - SettingsSection(title = "Recently sent") { - state.sentHistory.forEach { sent -> - SettingsRow( - label = sent.shortId, - description = "${sent.state.replace('_', ' ')} · " + - formatDiagnosticDate(sent.sentAtEpochMs), - ) { - IconButton( - onClick = { clipboard.setText(AnnotatedString(sent.shortId)) }, - ) { - Icon( - imageVector = Icons.Outlined.ContentCopy, - contentDescription = "Copy reference ID", - tint = PrairieMutedText, - modifier = Modifier.size(18.dp), - ) - } - } - } + state.sentHistory.forEach { sent -> + SettingsRow( + label = sent.shortId, + description = "${sent.state.replace('_', ' ')} · " + + formatDiagnosticDate(sent.sentAtEpochMs), + ) { + IconButton( + onClick = { clipboard.setText(AnnotatedString(sent.shortId)) }, + ) { + Icon( + imageVector = Icons.Outlined.ContentCopy, + contentDescription = "Copy reference ID", + tint = PrairieSecondaryText, + modifier = Modifier.size(18.dp), + ) } - Text( - "Sent reports are removed from this device once the selected destination has a copy. " + - "Use the reference ID when asking for help.", - color = PrairieMutedText, - style = SettingsTextStyles.rowDescription, - modifier = Modifier.padding( - start = SettingsDimens.headerStartInset, - end = SettingsDimens.headerStartInset, - top = Spacing.sm, - ), - ) } } } - item { Spacer(Modifier.height(SettingsDimens.pageBottomSpacer)) } } } if (confirmAlways && state.allowsAutomaticUpload) { - AlertDialog( - onDismissRequest = { confirmAlways = false }, - title = { Text("Always send crash reports?") }, - text = { - Text("Future eligible crash reports may be uploaded automatically. You can inspect pending reports and change this at any time.") - }, - confirmButton = { - TextButton(onClick = { - confirmAlways = false - onConsentChanged(DiagnosticsConsentMode.ALWAYS) - }) { Text("Always send") } - }, - dismissButton = { - TextButton(onClick = { confirmAlways = false }) { Text("Cancel") } + PrairieConfirmDialog( + title = "Always send crash reports?", + body = "Future eligible crash reports may be uploaded automatically. You can inspect pending reports and change this at any time.", + confirmLabel = "Always send", + destructive = false, + onConfirm = { + confirmAlways = false + onConsentChanged(DiagnosticsConsentMode.ALWAYS) }, + onDismiss = { confirmAlways = false }, ) } } -private const val PRIVACY_POLICY_URL = "https://prairieserver.org/privacy" +private fun destinationLabel(destination: DiagnosticsDestinationKind): String = when (destination) { + DiagnosticsDestinationKind.HOSTED -> "Prairie Diagnostics" + DiagnosticsDestinationKind.SELF_HOSTED -> "My Prairie Server" +} + +private fun consentLabel(mode: DiagnosticsConsentMode): String = when (mode) { + DiagnosticsConsentMode.ASK -> "Ask" + DiagnosticsConsentMode.ALWAYS -> "Always" + DiagnosticsConsentMode.NEVER -> "Never" +} + +private fun availabilityTitle(availability: DiagnosticsAvailabilityUi): String = when (availability) { + DiagnosticsAvailabilityUi.AVAILABLE -> "Available" + DiagnosticsAvailabilityUi.DISABLED -> "Disabled by server" + DiagnosticsAvailabilityUi.STORAGE_UNAVAILABLE -> "Storage unavailable" + DiagnosticsAvailabilityUi.OFFLINE -> "Offline" + DiagnosticsAvailabilityUi.INELIGIBLE -> "Unavailable" +} + +/** + * The Feature State footer: what a degraded availability means for reports. + * Like the Apple page, a working state needs no footer. + */ +private fun availabilityDetail(state: DiagnosticsUiState): String? = when (state.availability) { + DiagnosticsAvailabilityUi.AVAILABLE -> null + DiagnosticsAvailabilityUi.DISABLED -> "Local reports remain available to inspect or delete." + DiagnosticsAvailabilityUi.STORAGE_UNAVAILABLE -> "Local reports remain on this device." + DiagnosticsAvailabilityUi.OFFLINE -> + "Showing the last known diagnostics state. Reports stay on this device while the server is offline." + DiagnosticsAvailabilityUi.INELIGIBLE -> "Diagnostics are not available for this profile." +} @Composable private fun DiagnosticsUnavailableScreen(onBackClick: () -> Unit) { @@ -316,6 +302,7 @@ private fun DiagnosticsUnavailableScreen(onBackClick: () -> Unit) { title = "Diagnostics", onBackClick = onBackClick, containerColor = PrairieSettingsBackground, + centerTitle = true, ) }, containerColor = PrairieSettingsBackground, @@ -330,24 +317,14 @@ private fun DiagnosticsUnavailableScreen(onBackClick: () -> Unit) { } } -@Composable -private fun DiagnosticsStatusCard(state: DiagnosticsUiState) { - val destination = if (state.destinationKind == DiagnosticsDestinationKind.HOSTED) "Prairie Diagnostics" else "this server" - val (title, detail) = when (state.availability) { - DiagnosticsAvailabilityUi.AVAILABLE -> "Available" to "Reports can be reviewed and sent to $destination." - DiagnosticsAvailabilityUi.DISABLED -> "Disabled" to "Local reports remain available to inspect or delete." - DiagnosticsAvailabilityUi.STORAGE_UNAVAILABLE -> "Storage unavailable" to "Local reports remain on this device." - DiagnosticsAvailabilityUi.OFFLINE -> "Offline" to "Connect to refresh diagnostics availability." - DiagnosticsAvailabilityUi.INELIGIBLE -> "Unavailable" to "Diagnostics are not available for this profile." - } - SettingsSectionCard { - SettingsProse(title = title, body = detail) - } +/** The Apple apps' report titles; Android's ANR and native crash fold into them. */ +internal fun DiagnosticsReportType.displayName(): String = when (this) { + DiagnosticsReportType.CRASH, DiagnosticsReportType.NATIVE_CRASH -> "Crash" + DiagnosticsReportType.HANG, DiagnosticsReportType.ANR -> "Not Responding" + DiagnosticsReportType.ABNORMAL_EXIT -> "Unclean Shutdown" + DiagnosticsReportType.MANUAL -> "Manual Report" } -internal fun org.prairieserver.prairie.model.diagnostics.DiagnosticsReportType.displayName(): String = - name.lowercase().replace('_', ' ').replaceFirstChar(Char::uppercase) - internal fun formatDiagnosticBytes(bytes: Long): String = when { bytes >= 1_048_576 -> "%.1f MB".format(bytes / 1_048_576.0) bytes >= 1_024 -> "%.1f KB".format(bytes / 1_024.0) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/shuffle/ShuffleLauncher.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/shuffle/ShuffleLauncher.kt new file mode 100644 index 0000000000..ce56edeb45 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/shuffle/ShuffleLauncher.kt @@ -0,0 +1,21 @@ +package org.prairieserver.prairie.android.ui.screens.shuffle + +import androidx.compose.runtime.Composable +import org.koin.compose.koinInject +import org.prairieserver.prairie.android.ui.navigation.Route +import org.prairieserver.prairie.common.ui.ShuffleLauncher +import org.prairieserver.prairie.model.shuffle.Shuffle + +/** A [ShuffleLauncher] that calls [onStarted] with each shuffle it starts. */ +@Composable +fun rememberShuffleLauncher(onStarted: (Shuffle) -> Unit): ShuffleLauncher = + org.prairieserver.prairie.common.ui.rememberShuffleLauncher(koinInject(), onStarted) + +/** The player route for a shuffle's first pick, which plays from the beginning. */ +fun shufflePlayerRoute(shuffle: Shuffle, libraryId: Int? = null): String = + Route.Player( + contentId = shuffle.current.contentId, + resumePositionSeconds = 0.0, + libraryId = libraryId, + shuffleId = shuffle.id, + ).route diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartyComponents.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartyComponents.kt new file mode 100644 index 0000000000..640820d4de --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartyComponents.kt @@ -0,0 +1,121 @@ +package org.prairieserver.prairie.android.ui.screens.watchparty + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import org.prairieserver.prairie.android.ui.components.PrairieConfirmDialog +import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.repository.WatchTogetherRepository +import org.prairieserver.prairie.watchtogether.RoomSession + +/** A reconnect shorter than this is routine (socket rotation) and stays silent. */ +internal const val WATCH_PARTY_RECONNECT_NOTICE_DELAY_MS = 2_000L + +/** + * True once [disconnected] has held for two seconds; false again the moment + * it clears. [since] identifies one disconnection, so a new one restarts the + * wait. + */ +@Composable +internal fun rememberWatchPartyReconnectNotice(disconnected: Boolean, since: Long?): Boolean { + var show by remember { mutableStateOf(false) } + LaunchedEffect(disconnected, since) { + show = false + if (disconnected) { + delay(WATCH_PARTY_RECONNECT_NOTICE_DELAY_MS) + show = true + } + } + return show +} + +/** Confirms ending the party for everyone (host). */ +@Composable +internal fun WatchPartyEndDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { + PrairieConfirmDialog( + title = "End the party for everyone?", + body = "Everyone leaves the Watch Party, and it can't be rejoined.", + confirmLabel = "End party", + onConfirm = onConfirm, + onDismiss = onDismiss, + ) +} + +/** Explains a host's Leave before it happens: the room outlives them for two minutes. */ +@Composable +internal fun WatchPartyHostLeaveDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { + PrairieConfirmDialog( + title = "Leave the party?", + body = "You're the host. The party ends for everyone two minutes after you leave, unless you rejoin.", + confirmLabel = "Leave", + onConfirm = onConfirm, + onDismiss = onDismiss, + ) +} + +/** + * The D5 guard: while this device is in a Watch Party, starting anything + * else on the shared player first asks the user to leave the party. + */ +@Stable +class WatchPartySoloGuard internal constructor( + private val repository: WatchTogetherRepository, + private val roomSession: RoomSession, + private val scope: CoroutineScope, +) { + internal var pending by mutableStateOf<(() -> Unit)?>(null) + internal var pendingFromHost by mutableStateOf(false) + + /** Runs [start] now, or after the user agrees to leave the party. */ + fun run(start: () -> Unit) { + val room = repository.roomSnapshot.value + if (room == null) { + start() + } else { + pendingFromHost = room.selfRole == MemberRole.Host + pending = start + } + } + + internal fun leaveAndRun() { + val start = pending ?: return + pending = null + scope.launch { + roomSession.depart().join() + start() + } + } +} + +@Composable +fun rememberWatchPartySoloGuard(): WatchPartySoloGuard { + val repository: WatchTogetherRepository = koinInject() + val roomSession: RoomSession = koinInject() + val scope = rememberCoroutineScope() + return remember(repository, roomSession, scope) { WatchPartySoloGuard(repository, roomSession, scope) } +} + +@Composable +fun WatchPartySoloGuardDialog(guard: WatchPartySoloGuard) { + if (guard.pending == null) return + PrairieConfirmDialog( + title = "Leave the Watch Party to play this?", + body = if (guard.pendingFromHost) { + "You're the host. The party ends for everyone two minutes after you leave, unless you rejoin." + } else { + "You'll leave the party and play this on your own." + }, + confirmLabel = "Leave and play", + onConfirm = guard::leaveAndRun, + onDismiss = { guard.pending = null }, + ) +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartyDetailActions.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartyDetailActions.kt new file mode 100644 index 0000000000..a5551ee7a5 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartyDetailActions.kt @@ -0,0 +1,198 @@ +package org.prairieserver.prairie.android.ui.screens.watchparty + +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.android.ui.components.PrairieConfirmDialog +import org.prairieserver.prairie.model.feature.WatchPartyExposure +import org.prairieserver.prairie.model.watchtogether.AddSuggestionRequest +import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.repository.WatchTogetherRepository +import org.prairieserver.prairie.viewmodel.WatchPartyItem +import org.prairieserver.prairie.watchtogether.WatchPartyAvailability +import org.prairieserver.prairie.watchtogether.WatchPartyAvailabilityRepository +import org.prairieserver.prairie.watchtogether.newWatchPartyId +import org.prairieserver.prairie.watchtogether.watchPartyEligibility +import org.prairieserver.prairie.watchtogether.watchPartyErrorMessage +import org.prairieserver.prairie.watchtogether.watchPartyHostingOffered + +/** The detail page's one Watch Party overflow action. */ +data class DetailPartyAction(val label: String, val onClick: () -> Unit) + +/** + * The detail page's party operations (D9): a guest suggests, a host in the + * lobby stages, and a host whose party is playing switches everyone to the + * item through the selection operation. A suggestion whose outcome is unknown + * keeps its id, so tapping again for the same title replays it rather than + * adding a second one. + */ +class WatchPartyDetailViewModel( + private val repository: WatchTogetherRepository, + private val handoff: WatchPartyHandoff, +) : ViewModel() { + private val _messages = MutableSharedFlow(extraBufferCapacity = 4, onBufferOverflow = BufferOverflow.DROP_OLDEST) + val messages: SharedFlow = _messages.asSharedFlow() + private var suggestionDraft: AddSuggestionRequest? = null + + fun stage(item: WatchPartyItem) { + handoff.rememberPreview(item) + viewModelScope.launch { + val result = repository.stageSelection(SetSelectionRequest(item.contentId, item.fileId, item.libraryId)) + _messages.tryEmit( + if (result is ApiResult.Success) { + "Added ${item.title} to the party." + } else { + watchPartyErrorMessage(result, "Couldn't add ${item.title}.") + }, + ) + } + } + + fun select(item: WatchPartyItem, onSelected: (RoomSnapshot) -> Unit) { + handoff.rememberPreview(item) + viewModelScope.launch { + when (val result = repository.setSelection(SetSelectionRequest(item.contentId, item.fileId, item.libraryId))) { + is ApiResult.Success -> onSelected(result.data.room) + else -> _messages.tryEmit(watchPartyErrorMessage(result, "Couldn't switch to ${item.title}.")) + } + } + } + + fun suggest(item: WatchPartyItem) { + val request = suggestionDraft?.takeIf { it.contentId == item.contentId } ?: AddSuggestionRequest( + suggestionId = newWatchPartyId(), + contentId = item.contentId, + contentType = item.contentType, + title = item.title, + subtitle = item.subtitle, + posterUrl = item.posterUrl, + ) + // Held before sending, so a second tap while this one is in flight + // resends the same suggestion id instead of adding a duplicate. + suggestionDraft = request + viewModelScope.launch { + when (val result = repository.addSuggestion(request)) { + is ApiResult.Success -> { + if (suggestionDraft == request) suggestionDraft = null + _messages.tryEmit("Suggested ${item.title} to the party.") + } + is ApiResult.NetworkError -> { + suggestionDraft = request + _messages.tryEmit("Couldn't confirm your suggestion. Try again.") + } + is ApiResult.Error -> { + if (suggestionDraft == request) suggestionDraft = request.takeIf { result.error == "invalid_response" } + _messages.tryEmit(watchPartyErrorMessage(result, "Couldn't suggest ${item.title}.")) + } + } + } + } +} + +/** + * Builds the detail page's party action for an item. Made by + * [rememberWatchPartyDetailActions], which also returns the dialogs to compose. + */ +@Stable +class WatchPartyDetailActions internal constructor( + private val enabled: Boolean, + private val room: RoomSnapshot?, + private val canStage: Boolean, + private val canSuggest: Boolean, + private val canHost: Boolean, + private val onHost: (WatchPartyItem) -> Unit, + private val viewModel: WatchPartyDetailViewModel, + private val confirmSelect: (WatchPartyItem) -> Unit, +) { + /** The action for [item], or null when Watch Party is off or nothing applies. */ + fun actionFor(item: WatchPartyItem?): DetailPartyAction? { + if (!enabled || item == null) return null + val party = room ?: return if (canHost) DetailPartyAction("Watch Party") { onHost(item) } else null + val host = party.selfRole == MemberRole.Host && party.selfCanManageRoom + return when { + host && party.phase == RoomPhase.Lobby && canStage -> + DetailPartyAction("Add to party") { viewModel.stage(item) } + // The server refuses a direct selection in a voting room; those suggest instead. + host && party.phase == RoomPhase.Playing && party.selectionMode == RoomSelectionMode.HostPick -> + DetailPartyAction("Play for everyone") { confirmSelect(item) } + canSuggest -> DetailPartyAction("Suggest to Party") { viewModel.suggest(item) } + else -> null + } + } +} + +@Composable +fun rememberWatchPartyDetailActions( + onHost: (WatchPartyItem) -> Unit, + onSelectedWhilePlaying: (RoomSnapshot) -> Unit, +): Pair Unit> { + val exposure: WatchPartyExposure = koinInject() + val repository: WatchTogetherRepository = koinInject() + val availability: WatchPartyAvailabilityRepository = koinInject() + val viewModel: WatchPartyDetailViewModel = koinViewModel() + val enabled by exposure.enabled.collectAsState() + val room by repository.roomSnapshot.collectAsState() + val available by availability.availability.collectAsState() + val context = LocalContext.current + var selecting by remember { mutableStateOf(null) } + + LaunchedEffect(viewModel) { + viewModel.messages.collect { Toast.makeText(context, it, Toast.LENGTH_SHORT).show() } + } + + // Not gated on an action in flight: the repository runs one at a time, + // and the menu item should not flicker while another request finishes. + val eligibility = watchPartyEligibility( + room = room, + features = (available as? WatchPartyAvailability.Available)?.features, + busy = false, + personalVotesKnown = true, + ) + val actions = WatchPartyDetailActions( + enabled = enabled, + room = room, + canStage = eligibility.canStage, + canSuggest = eligibility.canSuggest, + canHost = watchPartyHostingOffered(available), + onHost = onHost, + viewModel = viewModel, + confirmSelect = { selecting = it }, + ) + val dialogs: @Composable () -> Unit = { + selecting?.let { item -> + PrairieConfirmDialog( + title = "Play this for everyone?", + body = "The party switches to it now.", + confirmLabel = "Play for everyone", + destructive = false, + onConfirm = { + selecting = null + viewModel.select(item, onSelectedWhilePlaying) + }, + onDismiss = { selecting = null }, + ) + } + } + return actions to dialogs +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartyHandoff.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartyHandoff.kt new file mode 100644 index 0000000000..790f3390ea --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartyHandoff.kt @@ -0,0 +1,71 @@ +package org.prairieserver.prairie.android.ui.screens.watchparty + +import java.util.UUID +import org.prairieserver.prairie.viewmodel.WatchPartyItem +import org.prairieserver.prairie.watchtogether.WatchPartyInvite + +/** + * Process-memory handoffs into the Watch Party hub, keyed by opaque ids that + * routes may carry: + * - an invitation link, whose join token is a secret and must never reach a + * route string, saved state, or logs; + * - the item a detail page asked to host with. + * + * Each slot holds one entry; a newer offer replaces the older one, and a take + * consumes it, so recreation or a restored route never replays it. After + * process death the slots are empty and the hub simply opens. + * + * It also remembers title previews for staged items so the lobby can lay out + * a staged title before any catalog read. + */ +class WatchPartyHandoff { + private val lock = Any() + private var invite: Pair? = null + private var host: Pair? = null + private val previews = LinkedHashMap() + + fun offerInvite(link: WatchPartyInvite.Link): String = synchronized(lock) { + val id = watchPartyInviteHandoffId(link) + invite = id to link + id + } + + fun takeInvite(id: String?): WatchPartyInvite.Link? = synchronized(lock) { + val pending = invite?.takeIf { id != null && it.first == id } ?: return@synchronized null + invite = null + pending.second + } + + fun offerHost(item: WatchPartyItem): String = synchronized(lock) { + val id = UUID.randomUUID().toString() + host = id to item + rememberPreviewLocked(item) + id + } + + fun takeHost(id: String?): WatchPartyItem? = synchronized(lock) { + val pending = host?.takeIf { id != null && it.first == id } ?: return@synchronized null + host = null + pending.second + } + + fun rememberPreview(item: WatchPartyItem) = synchronized(lock) { rememberPreviewLocked(item) } + + fun preview(contentId: String?): WatchPartyItem? = synchronized(lock) { + contentId?.let { previews[it] } + } + + private fun rememberPreviewLocked(item: WatchPartyItem) { + previews.remove(item.contentId) + previews[item.contentId] = item + while (previews.size > MAX_PREVIEWS) previews.remove(previews.keys.first()) + } + + private companion object { + const val MAX_PREVIEWS = 24 + } +} + +/** The hub route that joins [link] once handed over. It carries only an opaque id, never the token. */ +internal fun watchPartyInviteRoute(link: WatchPartyInvite.Link): String = + org.prairieserver.prairie.android.ui.navigation.Route.WatchPartyHub(invite = watchPartyInviteHandoffId(link)).route diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartyHubScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartyHubScreen.kt new file mode 100644 index 0000000000..2c0a57c353 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartyHubScreen.kt @@ -0,0 +1,463 @@ +package org.prairieserver.prairie.android.ui.screens.watchparty + +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.outlined.ContentPaste +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.android.cast.PrairieCastSessionManager +import org.prairieserver.prairie.android.ui.components.PrairieConfirmDialog +import org.prairieserver.prairie.android.ui.theme.PrairieOnSurface +import org.prairieserver.prairie.android.ui.theme.PrairieSecondaryText +import org.prairieserver.prairie.model.feature.WatchPartyExposure +import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.repository.WatchPartyPendingAction +import org.prairieserver.prairie.repository.WatchTogetherRepository +import org.prairieserver.prairie.viewmodel.WatchPartyHubViewModel +import org.prairieserver.prairie.watchtogether.RoomSession +import org.prairieserver.prairie.watchtogether.WatchPartyAvailability +import org.prairieserver.prairie.watchtogether.WatchPartyDestination + +/** + * The Watch Party hub, opened from the profile menu, a detail page's Watch + * Party action, or an invitation. It shows whether this server supports + * Watch Party, returns to the live party, rejoins the recent one, and hosts + * or joins. + * + * [inviteId] and [hostId] name one-shot handoffs held by [WatchPartyHandoff]; + * each is taken once, so recreation or a restored route never replays it. + */ +@Composable +fun WatchPartyHubScreen( + inviteId: String?, + hostId: String?, + onBack: () -> Unit, + onOpenLobby: (roomId: String) -> Unit, + onOpenPlayer: (RoomSnapshot) -> Unit, + viewModel: WatchPartyHubViewModel = koinViewModel(), +) { + val state by viewModel.uiState.collectAsState() + val handoff: WatchPartyHandoff = koinInject() + val repository: WatchTogetherRepository = koinInject() + val roomSession: RoomSession = koinInject() + val exposure: WatchPartyExposure = koinInject() + val enabled by exposure.enabled.collectAsState() + val castManager: PrairieCastSessionManager = koinInject() + val castState by castManager.castState.collectAsState() + val casting = castState.isConnected + val context = LocalContext.current + val clipboard = LocalClipboardManager.current + var code by rememberSaveable { mutableStateOf("") } + var dismissedEnded by rememberSaveable { mutableStateOf(null) } + var confirmHostLeave by rememberSaveable { mutableStateOf(false) } + + val available = state.availability is WatchPartyAvailability.Available + // Keep the handoff until this server supports room and playback + // capabilities, including while a failed probe waits for Retry. + // A forced re-check keeps the old answer published, so wait for it: the + // server may have turned Watch Party off since that answer. + LaunchedEffect(inviteId, available, enabled, casting, state.checking) { + if (!available || !enabled || casting || state.checking) return@LaunchedEffect + handoff.takeInvite(inviteId)?.let(viewModel::joinInvite) + } + + // A detail page's "Watch Party": host with that item once the server is + // known to support hosting. Unsupported servers show why instead. + LaunchedEffect(hostId, available, enabled, casting, state.checking) { + if (!available || !enabled || casting || state.checking) return@LaunchedEffect + handoff.takeHost(hostId)?.let(viewModel::hostWithItem) + } + + LaunchedEffect(state.destination, casting) { + if (casting) return@LaunchedEffect + val destination = state.destination ?: return@LaunchedEffect + viewModel.consumeDestination() + when (destination) { + is WatchPartyDestination.Lobby -> onOpenLobby(destination.roomId) + is WatchPartyDestination.Player -> { + val room = repository.roomSnapshot.value?.takeIf { it.roomId == destination.roomId } + if (room != null) onOpenPlayer(room) else onOpenLobby(destination.roomId) + } + } + } + + val busy = state.busy != null + val current = state.current + + // Probe again whenever the hub is shown outside a party, as Apple does: + // the server can turn Watch Party off at any time, and Rejoin should + // reflect a party that just ended. A failed probe keeps the last answer. + LaunchedEffect(current?.roomId, state.ended) { + if (current == null) viewModel.refresh(force = true) + } + val join: () -> Unit = { + if (!busy) viewModel.join(code.trim()) + } + + // A party uses this device's player. Wait for the receiver to disconnect + // before joining, creating, or consuming a detail/invitation handoff. + if (enabled && casting) { + PrairieConfirmDialog( + title = "Stop casting to use Watch Party?", + body = "Casting will stop. Watch Party plays on this device.", + confirmLabel = "Stop casting", + onConfirm = castManager::disconnect, + onDismiss = onBack, + ) + return + } + + val canEnter = enabled && available && !busy + val canHost = (state.availability as? WatchPartyAvailability.Available)?.features?.stagedSelection == true + Box(Modifier.fillMaxSize()) { + WatchPartyBackdrop(url = null) + Column( + Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)), + ) { + WatchPartyTopBar(onBack = onBack) + Column( + verticalArrangement = Arrangement.spacedBy(28.dp), + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .imePadding() + .navigationBarsPadding() + .padding(horizontal = WatchPartyMetrics.pageInset) + .padding(bottom = 40.dp), + ) { + HubHeadline(Modifier.padding(top = 24.dp)) + + // Why the last party ended; Rejoin when the party itself goes on. + val ended = state.ended + val endedKey = ended?.let { "${it.roomId}:${it.reason}" } + val showEnded = ended != null && current == null && dismissedEnded != endedKey + val endedRejoin = showEnded && enabled && available && ended != null && + watchPartyEndedOffersRejoin(ended.reason) && state.recent?.roomId == ended.roomId + val banners = showEnded || !enabled || !available || state.error != null || busy + if (banners) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + if (showEnded && ended != null) { + WatchPartyBanner( + text = watchPartyEndedMessage(ended.reason), + action = { + if (endedRejoin) { + TextButton(onClick = viewModel::rejoinRecent, enabled = !busy) { + Text("Rejoin", color = PrairieOnSurface) + } + } + DismissButton { dismissedEnded = endedKey } + }, + ) + } + if (!enabled) { + WatchPartyBanner("Watch Party is turned off. Turn it on in Settings, under Experimental.") + } else { + when (state.availability) { + null -> WatchPartyBanner("Checking Watch Party support…") + is WatchPartyAvailability.Unsupported -> + WatchPartyBanner("This server doesn't support Watch Party yet.") + WatchPartyAvailability.NotAllowed -> + WatchPartyBanner("Your account can't use Watch Party.") + is WatchPartyAvailability.ProbeFailed -> WatchPartyBanner( + "Couldn't check whether this server supports Watch Party.", + tone = WatchPartyBannerTone.Warning, + ) + is WatchPartyAvailability.Available -> Unit + } + } + // Errors stay inline; an uncertain create keeps its room id for Retry. + state.error?.let { message -> + WatchPartyBanner( + text = message, + tone = WatchPartyBannerTone.Warning, + action = { + if (state.createRetryable) { + TextButton(onClick = viewModel::retryCreate, enabled = !busy) { + Text("Retry", color = PrairieOnSurface) + } + } + DismissButton(viewModel::clearError) + }, + ) + } + if (busy) { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator( + color = PrairieSecondaryText, + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(12.dp)) + Text( + when (state.busy) { + WatchPartyPendingAction.Create -> "Starting your party…" + WatchPartyPendingAction.Join -> "Joining…" + WatchPartyPendingAction.Stage -> "Adding your title…" + else -> "Working…" + }, + fontSize = WatchPartyMetrics.BODY.sp, + color = PrairieSecondaryText, + ) + } + } + } + } + + if (!enabled) return@Column + + if (current != null) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + WatchPartyEyebrow("You're in a Watch Party") + if (current.code.isNotBlank()) { + Text( + current.code, + fontSize = WatchPartyMetrics.CODE.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + letterSpacing = (WatchPartyMetrics.CODE * 0.18f).sp, + color = PrairieOnSurface, + ) + } + WatchPartyButton( + text = "Return to party", + icon = Icons.Filled.PlayArrow, + onClick = viewModel::returnToParty, + enabled = !busy, + modifier = Modifier.fillMaxWidth(), + ) + WatchPartyButton( + text = "Leave party", + kind = WatchPartyButtonKind.Secondary, + onClick = { + if (current.selfRole == MemberRole.Host) confirmHostLeave = true else roomSession.depart() + }, + enabled = !busy, + modifier = Modifier.fillMaxWidth(), + ) + Text( + "Leave this party to host or join another one.", + fontSize = WatchPartyMetrics.CAPTION.sp, + color = PrairieSecondaryText, + ) + } + return@Column + } + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + // Hosting needs staged selection; a server without it can still be joined. + if (!available || canHost) { + WatchPartyButton( + text = "Start a party", + icon = Icons.Filled.Add, + onClick = { viewModel.host() }, + enabled = canEnter, + modifier = Modifier.fillMaxWidth(), + ) + WatchPartyButton( + text = "Start a party and let everyone vote", + kind = WatchPartyButtonKind.Secondary, + onClick = { viewModel.host(RoomSelectionMode.Vote) }, + enabled = canEnter, + modifier = Modifier.fillMaxWidth(), + ) + } else { + WatchPartyBanner(text = "This server can't host a Watch Party. You can still join one.") + } + state.recent?.takeIf { !endedRejoin }?.let { recent -> + WatchPartyButton( + text = recent.title?.takeIf { it.isNotBlank() }?.let { "Rejoin $it" } + ?: "Rejoin recent party", + icon = Icons.Filled.Refresh, + kind = WatchPartyButtonKind.Outlined, + onClick = viewModel::rejoinRecent, + enabled = canEnter, + modifier = Modifier.fillMaxWidth(), + ) + } + } + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + WatchPartyEyebrow("Have a code?") + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + WatchPartyCodeField( + value = code, + onValueChange = { code = it }, + enabled = !busy, + onGo = { if (code.isNotBlank() && canEnter) join() }, + modifier = Modifier.weight(1f), + ) + WatchPartyCircleButton( + icon = Icons.Outlined.ContentPaste, + contentDescription = "Paste", + size = 52.dp, + shape = RoundedCornerShape(14.dp), + enabled = !busy, + onClick = { + val pasted = clipboard.getText()?.text?.trim().orEmpty() + if (pasted.isEmpty()) { + Toast.makeText(context, "Copy an invitation or party code first.", Toast.LENGTH_SHORT).show() + } else { + code = pasted + } + }, + ) + } + WatchPartyButton( + text = "Join party", + kind = WatchPartyButtonKind.Secondary, + onClick = join, + enabled = canEnter && code.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) + if (state.availability != null && !available) { + WatchPartyButton( + text = "Check again", + kind = WatchPartyButtonKind.Secondary, + onClick = { viewModel.refresh(force = true) }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } + } + + if (confirmHostLeave) { + WatchPartyHostLeaveDialog( + onConfirm = { + confirmHostLeave = false + roomSession.depart() + }, + onDismiss = { confirmHostLeave = false }, + ) + } +} + +@Composable +private fun HubHeadline(modifier: Modifier = Modifier) { + Column(modifier, verticalArrangement = Arrangement.spacedBy((WatchPartyMetrics.BODY * 0.6f).dp)) { + WatchPartyEyebrow("Watch Party") + WatchPartyHeroTitle("Watch something\ntogether") + Text( + "Pick a title, share the code, and everyone's playback stays in step.", + fontSize = WatchPartyMetrics.BODY.sp, + color = PrairieSecondaryText, + ) + } +} + +@Composable +private fun DismissButton(onClick: () -> Unit) { + IconButton(onClick = onClick) { + Icon(Icons.Filled.Close, contentDescription = "Dismiss", tint = PrairieSecondaryText) + } +} + +/** "Party code or invite link": monospaced, in the lobby's chrome. */ +@Composable +private fun WatchPartyCodeField( + value: String, + onValueChange: (String) -> Unit, + enabled: Boolean, + onGo: () -> Unit, + modifier: Modifier = Modifier, +) { + val shape = RoundedCornerShape(14.dp) + val style = TextStyle( + fontSize = 17.sp, + fontWeight = FontWeight.Medium, + fontFamily = FontFamily.Monospace, + color = PrairieOnSurface, + ) + BasicTextField( + value = value, + onValueChange = onValueChange, + enabled = enabled, + singleLine = true, + textStyle = style, + cursorBrush = SolidColor(PrairieOnSurface), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Characters, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Go, + ), + keyboardActions = KeyboardActions(onGo = { onGo() }), + modifier = modifier + .height(52.dp) + .clip(shape) + .background(WatchPartyColors.chromeFill) + .border(1.dp, WatchPartyColors.chromeBorder, shape) + .semantics { contentDescription = "Party code or invite link" }, + decorationBox = { inner -> + Box(Modifier.fillMaxSize().padding(horizontal = 14.dp), contentAlignment = Alignment.CenterStart) { + if (value.isEmpty()) { + Text("Party code or invite link", style = style.copy(color = PrairieSecondaryText.copy(alpha = 0.5f)), maxLines = 1) + } + inner() + } + }, + ) +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartyInviteSheet.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartyInviteSheet.kt new file mode 100644 index 0000000000..b59b8d0222 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartyInviteSheet.kt @@ -0,0 +1,210 @@ +package org.prairieserver.prairie.android.ui.screens.watchparty + +import android.content.Intent +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.IosShare +import androidx.compose.material.icons.outlined.Link +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +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 +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.common.BitMatrix +import com.google.zxing.qrcode.QRCodeWriter +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel +import org.prairieserver.prairie.android.ui.theme.PrairieOnSurface +import org.prairieserver.prairie.android.ui.theme.PrairieSecondaryText + +/** + * The invitation, after Apple's `WatchPartyInviteView` phone layout: the + * code, a QR of the invitation link, Share and Copy. A guest has no link + * (the server gives the invite path to the host only), so a guest shares or + * copies the code alone. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun WatchPartyInviteSheet( + code: String, + inviteUrl: String?, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val clipboard = LocalClipboardManager.current + var copied by remember { mutableStateOf(null) } + + fun share(text: String) { + val send = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_SUBJECT, "Join my Prairie Watch Party") + putExtra(Intent.EXTRA_TEXT, text) + } + context.startActivity(Intent.createChooser(send, "Share Watch Party invitation")) + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = Color.Black, + contentColor = PrairieOnSurface, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy((WatchPartyMetrics.BODY * 1.4f).dp), + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 32.dp) + .padding(bottom = 24.dp) + .navigationBarsPadding(), + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { + WatchPartyEyebrow("Invite friends") + Text( + "Join with this code", + fontSize = (WatchPartyMetrics.HERO_TITLE * 0.8f).sp, + fontWeight = FontWeight.Bold, + color = PrairieOnSurface, + textAlign = TextAlign.Center, + ) + Text( + "Anyone with a profile on this Prairie server can enter it from Watch Party.", + fontSize = WatchPartyMetrics.BODY.sp, + color = PrairieSecondaryText, + textAlign = TextAlign.Center, + ) + } + Text( + code, + fontSize = 40.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + letterSpacing = 8.sp, + color = PrairieOnSurface, + maxLines = 1, + modifier = Modifier + .padding(start = 8.dp) + .semantics { contentDescription = "Party code ${code.toList().joinToString(", ")}" }, + ) + if (inviteUrl != null) { + WatchPartyQrCode( + content = inviteUrl, + size = 200.dp, + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(Color.White) + .padding(12.dp) + .semantics { contentDescription = "Scan to join this party" }, + ) + Text( + "Or scan to open the invitation on a phone.", + fontSize = WatchPartyMetrics.CAPTION.sp, + color = PrairieSecondaryText, + textAlign = TextAlign.Center, + ) + } + Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(top = 8.dp)) { + WatchPartyButton( + text = if (inviteUrl != null) "Share invitation" else "Share code", + icon = Icons.Outlined.IosShare, + onClick = { share(watchPartyShareText(code, inviteUrl)) }, + modifier = Modifier.fillMaxWidth(), + ) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + if (inviteUrl != null) { + WatchPartyButton( + text = if (copied == "link") "Copied" else "Copy link", + icon = if (copied == "link") Icons.Filled.Check else Icons.Outlined.Link, + kind = WatchPartyButtonKind.Secondary, + onClick = { + clipboard.setText(AnnotatedString(inviteUrl)) + copied = "link" + }, + modifier = Modifier.weight(1f), + ) + } + WatchPartyButton( + text = if (copied == "code") "Copied" else "Copy code", + icon = if (copied == "code") Icons.Filled.Check else Icons.Outlined.ContentCopy, + kind = WatchPartyButtonKind.Secondary, + onClick = { + clipboard.setText(AnnotatedString(code)) + copied = "code" + }, + modifier = Modifier.weight(1f), + ) + } + } + WatchPartyButton( + text = "Done", + kind = WatchPartyButtonKind.Secondary, + onClick = onDismiss, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + } + } +} + +/** [content] as a QR code, one square per module, at error correction M. */ +@Composable +internal fun WatchPartyQrCode(content: String, size: Dp, modifier: Modifier = Modifier) { + val matrix = remember(content) { watchPartyQrMatrix(content) } + Canvas(modifier.size(size)) { + val count = matrix.width + val module = this.size.minDimension / count + for (y in 0 until count) { + for (x in 0 until count) { + if (matrix.get(x, y)) { + // A hair of overlap keeps sub-pixel seams out of dark runs. + drawRect(Color.Black, Offset(x * module, y * module), Size(module + 0.5f, module + 0.5f)) + } + } + } + } +} + +private fun watchPartyQrMatrix(content: String): BitMatrix = + // Width and height 0 return the bare module grid; the white card is the quiet zone. + QRCodeWriter().encode( + content, + BarcodeFormat.QR_CODE, + 0, + 0, + mapOf(EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M, EncodeHintType.MARGIN to 0), + ) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartyLobbyScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartyLobbyScreen.kt new file mode 100644 index 0000000000..d623ef7b8b --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartyLobbyScreen.kt @@ -0,0 +1,262 @@ +package org.prairieserver.prairie.android.ui.screens.watchparty + +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf +import org.prairieserver.prairie.android.ui.theme.PrairieOnSurface +import org.prairieserver.prairie.android.ui.theme.PrairieSecondaryText +import org.prairieserver.prairie.model.watchtogether.GuestControlPolicy +import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.Suggestion +import org.prairieserver.prairie.network.ServerRegistry +import org.prairieserver.prairie.repository.WatchTogetherRepository +import org.prairieserver.prairie.viewmodel.WatchPartyConnectionStatus +import org.prairieserver.prairie.viewmodel.WatchPartyLobbyViewModel +import org.prairieserver.prairie.watchtogether.WatchPartyDestination +import org.prairieserver.prairie.watchtogether.watchPartyInviteUrl + +/** + * The Watch Party lobby, after Apple's phone lobby: the staged film's artwork + * fills the page under the code pill, the hero, the ballot or suggestions, + * the seats, and one pinned action. The room's phase alone opens the player. + */ +@Composable +fun WatchPartyLobbyScreen( + roomId: String, + onBack: () -> Unit, + onOpenPlayer: (RoomSnapshot) -> Unit, + /** The party ended; the hub explains why and offers Rejoin when it can. */ + onPartyEnded: () -> Unit, + /** This device left (Leave, End, or elsewhere). */ + onLeft: () -> Unit, + onOpenDetail: (contentId: String) -> Unit, + viewModel: WatchPartyLobbyViewModel = koinViewModel(parameters = { parametersOf(roomId) }), +) { + val state by viewModel.state.collectAsState() + val destination by viewModel.destination.collectAsState() + val repository: WatchTogetherRepository = koinInject() + val handoff: WatchPartyHandoff = koinInject() + val serverRegistry: ServerRegistry = koinInject() + val activeServer by serverRegistry.activeEntry.collectAsState() + val context = LocalContext.current + + val room = state.room?.takeIf { it.roomId == roomId } + val eligibility = state.eligibility + val isHost = room?.selfRole == MemberRole.Host + var leaving by remember { mutableStateOf(false) } + var seenRoom by remember { mutableStateOf(false) } + var pickerMode by rememberSaveable { mutableStateOf(null) } + var inviteOpen by rememberSaveable { mutableStateOf(false) } + var confirmEnd by rememberSaveable { mutableStateOf(false) } + var confirmHostLeave by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(viewModel) { + viewModel.messages.collect { Toast.makeText(context, it, Toast.LENGTH_SHORT).show() } + } + + // Only the room's phase opens the player. + LaunchedEffect(destination) { + if (destination !is WatchPartyDestination.Player || leaving) return@LaunchedEffect + val playing = repository.roomSnapshot.value?.takeIf { it.roomId == roomId } ?: return@LaunchedEffect + onOpenPlayer(playing) + } + + LaunchedEffect(room != null) { + if (room != null) seenRoom = true + } + LaunchedEffect(state.ended) { + val ended = state.ended ?: return@LaunchedEffect + if (ended.roomId == roomId && !leaving) { + leaving = true + onPartyEnded() + } + } + LaunchedEffect(room == null, seenRoom, state.ended) { + if (room == null && seenRoom && state.ended == null && !leaving) { + leaving = true + onLeft() + } + } + + fun leaveNow() { + leaving = true + viewModel.leave() + onLeft() + } + + fun endNow() { + leaving = true + viewModel.endForEveryone() + onLeft() + } + + val connection = state.connection + val reconnectSince = (connection as? WatchPartyConnectionStatus.Reconnecting)?.sinceMs + val showReconnect = rememberWatchPartyReconnectNotice( + disconnected = room != null && reconnectSince != null, + since = reconnectSince, + ) + val staged = rememberWatchPartyStagedTitle(room, state.suggestions) + + if (room == null) { + Box(Modifier.fillMaxSize()) { + WatchPartyBackdrop(url = null) + Column(Modifier.fillMaxSize()) { + WatchPartyTopBar(onBack = onBack) + } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.align(Alignment.Center), + ) { + CircularProgressIndicator(color = PrairieSecondaryText) + Text("Connecting to the party…", color = PrairieSecondaryText) + } + } + return + } + + val actions = object : WatchPartyRoomActions { + override fun invite() { + inviteOpen = true + } + + override fun chooseTitle() { + pickerMode = WatchPartyPickMode.Stage + } + + override fun suggest() { + pickerMode = WatchPartyPickMode.Suggest + } + + override fun start() = viewModel.start().let { } + + override fun setLobbyReady(ready: Boolean) = viewModel.setLobbyReady(ready) + + override fun returnToPlayback() { + repository.roomSnapshot.value?.takeIf { it.roomId == roomId }?.let(onOpenPlayer) + } + + override fun setMode(mode: RoomSelectionMode) { + if (room.selectionMode != mode) viewModel.setMode(mode) + } + + override fun setPolicy(policy: GuestControlPolicy) { + if (room.guestControlPolicy != policy) viewModel.setGuestPolicy(policy) + } + + override fun leave() { + if (isHost) confirmHostLeave = true else leaveNow() + } + + override fun end() { + confirmEnd = true + } + + override fun vote(suggestion: Suggestion) = viewModel.vote(suggestion).let { } + + override fun promote(suggestion: Suggestion) = viewModel.promote(suggestion).let { } + + override fun queue(suggestion: Suggestion) { + handoff.rememberPreview(suggestion.toPartyItem()) + viewModel.queue(suggestion) + } + + override fun remove(suggestion: Suggestion) = viewModel.remove(suggestion) + } + + WatchPartyRoomContent( + room = room, + staged = staged, + suggestions = state.suggestions, + personalVotesKnown = state.personalVotesKnown, + features = state.features, + eligibility = eligibility, + connected = connection is WatchPartyConnectionStatus.Connected, + showReconnect = showReconnect, + firstConnect = reconnectSince == 0L, + actions = actions, + onBack = onBack, + extraBanner = state.suggestionRetry?.let { draft -> + { + WatchPartyBanner( + text = "Couldn't confirm your suggestion of ${draft.title}.", + tone = WatchPartyBannerTone.Warning, + action = { + TextButton(onClick = viewModel::retrySuggestion) { Text("Try again", color = PrairieOnSurface) } + IconButton(onClick = viewModel::dismissSuggestionRetry) { + Icon(Icons.Filled.Close, contentDescription = "Dismiss", tint = PrairieSecondaryText) + } + }, + ) + } + }, + ) + + if (inviteOpen) { + WatchPartyInviteSheet( + code = room.code, + inviteUrl = if (isHost) activeServer?.url?.let { watchPartyInviteUrl(it, room.invitePath) } else null, + onDismiss = { inviteOpen = false }, + ) + } + + pickerMode?.let { mode -> + WatchPartyPicker( + mode = mode, + roomMembers = room.members, + memberState = viewModel::memberState, + onPick = { item -> + handoff.rememberPreview(item) + if (mode == WatchPartyPickMode.Stage) viewModel.stage(item) else viewModel.suggest(item) + }, + onOpenSeries = onOpenDetail, + onDismiss = { pickerMode = null }, + ) + } + + if (confirmEnd) { + WatchPartyEndDialog( + onConfirm = { + confirmEnd = false + endNow() + }, + onDismiss = { confirmEnd = false }, + ) + } + if (confirmHostLeave) { + WatchPartyHostLeaveDialog( + onConfirm = { + confirmHostLeave = false + leaveNow() + }, + onDismiss = { confirmHostLeave = false }, + ) + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartyPanelSheet.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartyPanelSheet.kt new file mode 100644 index 0000000000..b812143ca3 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartyPanelSheet.kt @@ -0,0 +1,141 @@ +package org.prairieserver.prairie.android.ui.screens.watchparty + +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import org.prairieserver.prairie.model.watchtogether.GuestControlPolicy +import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.repository.WatchTogetherRepository +import org.prairieserver.prairie.watchtogether.WatchPartyAvailability +import org.prairieserver.prairie.watchtogether.WatchPartyAvailabilityRepository +import org.prairieserver.prairie.watchtogether.WatchPartyEligibility +import org.prairieserver.prairie.watchtogether.watchPartyErrorMessage + +/** + * The party panel over a retained party player (Back opens it). It is the + * room itself, as Apple's `WatchPartyRoomPanel` reopens the lobby over the + * player: "Now watching · together", the seats with playback states, the + * code pill and "···" options, and Return to playback as the primary action. + * Closing it only closes the panel; the player keeps playing underneath. + */ +@Composable +internal fun WatchPartyPanelSheet( + room: RoomSnapshot?, + eligibility: WatchPartyEligibility, + inviteUrl: String?, + onReturnToLobby: () -> Unit, + onEndForEveryone: () -> Unit, + onLeave: () -> Unit, + onDismiss: () -> Unit, +) { + val repository: WatchTogetherRepository = koinInject() + val availabilityRepository: WatchPartyAvailabilityRepository = koinInject() + val availability by availabilityRepository.availability.collectAsState() + val suggestions by repository.suggestions.collectAsState() + val personalVotesKnown by repository.personalVotesKnown.collectAsState() + val connection by repository.connectionState.collectAsState() + val context = LocalContext.current + val scope = rememberCoroutineScope() + val isHost = room?.selfRole == MemberRole.Host + var inviteOpen by rememberSaveable { mutableStateOf(false) } + var confirmEnd by rememberSaveable { mutableStateOf(false) } + var confirmHostLeave by rememberSaveable { mutableStateOf(false) } + + if (room == null) { + LaunchedEffect(Unit) { onDismiss() } + return + } + + val staged = rememberWatchPartyStagedTitle(room, suggestions) + val reconnectSince = connection.disconnectedAtMs.takeIf { !connection.writable } + val showReconnect = rememberWatchPartyReconnectNotice( + disconnected = !connection.writable, + since = reconnectSince, + ) + + val actions = object : WatchPartyRoomActions { + override fun invite() { + inviteOpen = true + } + + override fun returnToPlayback() = onDismiss() + + override fun setPolicy(policy: GuestControlPolicy) { + if (room.guestControlPolicy == policy) return + scope.launch { + val result = repository.updatePolicy(policy) + if (result !is ApiResult.Success) { + Toast.makeText( + context, + watchPartyErrorMessage(result, "Couldn't change who can play and pause."), + Toast.LENGTH_SHORT, + ).show() + } + } + } + + override fun returnEveryoneToLobby() = onReturnToLobby() + + override fun leave() { + if (isHost) confirmHostLeave = true else onLeave() + } + + override fun end() { + confirmEnd = true + } + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false), + ) { + WatchPartyRoomContent( + room = room, + staged = staged, + suggestions = suggestions, + personalVotesKnown = personalVotesKnown, + features = (availability as? WatchPartyAvailability.Available)?.features, + eligibility = eligibility, + connected = connection.writable, + showReconnect = showReconnect, + firstConnect = reconnectSince == null || reconnectSince == 0L, + actions = actions, + onBack = null, + ) + } + + if (inviteOpen) { + WatchPartyInviteSheet(code = room.code, inviteUrl = inviteUrl, onDismiss = { inviteOpen = false }) + } + if (confirmEnd) { + WatchPartyEndDialog( + onConfirm = { + confirmEnd = false + onEndForEveryone() + }, + onDismiss = { confirmEnd = false }, + ) + } + if (confirmHostLeave) { + WatchPartyHostLeaveDialog( + onConfirm = { + confirmHostLeave = false + onLeave() + }, + onDismiss = { confirmHostLeave = false }, + ) + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartyPickerSheet.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartyPickerSheet.kt new file mode 100644 index 0000000000..786ba8f489 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartyPickerSheet.kt @@ -0,0 +1,746 @@ +package org.prairieserver.prairie.android.ui.screens.watchparty + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.PlayCircle +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.android.ui.components.BackdropCard +import org.prairieserver.prairie.android.ui.components.MediaCard +import org.prairieserver.prairie.android.ui.navigation.LocalHeroSourceHandoff +import org.prairieserver.prairie.android.ui.navigation.LocalSharedTransitionScope +import org.prairieserver.prairie.android.ui.theme.PrairieOnSurface +import org.prairieserver.prairie.common.cards.LocalEpisodeSpoilerPrefs +import org.prairieserver.prairie.android.ui.theme.PrairiePageBackground +import org.prairieserver.prairie.android.ui.theme.PrairieSecondaryText +import org.prairieserver.prairie.model.catalog.BrowseItem +import org.prairieserver.prairie.model.section.SectionItem +import org.prairieserver.prairie.model.watchtogether.ItemMemberState +import org.prairieserver.prairie.model.watchtogether.MemberWatchState +import org.prairieserver.prairie.model.watchtogether.PickerEntry +import org.prairieserver.prairie.model.watchtogether.PickerNextUp +import org.prairieserver.prairie.model.watchtogether.RoomMember +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.repository.CatalogRepository +import org.prairieserver.prairie.repository.SectionRepository +import org.prairieserver.prairie.viewmodel.WatchPartyItem +import org.prairieserver.prairie.viewmodel.WatchPartySpoilers +import org.prairieserver.prairie.viewmodel.WatchPartyPickerViewModel + +/** What choosing a title in the picker does. */ +internal enum class WatchPartyPickMode(val title: String, val eyebrow: String, val confirmLabel: String) { + /** Host Picks host: stage it as the next title. */ + Stage("Choose a title", "Watch together", "Choose for the party"), + + /** Everyone else, and vote rooms: suggest it. */ + Suggest("Suggest a title", "Suggest to the party", "Add suggestion"), +} + +/** + * The room picker, after Apple's `WatchPartyMediaPicker`: full screen, search + * on top, then Home-style shelves (the group's rows, this profile's resume + * row, Home's discovery rows, and the newest movies and series). Choosing a + * title opens a confirmation page with who in the room has seen it. A series + * with a group next-up episode offers that episode; any other series opens + * its detail page, where the party action applies. Picking never starts + * playback. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +internal fun WatchPartyPicker( + mode: WatchPartyPickMode, + roomMembers: List, + memberState: suspend (String) -> ItemMemberState?, + onPick: (WatchPartyItem) -> Unit, + onOpenSeries: (String) -> Unit, + onDismiss: () -> Unit, + viewModel: WatchPartyPickerViewModel = koinViewModel(), +) { + val state by viewModel.state.collectAsState() + val shelves by rememberPickerHomeShelves() + var choice by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { viewModel.loadRows() } + + val entries = remember(state.rows) { + state.rows?.let { it.continueTogether + it.watchlistUnion }.orEmpty().associateBy { it.item.contentId } + } + + fun openSeriesPage(seriesId: String) { + onDismiss() + onOpenSeries(seriesId) + } + + fun open(item: SectionItem) { + when (item.type) { + "episode" -> choice = WatchPartyChoice.episode(item) + "series" -> { + val nextUp = entries[item.contentId]?.nextUp + if (nextUp != null) choice = WatchPartyChoice.nextUp(item, nextUp) else openSeriesPage(item.contentId) + } + else -> choice = WatchPartyChoice.title(item) + } + } + + Dialog( + // Back on the confirmation page returns to the shelves. + onDismissRequest = { if (choice != null) choice = null else onDismiss() }, + properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false), + ) { + // Cards here have no detail hero to morph into. + CompositionLocalProvider(LocalSharedTransitionScope provides null, LocalHeroSourceHandoff provides null) { + val current = choice + if (current == null) { + PickerShelvesPage( + mode = mode, + state = state, + shelves = shelves, + roomMembers = roomMembers, + onQuery = viewModel::onQuery, + onOpen = ::open, + onClose = onDismiss, + ) + } else { + PickerChoicePage( + mode = mode, + choice = current, + roomMembers = roomMembers, + memberState = memberState, + onConfirm = { + onPick(current.lobbyItem) + onDismiss() + }, + onSeeAllEpisodes = current.seriesId?.let { id -> { openSeriesPage(id) } }, + onBack = { choice = null }, + ) + } + } + } +} + +// MARK: Data + +/** A title on the confirmation page, and how the lobby should show it once chosen. */ +private data class WatchPartyChoice( + val contentId: String, + val title: String, + val subtitle: String?, + val posterUrl: String?, + val posterThumbhash: String?, + val backdropUrl: String?, + val backdropThumbhash: String?, + val overview: String?, + val facts: List, + /** The series to browse from here, for an episode. */ + val seriesId: String?, + val lobbyItem: WatchPartyItem, + val spoilers: WatchPartySpoilers = lobbyItem.spoilers, +) { + companion object { + fun title(item: SectionItem) = WatchPartyChoice( + contentId = item.contentId, + title = item.title, + subtitle = null, + posterUrl = item.posterUrl, + posterThumbhash = item.posterThumbhash, + backdropUrl = item.backdropUrl, + backdropThumbhash = item.backdropThumbhash, + overview = item.overview, + facts = listOfNotNull( + item.year.takeIf { it > 0 }?.toString(), + watchPartyRuntime(item.runtime), + item.contentRating?.takeIf { it.isNotBlank() }, + item.genres.firstOrNull(), + ), + seriesId = null, + lobbyItem = WatchPartyItem( + contentId = item.contentId, + contentType = item.type, + title = item.title, + subtitle = item.year.takeIf { it > 0 }?.toString(), + posterUrl = item.posterUrl, + spoilers = WatchPartySpoilers.of(item), + ), + ) + + /** A resume-row episode: series title on top, the episode underneath. */ + fun episode(item: SectionItem): WatchPartyChoice { + val series = item.seriesTitle?.takeIf { it.isNotBlank() } + val season = item.seasonNumber + val number = item.episodeNumber + val code = if (season != null && number != null) "S$season · E$number" else null + return WatchPartyChoice( + contentId = item.contentId, + title = series ?: item.title, + subtitle = listOfNotNull(code, item.title.takeIf { series != null }).joinToString(" · ").ifBlank { null }, + posterUrl = item.posterUrl, + posterThumbhash = item.posterThumbhash, + backdropUrl = item.backdropUrl, + backdropThumbhash = item.backdropThumbhash, + overview = item.overview, + facts = listOfNotNull(watchPartyRuntime(item.runtime)), + seriesId = item.seriesId?.takeIf { it.isNotBlank() }, + lobbyItem = WatchPartyItem( + contentId = item.contentId, + contentType = "episode", + title = item.title, + subtitle = if (series != null && season != null && number != null) { + "$series · S$season:E$number" + } else { + series + }, + posterUrl = item.posterUrl, + spoilers = WatchPartySpoilers.of(item), + ), + ) + } + + /** A series whose group next-up episode the server named. */ + fun nextUp(series: SectionItem, next: PickerNextUp): WatchPartyChoice { + val episodeTitle = next.title.ifBlank { "Episode ${next.episodeNumber}" } + return WatchPartyChoice( + contentId = next.contentId, + title = series.title, + subtitle = "S${next.seasonNumber} · E${next.episodeNumber}" + + next.title.takeIf { it.isNotBlank() }?.let { " · $it" }.orEmpty(), + posterUrl = series.posterUrl, + posterThumbhash = series.posterThumbhash, + backdropUrl = series.backdropUrl, + backdropThumbhash = series.backdropThumbhash, + overview = null, + facts = emptyList(), + seriesId = series.contentId, + lobbyItem = WatchPartyItem( + contentId = next.contentId, + contentType = "episode", + title = episodeTitle, + subtitle = "${series.title} · S${next.seasonNumber}:E${next.episodeNumber}", + posterUrl = series.posterUrl, + // Series artwork; the episode's own watch state isn't known here. + spoilers = WatchPartySpoilers(unwatchedEpisode = true, posterIsEpisodeStill = false, backdropIsEpisodeStill = false), + ), + ) + } + } +} + +private fun BrowseItem.toSectionItem() = SectionItem( + contentId = contentId, + type = type, + title = title, + year = year, + genres = genres, + contentRating = contentRating, + runtime = runtime, + overview = overview, + posterUrl = posterUrl, + posterThumbhash = posterThumbhash, + backdropUrl = backdropUrl, + backdropThumbhash = backdropThumbhash, + posterIsEpisodeStill = posterIsEpisodeStill, + backdropIsEpisodeStill = backdropIsEpisodeStill, + positionSeconds = positionSeconds, + userState = userState, + overlaySummary = overlaySummary, +) + +private val playableTypes = setOf("movie", "series", "episode") + +/** Home's rows for the shelf, loaded once per open. */ +private data class PickerHomeShelves( + val loading: Boolean = true, + val resume: List = emptyList(), + val discovery: List>> = emptyList(), + val recentMovies: List = emptyList(), + val recentSeries: List = emptyList(), +) + +/** + * Home rows the shelf skips, as on Apple and the web picker: personal rows are + * covered by the group rows or would leak one member's history, and + * per-library recently-added rows are replaced by the shelf's own. + */ +private val skippedHomeSectionTypes = setOf( + "recently_added", "recently_released", "new_to_library", "continue_watching", "in_progress", "next_up", + "next_in_series", "watchlist", "favorites", "profile_activity_feed", "because_you_watched", + "recommended_for_you", "similar_users_liked", "taste_match", "forgotten_favorites", +) +private const val MAX_HOME_SECTIONS = 4 + +@Composable +private fun rememberPickerHomeShelves(): androidx.compose.runtime.State { + val sections: SectionRepository = koinInject() + val catalog: CatalogRepository = koinInject() + return produceState(PickerHomeShelves()) { + suspend fun recent(type: String): List = + when (val result = catalog.browse(mediaType = type, sort = "added_at", order = "desc", limit = 24)) { + is ApiResult.Success -> result.data.items.filter { it.type in playableTypes }.map { it.toSectionItem() } + else -> emptyList() + } + coroutineScope { + val home = async { + val owner = sections.captureHomeAuthority() ?: return@async null + (sections.getHomeSections(owner) as? ApiResult.Success)?.data?.sections + } + val movies = async { recent("movie") } + val series = async { recent("series") } + val homeSections = home.await().orEmpty() + value = PickerHomeShelves( + loading = false, + resume = homeSections + .firstOrNull { it.sectionType == "continue_watching" || it.sectionType == "in_progress" } + ?.items?.filter { it.type == "movie" || it.type == "episode" }.orEmpty(), + discovery = homeSections + .filter { it.sectionType.lowercase() !in skippedHomeSectionTypes } + .mapNotNull { section -> + val items = section.items.filter { it.type == "movie" || it.type == "series" } + if (items.isEmpty()) null else section.title to items + } + .take(MAX_HOME_SECTIONS), + recentMovies = movies.await(), + recentSeries = series.await(), + ) + } + } +} + +private data class PickerShelf( + val id: String, + val title: String, + val items: List, + val stills: Boolean = false, + val captions: Map = emptyMap(), + val progress: Map = emptyMap(), +) + +private fun memberNames(entry: PickerEntry): String? = + entry.members.map { it.displayName.ifBlank { "Someone" } }.takeIf { it.isNotEmpty() }?.let(::formatWatchPartyNames) + +// MARK: Shelves page + +@Composable +private fun PickerShelvesPage( + mode: WatchPartyPickMode, + state: WatchPartyPickerViewModel.PickerState, + shelves: PickerHomeShelves, + roomMembers: List, + onQuery: (String) -> Unit, + onOpen: (SectionItem) -> Unit, + onClose: () -> Unit, +) { + val rows = state.rows + val list = buildList { + rows?.continueTogether?.takeIf { it.isNotEmpty() }?.let { entries -> + add( + PickerShelf( + id = "together", + title = "Continue Together", + items = entries.map { it.item.toSectionItem() }, + captions = entries.mapNotNull { entry -> + val caption = entry.nextUp?.let { "Next: S${it.seasonNumber} · E${it.episodeNumber}" } ?: memberNames(entry) + caption?.let { entry.item.contentId to it } + }.toMap(), + progress = entries.mapNotNull { entry -> + val position = entry.members.mapNotNull { it.positionSeconds }.maxOrNull() + val duration = entry.members.mapNotNull { it.durationSeconds }.maxOrNull() + if (position != null && duration != null && duration > 0) { + entry.item.contentId to (position / duration).toFloat().coerceIn(0f, 1f) + } else { + null + } + }.toMap(), + ), + ) + } + if (shelves.resume.isNotEmpty()) add(PickerShelf("resume", "Continue Watching", shelves.resume, stills = true)) + rows?.watchlistUnion?.takeIf { it.isNotEmpty() }?.let { entries -> + add( + PickerShelf( + id = "watchlist", + title = "On Your Watchlists", + items = entries.map { it.item.toSectionItem() }, + captions = entries.mapNotNull { entry -> memberNames(entry)?.let { entry.item.contentId to it } }.toMap(), + ), + ) + } + shelves.discovery.forEachIndexed { index, (title, items) -> add(PickerShelf("home-$index", title, items)) } + if (shelves.recentMovies.isNotEmpty()) add(PickerShelf("movies", "Recently Added Movies", shelves.recentMovies)) + if (shelves.recentSeries.isNotEmpty()) add(PickerShelf("series", "Recently Added Series", shelves.recentSeries)) + } + val searching = state.query.isNotBlank() + + Column( + Modifier + .fillMaxSize() + .background(PrairiePageBackground) + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)), + ) { + Box(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 16.dp)) { + WatchPartyCircleButton( + icon = Icons.Filled.Close, + contentDescription = "Close", + onClick = onClose, + size = 44.dp, + modifier = Modifier.align(Alignment.CenterStart), + ) + } + Text( + mode.title, + fontSize = 30.sp, + fontWeight = FontWeight.Bold, + color = PrairieOnSurface, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + PickerSearchField( + query = state.query, + onQuery = onQuery, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + ) + state.error?.let { + WatchPartyBanner(it, tone = WatchPartyBannerTone.Warning, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) + } + when { + searching -> PickerSearchResults(state, onOpen) + list.isEmpty() && (shelves.loading || state.rowsLoading) -> PickerProgress() + list.isEmpty() -> Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().padding(top = 80.dp, start = 32.dp, end = 32.dp), + ) { + Text("Nothing to pick from yet", fontSize = 17.sp, fontWeight = FontWeight.SemiBold, color = PrairieOnSurface) + Text( + "Search for a title, or add something to a watchlist.", + fontSize = WatchPartyMetrics.BODY.sp, + color = PrairieSecondaryText, + ) + } + else -> LazyColumn( + verticalArrangement = Arrangement.spacedBy(24.dp), + contentPadding = PaddingValues(top = 8.dp, bottom = 32.dp), + modifier = Modifier.fillMaxSize().navigationBarsPadding(), + ) { + items(list, key = { it.id }) { shelf -> PickerShelfRow(shelf, onOpen) } + } + } + } +} + +@Composable +private fun PickerSearchField(query: String, onQuery: (String) -> Unit, modifier: Modifier = Modifier) { + val shape = RoundedCornerShape(12.dp) + val style = TextStyle(fontSize = 16.sp, color = PrairieOnSurface) + BasicTextField( + value = query, + onValueChange = onQuery, + singleLine = true, + textStyle = style, + cursorBrush = SolidColor(PrairieOnSurface), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + modifier = modifier + .fillMaxWidth() + .height(44.dp) + .clip(shape) + .background(WatchPartyColors.chromeFill) + .border(1.dp, WatchPartyColors.chromeBorder, shape), + decorationBox = { inner -> + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 12.dp, end = 4.dp)) { + Icon(Icons.Filled.Search, contentDescription = null, tint = PrairieSecondaryText, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + Box(Modifier.weight(1f)) { + if (query.isEmpty()) { + Text("Search movies and series", style = style.copy(color = PrairieSecondaryText), maxLines = 1) + } + inner() + } + if (query.isNotEmpty()) { + IconButton(onClick = { onQuery("") }, modifier = Modifier.size(36.dp)) { + Icon(Icons.Filled.Close, contentDescription = "Clear search", tint = PrairieSecondaryText, modifier = Modifier.size(18.dp)) + } + } + } + }, + ) +} + +@Composable +private fun PickerShelfRow(shelf: PickerShelf, onOpen: (SectionItem) -> Unit) { + val spoilerPrefs = LocalEpisodeSpoilerPrefs.current + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + // Home's row heading: 20sp, with the resume row's play glyph. + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(horizontal = 16.dp), + ) { + if (shelf.stills) { + Icon(Icons.Filled.PlayCircle, contentDescription = null, tint = PrairieOnSurface, modifier = Modifier.size(20.dp)) + } + Text(shelf.title, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, color = PrairieOnSurface) + } + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(shelf.items.distinctBy { it.contentId }, key = { it.contentId }) { item -> + if (shelf.stills) { + val position = item.positionSeconds + val duration = item.durationSeconds + BackdropCard( + title = item.title, + backdropUrl = item.backdropUrl ?: item.posterUrl, + backdropThumbhash = item.backdropThumbhash ?: item.posterThumbhash, + seriesTitle = item.seriesTitle, + seasonNumber = item.seasonNumber, + episodeNumber = item.episodeNumber, + progress = if (position != null && duration != null && duration > 0) { + (position / duration).toFloat().coerceIn(0f, 1f) + } else { + null + }, + userState = item.userState, + onClick = { onOpen(item) }, + hideArtwork = WatchPartySpoilers.of(item).hidesBackdrop(spoilerPrefs, item.backdropUrl), + ) + } else { + MediaCard( + title = item.title, + posterUrl = item.posterUrl, + posterThumbhash = item.posterThumbhash, + year = item.year, + subtitle = shelf.captions[item.contentId], + type = item.type, + userState = item.userState, + progress = shelf.progress[item.contentId], + onClick = { onOpen(item) }, + hideArtwork = WatchPartySpoilers.of(item).hidesPoster(spoilerPrefs), + ) + } + } + } + } +} + +@Composable +private fun PickerSearchResults(state: WatchPartyPickerViewModel.PickerState, onOpen: (SectionItem) -> Unit) { + val spoilerPrefs = LocalEpisodeSpoilerPrefs.current + when { + state.results.isNotEmpty() -> LazyVerticalGrid( + columns = GridCells.Fixed(3), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(18.dp), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 32.dp), + modifier = Modifier.fillMaxSize().navigationBarsPadding(), + ) { + items(state.results, key = { it.contentId }) { result -> + val item = result.toSectionItem() + MediaCard( + title = item.title, + posterUrl = item.posterUrl, + posterThumbhash = item.posterThumbhash, + year = item.year, + type = item.type, + userState = item.userState, + onClick = { onOpen(item) }, + hideArtwork = WatchPartySpoilers.of(item).hidesPoster(spoilerPrefs), + modifier = Modifier.fillMaxWidth(), + width = androidx.compose.ui.unit.Dp.Unspecified, + ) + } + } + state.searching || state.query.trim().length < 2 -> PickerProgress() + else -> Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().padding(top = 80.dp), + ) { + Text("No results", fontSize = 17.sp, fontWeight = FontWeight.SemiBold, color = PrairieOnSurface) + Text("Try a different search term.", fontSize = WatchPartyMetrics.BODY.sp, color = PrairieSecondaryText) + } + } +} + +@Composable +private fun PickerProgress() { + Box(Modifier.fillMaxWidth().padding(top = 120.dp), contentAlignment = Alignment.TopCenter) { + CircularProgressIndicator(color = PrairieSecondaryText, modifier = Modifier.size(28.dp), strokeWidth = 2.dp) + } +} + +// MARK: Confirmation page + +@Composable +private fun PickerChoicePage( + mode: WatchPartyPickMode, + choice: WatchPartyChoice, + roomMembers: List, + memberState: suspend (String) -> ItemMemberState?, + onConfirm: () -> Unit, + onSeeAllEpisodes: (() -> Unit)?, + onBack: () -> Unit, +) { + var loadingHistory by remember(choice.contentId) { mutableStateOf(true) } + var history by remember(choice.contentId) { mutableStateOf(null) } + LaunchedEffect(choice.contentId) { + history = memberState(choice.contentId) + loadingHistory = false + } + val spoilerPrefs = LocalEpisodeSpoilerPrefs.current + Box(Modifier.fillMaxSize()) { + WatchPartyBackdrop( + url = choice.backdropUrl ?: choice.posterUrl, + thumbhash = if (choice.backdropUrl != null) choice.backdropThumbhash else choice.posterThumbhash, + isPoster = choice.backdropUrl == null, + hidden = choice.spoilers.hidesBackdrop(spoilerPrefs, choice.backdropUrl), + ) + Column( + Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)), + ) { + Box(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 16.dp)) { + WatchPartyCircleButton( + icon = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + onClick = onBack, + size = 44.dp, + modifier = Modifier.align(Alignment.CenterStart), + ) + } + Column( + verticalArrangement = Arrangement.spacedBy((WatchPartyMetrics.BODY * 1.4f).dp), + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .navigationBarsPadding() + .padding(horizontal = WatchPartyMetrics.pageInset) + // Let the backdrop show above the poster, as in the lobby. + .padding(top = 150.dp, bottom = WatchPartyMetrics.pageInset), + ) { + Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(WatchPartyMetrics.BODY.dp)) { + WatchPartyHeroPoster(choice.posterUrl, choice.posterThumbhash, 120.dp, choice.spoilers.hidesPoster(spoilerPrefs)) + Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.weight(1f)) { + WatchPartyEyebrow(mode.eyebrow) + WatchPartyHeroTitle(choice.title, size = WatchPartyMetrics.HERO_TITLE * 0.9f) + val facts = listOfNotNull(choice.subtitle) + choice.facts + if (facts.isNotEmpty()) { + Text(facts.joinToString(" · "), fontSize = WatchPartyMetrics.BODY.sp, color = PrairieSecondaryText) + } + } + } + choice.overview?.takeIf { it.isNotBlank() && !choice.spoilers.hidesOverview(spoilerPrefs) }?.let { + Text(it, fontSize = WatchPartyMetrics.BODY.sp, color = PrairieSecondaryText, maxLines = 6) + } + WatchPartyButton( + text = mode.confirmLabel, + icon = if (mode == WatchPartyPickMode.Suggest) Icons.Filled.Add else Icons.Filled.Check, + onClick = onConfirm, + modifier = Modifier.fillMaxWidth(), + ) + if (onSeeAllEpisodes != null) { + WatchPartyButton( + text = "See all episodes", + icon = Icons.AutoMirrored.Filled.List, + kind = WatchPartyButtonKind.Secondary, + onClick = onSeeAllEpisodes, + modifier = Modifier.fillMaxWidth(), + ) + } + // Hidden when the server has no member-state read. + if (loadingHistory || history != null) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + WatchPartyEyebrow("Who's seen it") + if (loadingHistory) { + Text("Checking viewing history…", fontSize = WatchPartyMetrics.CAPTION.sp, color = PrairieSecondaryText) + } else { + val states = history?.members.orEmpty() + roomMembers.forEach { member -> + val watch = states.firstOrNull { it.userId == member.userId && it.profileId == member.profileId } + Row { + Text( + watchPartyMemberName(member), + fontSize = WatchPartyMetrics.BODY.sp, + color = PrairieOnSurface, + modifier = Modifier.weight(1f), + ) + Text(watchHistoryLabel(watch), fontSize = WatchPartyMetrics.BODY.sp, color = PrairieSecondaryText) + } + } + } + } + } + } + } + } +} + +private fun watchHistoryLabel(state: MemberWatchState?): String { + state ?: return "No viewing history" + val label = when (state.state) { + "watched", "completed", "played" -> "Watched" + "in_progress", "inprogress" -> "In progress" + else -> "Not watched" + } + return if (state.onWatchlist) "$label · Watchlist" else label +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartyRoomContent.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartyRoomContent.kt new file mode 100644 index 0000000000..3b2c4a0631 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartyRoomContent.kt @@ -0,0 +1,1001 @@ +package org.prairieserver.prairie.android.ui.screens.watchparty + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Logout +import androidx.compose.material.icons.automirrored.filled.Undo +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.MoreHoriz +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.HighlightOff +import androidx.compose.material.icons.outlined.PanTool +import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material.icons.outlined.SmartDisplay +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.koin.compose.koinInject +import org.prairieserver.prairie.android.ui.components.PrairieDropdownMenu +import org.prairieserver.prairie.android.ui.theme.PrairieDestructive +import org.prairieserver.prairie.android.ui.theme.PrairieOnSurface +import org.prairieserver.prairie.android.ui.theme.PrairieSecondaryText +import org.prairieserver.prairie.model.catalog.ItemDetail +import org.prairieserver.prairie.model.watchtogether.GuestControlPolicy +import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.Suggestion +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.repository.CatalogRepository +import org.prairieserver.prairie.common.cards.LocalEpisodeSpoilerPrefs +import org.prairieserver.prairie.viewmodel.WatchPartyItem +import org.prairieserver.prairie.viewmodel.WatchPartySpoilers +import org.prairieserver.prairie.watchtogether.WatchPartyConnectionTone +import org.prairieserver.prairie.watchtogether.WatchPartyEligibility +import org.prairieserver.prairie.watchtogether.WatchPartyFeatures +import org.prairieserver.prairie.watchtogether.WatchPartyPrimaryAction +import org.prairieserver.prairie.watchtogether.WatchPartySecondaryAction +import org.prairieserver.prairie.watchtogether.canRemoveSuggestion +import org.prairieserver.prairie.watchtogether.roomVoteWinner +import org.prairieserver.prairie.watchtogether.watchPartyConnectionLabel +import org.prairieserver.prairie.watchtogether.watchPartyLobbyBanner +import org.prairieserver.prairie.watchtogether.watchPartyLobbyHero +import org.prairieserver.prairie.watchtogether.watchPartyLobbyHint +import org.prairieserver.prairie.watchtogether.watchPartyPrimaryAction +import org.prairieserver.prairie.watchtogether.watchPartySecondaryAction + +/* + * The room itself, shared by the lobby screen and the party panel over the + * player (Apple's `WatchPartyLobbyView`, which `WatchPartyRoomPanel` reuses). + * The staged film's artwork fills the page; the code pill, connection dot and + * options sit on top, the hero and seats in the middle, and the one + * foregrounded action is pinned to the bottom. + */ + +/** Everything the room can ask its host screen to do. */ +internal interface WatchPartyRoomActions { + fun invite() + fun chooseTitle() {} + fun suggest() {} + fun start() {} + fun setLobbyReady(ready: Boolean) {} + fun returnToPlayback() + fun setMode(mode: RoomSelectionMode) {} + fun setPolicy(policy: GuestControlPolicy) + fun returnEveryoneToLobby() {} + fun leave() + fun end() + fun vote(suggestion: Suggestion) {} + fun promote(suggestion: Suggestion) {} + fun queue(suggestion: Suggestion) {} + fun remove(suggestion: Suggestion) {} +} + +/** The staged title as the room shows it: a caller's preview until the catalog read lands. */ +internal data class WatchPartyStagedTitle( + val title: String?, + val subtitle: String?, + val posterUrl: String?, + val posterThumbhash: String?, + val backdropUrl: String?, + val backdropThumbhash: String?, + val facts: List, + val chips: List, + val overview: String?, + /** The catalog refused this profile the title (403/404). */ + val unavailable: Boolean, + val spoilers: WatchPartySpoilers = WatchPartySpoilers.NONE, +) + +@Composable +internal fun rememberWatchPartyStagedTitle(room: RoomSnapshot?, suggestions: List): WatchPartyStagedTitle? { + val handoff: WatchPartyHandoff = koinInject() + val catalog: CatalogRepository = koinInject() + val stagedId = room?.selectedContentId?.takeIf { it.isNotBlank() } + val libraryId = room?.selectedLibraryId + var detail by remember(stagedId) { mutableStateOf(null) } + var unavailable by remember(stagedId) { mutableStateOf(false) } + // Re-read on each phase change: after the party plays the title, its watch + // state decides whether spoiler protection still hides it. + LaunchedEffect(stagedId, libraryId, room?.phase) { + val id = stagedId ?: return@LaunchedEffect + when (val result = catalog.getItemDetail(id, libraryId)) { + is ApiResult.Success -> detail = result.data + is ApiResult.Error -> unavailable = result.code == 403 || result.code == 404 + is ApiResult.NetworkError -> Unit + } + } + stagedId ?: return null + val preview = handoff.preview(stagedId) ?: suggestions.firstOrNull { it.contentId == stagedId }?.toPartyItem() + val loaded = detail + if (loaded == null) { + return WatchPartyStagedTitle( + title = preview?.title, + subtitle = preview?.subtitle, + posterUrl = preview?.posterUrl, + posterThumbhash = null, + backdropUrl = null, + backdropThumbhash = null, + facts = emptyList(), + chips = emptyList(), + overview = null, + unavailable = unavailable, + spoilers = preview?.spoilers ?: WatchPartySpoilers.NONE, + ) + } + val episode = loaded.type == "episode" + val spoilers = WatchPartySpoilers.of(loaded) + val versions = loaded.versions + val version = versions.firstOrNull { it.fileId == room.selectedFileId } ?: versions.singleOrNull() + return WatchPartyStagedTitle( + title = loaded.title, + subtitle = if (episode) loaded.episodeSubtitle() else null, + posterUrl = loaded.posterUrl ?: preview?.posterUrl, + posterThumbhash = loaded.posterThumbhash, + backdropUrl = loaded.backdropUrl, + backdropThumbhash = loaded.backdropThumbhash, + facts = listOfNotNull( + loaded.year.takeIf { it > 0 && !episode }?.toString(), + watchPartyRuntime(loaded.runtime), + ), + chips = watchPartyQualityChips(version), + overview = loaded.overview?.takeIf { it.isNotBlank() && !spoilers.hidesOverview(LocalEpisodeSpoilerPrefs.current) }, + unavailable = false, + spoilers = spoilers, + ) +} + +private fun ItemDetail.episodeSubtitle(): String? { + val series = seriesTitle?.takeIf { it.isNotBlank() } ?: return null + val season = seasonNumber + val episode = episodeNumber + return if (season != null && episode != null) "$series · S$season:E$episode" else series +} + +internal fun Suggestion.toPartyItem() = WatchPartyItem( + contentId = contentId, + contentType = contentType, + title = title, + subtitle = subtitle.ifBlank { null }, + posterUrl = posterUrl.ifBlank { null }, +) + +@Composable +internal fun WatchPartyRoomContent( + room: RoomSnapshot, + staged: WatchPartyStagedTitle?, + suggestions: List, + personalVotesKnown: Boolean, + features: WatchPartyFeatures?, + eligibility: WatchPartyEligibility, + /** The room socket is writable. */ + connected: Boolean, + /** A disconnection has lasted long enough to mention (two seconds). */ + showReconnect: Boolean, + /** Still connecting for the first time rather than reconnecting. */ + firstConnect: Boolean, + actions: WatchPartyRoomActions, + onBack: (() -> Unit)?, + modifier: Modifier = Modifier, + extraBanner: (@Composable () -> Unit)? = null, +) { + val lobby = room.phase == RoomPhase.Lobby + val voteLobby = lobby && room.selectionMode == RoomSelectionMode.Vote + val unavailable = staged?.unavailable == true + val winner = roomVoteWinner(suggestions) + // The film is the room: its backdrop, else its poster, else a voting + // room's leader, blurred. + val leader = if (room.selectionMode == RoomSelectionMode.Vote) { + (winner ?: suggestions.firstOrNull())?.takeIf { it.posterUrl.isNotBlank() } + } else { + null + } + val backdrop = staged?.backdropUrl ?: staged?.posterUrl ?: leader?.posterUrl + val backdropIsPoster = staged?.backdropUrl == null + val spoilerPrefs = LocalEpisodeSpoilerPrefs.current + val backdropHidden = if (staged != null && (staged.backdropUrl != null || staged.posterUrl != null)) { + staged.spoilers.hidesBackdrop(spoilerPrefs, staged.backdropUrl) + } else { + leader?.let { WatchPartySpoilers.of(it).hidesPoster(spoilerPrefs) } ?: false + } + + val primary = watchPartyPrimaryAction(room, features, suggestions, unavailable) + val secondary = watchPartySecondaryAction(room, primary) + val hint = watchPartyLobbyHint(room, suggestions) + val banner = watchPartyLobbyBanner(room, unavailable, reconnecting = showReconnect)?.let { + if (showReconnect && firstConnect && it == "Reconnecting to the party…") "Connecting to the party…" else it + } + + val header: @Composable () -> Unit = { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + WatchPartyCodePill(code = room.code, onClick = actions::invite) + Spacer(Modifier.width(10.dp)) + ConnectionDot(room, connected, reconnecting = !connected && !firstConnect) + Spacer(Modifier.weight(1f)) + WatchPartyOptionsMenu(room, features, eligibility, actions) + } + } + val middle: @Composable ColumnScope.(compact: Boolean) -> Unit = { compact -> + RoomHero(room, staged, suggestions, compact) + if (banner != null) { + val warning = banner != "Reconnecting to the party…" && banner != "Connecting to the party…" + WatchPartyBanner(banner, tone = if (warning) WatchPartyBannerTone.Warning else WatchPartyBannerTone.Neutral) + } + extraBanner?.invoke() + if (voteLobby) { + WatchPartyBallot(room, suggestions, personalVotesKnown, eligibility, connected, actions) + } else if (lobby && room.selectionMode == RoomSelectionMode.HostPick && suggestions.isNotEmpty()) { + WatchPartyHostPickSuggestions(room, suggestions, eligibility, connected, actions) + } + WatchPartySeatsRow(members = room.members, phase = room.phase, onInvite = actions::invite) + } + val buttons: @Composable ColumnScope.() -> Unit = { + PrimaryButton(primary, room, winner, eligibility, connected, actions) + when (secondary) { + WatchPartySecondaryAction.ChangeTitle -> WatchPartyButton( + text = "Change title", + kind = WatchPartyButtonKind.Secondary, + enabled = eligibility.canStage, + onClick = actions::chooseTitle, + modifier = Modifier.fillMaxWidth(), + ) + WatchPartySecondaryAction.Suggest -> WatchPartyButton( + text = if (primary is WatchPartyPrimaryAction.Start) "Suggest" else "Suggest a title", + icon = Icons.Filled.Add, + kind = WatchPartyButtonKind.Secondary, + enabled = eligibility.canSuggest, + onClick = actions::suggest, + modifier = Modifier.fillMaxWidth(), + ) + null -> Unit + } + if (hint != null) WatchPartyHint(hint) + } + + BoxWithConstraints(modifier.fillMaxSize()) { + val landscape = maxWidth > maxHeight + WatchPartyBackdrop( + url = backdrop, + thumbhash = if (backdropIsPoster) staged?.posterThumbhash else staged?.backdropThumbhash, + isPoster = backdropIsPoster, + hidden = backdropHidden, + ) + Column( + Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)), + ) { + if (!landscape) { + WatchPartyTopBar(onBack = onBack) + Column( + verticalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(horizontal = WatchPartyMetrics.pageInset) + .padding(top = 8.dp, bottom = 16.dp), + ) { + header() + middle(false) + } + Column( + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier + .fillMaxWidth() + .background(WatchPartyBottomScrim) + .padding(horizontal = WatchPartyMetrics.pageInset) + .padding(top = 12.dp, bottom = 8.dp) + .navigationBarsPadding(), + content = buttons, + ) + } else { + // Over the landscape player: one bar across the top, the room on + // the left, the actions bottom-right, as on tvOS. + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(horizontal = WatchPartyMetrics.pageInset, vertical = 10.dp), + ) { + if (onBack != null) { + WatchPartyCircleButton(Icons.AutoMirrored.Filled.ArrowBack, "Navigate back", onBack, size = 40.dp) + Spacer(Modifier.width(14.dp)) + } + Text("Watch Party", fontSize = 17.sp, fontWeight = FontWeight.SemiBold, color = PrairieOnSurface) + Spacer(Modifier.width(14.dp)) + ConnectionDot(room, connected, reconnecting = !connected && !firstConnect) + Spacer(Modifier.weight(1f)) + WatchPartyCodePill(code = room.code, onClick = actions::invite) + Spacer(Modifier.width(12.dp)) + WatchPartyOptionsMenu(room, features, eligibility, actions) + } + Row(Modifier.weight(1f).padding(horizontal = WatchPartyMetrics.pageInset)) { + Column( + verticalArrangement = Arrangement.spacedBy(18.dp), + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .verticalScroll(rememberScrollState()) + .padding(bottom = 16.dp) + .navigationBarsPadding(), + ) { + middle(true) + } + Spacer(Modifier.width(28.dp)) + Column( + verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.Bottom), + modifier = Modifier + .width(300.dp) + .fillMaxHeight() + .padding(bottom = 16.dp) + .navigationBarsPadding(), + content = buttons, + ) + } + } + } + } +} + +@Composable +private fun ConnectionDot(room: RoomSnapshot, connected: Boolean, reconnecting: Boolean) { + val (tone, label) = watchPartyConnectionLabel(room, connected, reconnecting) + val color = when (tone) { + WatchPartyConnectionTone.Good -> WatchPartyColors.emerald + WatchPartyConnectionTone.Warning -> WatchPartyColors.amber + WatchPartyConnectionTone.Neutral -> PrairieSecondaryText + WatchPartyConnectionTone.Bad -> WatchPartyColors.rose + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.semantics(mergeDescendants = true) {}, + ) { + Box(Modifier.size((WatchPartyMetrics.CAPTION * 0.55f).dp).clip(CircleShape).background(color)) + Text(label, fontSize = WatchPartyMetrics.CAPTION.sp, color = PrairieSecondaryText, maxLines = 1) + } +} + +/** Eyebrow + title + facts, with the staged poster beside it. A voting lobby asks the question instead. */ +@Composable +private fun RoomHero(room: RoomSnapshot, staged: WatchPartyStagedTitle?, suggestions: List, compact: Boolean) { + val hero = watchPartyLobbyHero(room, suggestions, staged?.unavailable == true) + val showsPoster = !room.selectedContentId.isNullOrBlank() && !hero.isVoteQuestion + val titleSize = if (compact) 24f else WatchPartyMetrics.HERO_TITLE + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier.padding(top = if (compact) 4.dp else if (showsPoster) 120.dp else 40.dp), + ) { + if (showsPoster) { + WatchPartyHeroPoster( + staged?.posterUrl, + staged?.posterThumbhash, + if (compact) 72.dp else 112.dp, + hidden = staged?.spoilers?.hidesPoster(LocalEpisodeSpoilerPrefs.current) == true, + ) + } + Column(verticalArrangement = Arrangement.spacedBy((titleSize * 0.28f).dp), modifier = Modifier.weight(1f)) { + WatchPartyEyebrow(hero.eyebrow) + val title = hero.title ?: staged?.title + WatchPartyHeroTitle( + text = title ?: "Loading title…", + color = if (title == null || staged?.unavailable == true) PrairieSecondaryText else PrairieOnSurface, + size = titleSize, + ) + val selected = !room.selectedContentId.isNullOrBlank() && !hero.isVoteQuestion + if (selected && staged != null) FactsLine(staged) + hero.supporting?.let { + Text( + it, + fontSize = if (selected) WatchPartyMetrics.CAPTION.sp else WatchPartyMetrics.BODY.sp, + color = if (selected) PrairieSecondaryText.copy(alpha = 0.7f) else PrairieSecondaryText, + ) + } + } + } +} + +@Composable +private fun FactsLine(staged: WatchPartyStagedTitle) { + val parts = listOfNotNull(staged.subtitle?.takeIf { it.isNotBlank() }) + staged.facts + if (parts.isEmpty() && staged.chips.isEmpty()) return + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (parts.isNotEmpty()) { + Text( + buildAnnotatedString { + parts.forEachIndexed { index, part -> + if (index > 0) withStyle(SpanStyle(color = PrairieSecondaryText.copy(alpha = 0.5f))) { append(" · ") } + append(part) + } + }, + fontSize = (WatchPartyMetrics.BODY - 1).sp, + color = PrairieSecondaryText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + } + staged.chips.forEach { chip -> + Text( + chip, + fontSize = (WatchPartyMetrics.CAPTION * 0.85f).sp, + fontWeight = FontWeight.SemiBold, + color = PrairieSecondaryText, + maxLines = 1, + modifier = Modifier + .border(1.dp, PrairieSecondaryText.copy(alpha = 0.5f), RoundedCornerShape(4.dp)) + .padding(horizontal = 6.dp, vertical = 1.dp), + ) + } + } +} + +@Composable +private fun PrimaryButton( + primary: WatchPartyPrimaryAction, + room: RoomSnapshot, + winner: Suggestion?, + eligibility: WatchPartyEligibility, + connected: Boolean, + actions: WatchPartyRoomActions, +) { + val full = Modifier.fillMaxWidth() + when (primary) { + WatchPartyPrimaryAction.ChooseTitle -> WatchPartyButton( + text = if (room.selectedContentId.isNullOrBlank()) "Choose a title" else "Change title", + enabled = eligibility.canStage, + onClick = actions::chooseTitle, + modifier = full, + ) + is WatchPartyPrimaryAction.Start -> { + // A voting room starts its leader; a host-pick room its staged title. + val vote = room.selectionMode == RoomSelectionMode.Vote + WatchPartyButton( + text = primary.title?.let { "Start $it" } ?: "Start for everyone", + icon = Icons.Filled.PlayArrow, + enabled = connected && if (vote) eligibility.canPromote && winner != null else eligibility.canStart, + onClick = { if (vote) winner?.let(actions::promote) else actions.start() }, + modifier = full, + ) + } + WatchPartyPrimaryAction.WaitingForVotes -> WatchPartyButton( + text = "Suggest a title", + icon = Icons.Filled.Add, + enabled = eligibility.canSuggest, + onClick = actions::suggest, + modifier = full, + ) + is WatchPartyPrimaryAction.Ready -> if (primary.isReady) { + WatchPartyButton( + text = "You're ready", + icon = Icons.Filled.Check, + kind = WatchPartyButtonKind.Outlined, + enabled = connected && eligibility.canLobbyReady, + onClick = { actions.setLobbyReady(false) }, + modifier = full, + trailing = { + Text( + " tap to undo", + fontSize = WatchPartyMetrics.CAPTION.sp, + color = PrairieOnSurface.copy(alpha = 0.6f), + maxLines = 1, + ) + }, + ) + } else { + WatchPartyButton( + text = "I'm ready", + icon = Icons.Outlined.CheckCircle, + enabled = connected && eligibility.canLobbyReady, + onClick = { actions.setLobbyReady(true) }, + modifier = full, + ) + } + WatchPartyPrimaryAction.ReturnToPlayback -> WatchPartyButton( + text = "Return to playback", + icon = Icons.Filled.PlayArrow, + onClick = actions::returnToPlayback, + modifier = full, + ) + WatchPartyPrimaryAction.None -> Unit + } +} + +private enum class OptionsPage { Main, Mode, Policy } + +/** + * The "···" menu. Settings open a second page that shows their choices, so + * the top level reads as one aligned list with each setting's current value. + */ +@Composable +private fun WatchPartyOptionsMenu( + room: RoomSnapshot, + features: WatchPartyFeatures?, + eligibility: WatchPartyEligibility, + actions: WatchPartyRoomActions, +) { + var open by remember { mutableStateOf(false) } + var page by remember { mutableStateOf(OptionsPage.Main) } + val host = eligibility.isHost + fun close() { + open = false + page = OptionsPage.Main + } + Box { + WatchPartyCircleButton( + icon = Icons.Filled.MoreHoriz, + contentDescription = "Party options", + onClick = { open = true }, + ) + PrairieDropdownMenu(expanded = open, onDismissRequest = ::close) { + val vote = room.selectionMode == RoomSelectionMode.Vote + val guestsControl = room.guestControlPolicy == GuestControlPolicy.GuestPlayPause + when (page) { + OptionsPage.Main -> { + if (host) { + if (room.phase == RoomPhase.Lobby && features?.selectionModeSwitch == true) { + OptionRow( + "Who chooses", + icon = Icons.Outlined.PanTool, + value = if (vote) "Everyone votes" else "Host", + enabled = eligibility.canSwitchMode, + ) { page = OptionsPage.Mode } + } + OptionRow( + "Play & pause", + icon = Icons.Outlined.SmartDisplay, + value = if (guestsControl) "Host and guests" else "Host only", + enabled = eligibility.canSetPolicy, + ) { page = OptionsPage.Policy } + if (room.phase == RoomPhase.Playing && features?.stopPlayback == true) { + OptionRow( + "Return everyone to lobby", + icon = Icons.AutoMirrored.Filled.Undo, + enabled = eligibility.canStop, + ) { + close() + actions.returnEveryoneToLobby() + } + } + HorizontalDivider(color = WatchPartyColors.chromeBorder) + } + OptionRow("Leave party", icon = Icons.AutoMirrored.Filled.Logout) { + close() + actions.leave() + } + if (host) { + OptionRow("End party", icon = Icons.Outlined.HighlightOff, destructive = true, enabled = eligibility.canEnd) { + close() + actions.end() + } + } + } + OptionsPage.Mode -> { + OptionRow("Who chooses", icon = Icons.AutoMirrored.Filled.ArrowBack, header = true) { page = OptionsPage.Main } + OptionRow("Host", checked = !vote, enabled = eligibility.canSwitchMode) { + close() + if (vote) actions.setMode(RoomSelectionMode.HostPick) + } + OptionRow("Everyone votes", checked = vote, enabled = eligibility.canSwitchMode) { + close() + if (!vote) actions.setMode(RoomSelectionMode.Vote) + } + } + OptionsPage.Policy -> { + OptionRow("Play & pause", icon = Icons.AutoMirrored.Filled.ArrowBack, header = true) { page = OptionsPage.Main } + OptionRow("Host only", checked = !guestsControl, enabled = eligibility.canSetPolicy) { + close() + if (guestsControl) actions.setPolicy(GuestControlPolicy.HostOnly) + } + OptionRow("Host and guests", checked = guestsControl, enabled = eligibility.canSetPolicy) { + close() + if (!guestsControl) actions.setPolicy(GuestControlPolicy.GuestPlayPause) + } + } + } + } + } +} + +@Composable +private fun OptionRow( + label: String, + icon: ImageVector? = null, + value: String? = null, + checked: Boolean? = null, + destructive: Boolean = false, + header: Boolean = false, + enabled: Boolean = true, + onClick: () -> Unit, +) { + val color = if (destructive) PrairieDestructive else PrairieOnSurface + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .widthIn(min = 240.dp) + .heightIn(min = 48.dp) + .alpha(if (enabled) 1f else 0.45f) + .clickable(enabled = enabled, onClick = onClick) + .padding(horizontal = 16.dp, vertical = 10.dp), + ) { + when { + checked != null -> Box(Modifier.size(20.dp)) { + if (checked) Icon(Icons.Filled.Check, contentDescription = "Selected", tint = color, modifier = Modifier.size(20.dp)) + } + icon != null -> Icon(icon, contentDescription = null, tint = if (header) PrairieSecondaryText else color, modifier = Modifier.size(20.dp)) + } + if (checked != null || icon != null) Spacer(Modifier.width(12.dp)) + Text( + label, + fontSize = 16.sp, + fontWeight = if (header) FontWeight.SemiBold else FontWeight.Normal, + color = if (header) PrairieSecondaryText else color, + modifier = Modifier.weight(1f), + ) + if (value != null) { + Spacer(Modifier.width(16.dp)) + Text(value, fontSize = 14.sp, color = PrairieSecondaryText, maxLines = 1) + } + } +} + +// MARK: Ballot (vote mode) + +/** Candidates with vote counts and a vote toggle. The leader is highlighted; Suggest is the dashed row. */ +@Composable +private fun WatchPartyBallot( + room: RoomSnapshot, + suggestions: List, + personalVotesKnown: Boolean, + eligibility: WatchPartyEligibility, + connected: Boolean, + actions: WatchPartyRoomActions, +) { + val leaderId = roomVoteWinner(suggestions)?.id + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + suggestions.forEach { suggestion -> + CandidateRow( + suggestion = suggestion, + isLeader = suggestion.id == leaderId, + votesKnown = personalVotesKnown, + canVote = eligibility.canVote && connected, + canPromote = eligibility.canPromote, + canRemove = !eligibility.busy && canRemoveSuggestion(room, suggestion), + actions = actions, + ) + } + if (suggestions.isEmpty()) { + Text( + "Nothing suggested yet. Anyone can add a title.", + fontSize = WatchPartyMetrics.CAPTION.sp, + color = PrairieSecondaryText, + ) + } + val dashColor = PrairieSecondaryText.copy(alpha = 0.5f) + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 44.dp) + .alpha(if (eligibility.canSuggest) 1f else 0.45f) + .clip(RoundedCornerShape(14.dp)) + .drawBehind { + drawRoundRect( + color = dashColor, + cornerRadius = CornerRadius(14.dp.toPx()), + style = Stroke(1.dp.toPx(), pathEffect = PathEffect.dashPathEffect(floatArrayOf(5.dp.toPx(), 4.dp.toPx()))), + ) + } + .clickable(enabled = eligibility.canSuggest, role = Role.Button, onClick = actions::suggest), + ) { + Icon(Icons.Filled.Add, contentDescription = null, tint = PrairieSecondaryText, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Suggest a title", fontSize = 15.sp, fontWeight = FontWeight.Medium, color = PrairieSecondaryText) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun CandidateRow( + suggestion: Suggestion, + isLeader: Boolean, + votesKnown: Boolean, + canVote: Boolean, + canPromote: Boolean, + canRemove: Boolean, + actions: WatchPartyRoomActions, +) { + var menu by remember { mutableStateOf(false) } + val voted = suggestion.votedByMe + val voteEnabled = votesKnown && canVote + val hasMenu = canPromote || canRemove + val shape = RoundedCornerShape(14.dp) + val votes = "${suggestion.voteCount} ${if (suggestion.voteCount == 1) "vote" else "votes"}" + Box { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .fillMaxWidth() + .clip(shape) + .background(if (isLeader) WatchPartyColors.chromeSelectedFill else WatchPartyColors.chromeFill) + .border(1.dp, if (isLeader) PrairieOnSurface.copy(alpha = 0.35f) else WatchPartyColors.chromeBorder, shape) + .combinedClickable( + enabled = voteEnabled || hasMenu, + onClick = { if (voteEnabled) actions.vote(suggestion) }, + onLongClick = if (hasMenu) ({ menu = true }) else null, + onLongClickLabel = "More actions", + ) + .semantics { + contentDescription = "${suggestion.title}, $votes" + if (isLeader) ", leading" else "" + customActions = buildList { + if (canPromote) add(CustomAccessibilityAction("Start this one") { actions.promote(suggestion); true }) + if (canRemove) add(CustomAccessibilityAction("Remove suggestion") { actions.remove(suggestion); true }) + } + } + .padding(8.dp), + ) { + WatchPartyPoster( + suggestion.posterUrl, + null, + WatchPartyMetrics.ballotPosterWidth, + cornerRadius = 6.dp, + hidden = WatchPartySpoilers.of(suggestion).hidesPoster(LocalEpisodeSpoilerPrefs.current), + ) + SuggestionText(suggestion, Modifier.weight(1f)) + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.widthIn(min = 48.dp)) { + Text( + "${suggestion.voteCount}", + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + color = PrairieOnSurface, + ) + Text( + if (suggestion.voteCount == 1) "VOTE" else "VOTES", + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 1.sp, + color = PrairieSecondaryText, + ) + } + val toggleShape = RoundedCornerShape(12.dp) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(44.dp) + .alpha(if (voteEnabled) 1f else 0.45f) + .clip(toggleShape) + .background(if (voted && votesKnown) PrairieOnSurface else Color.Transparent) + .border(1.dp, if (voted && votesKnown) Color.Transparent else WatchPartyColors.outline, toggleShape) + .clickable(enabled = voteEnabled, role = Role.Button) { actions.vote(suggestion) } + .semantics { + contentDescription = when { + !votesKnown -> "Loading your vote" + voted -> "Remove vote for ${suggestion.title}" + else -> "Vote for ${suggestion.title}" + } + }, + ) { + Icon( + when { + !votesKnown -> Icons.Outlined.Schedule + voted -> Icons.Filled.Check + else -> Icons.Filled.Add + }, + contentDescription = null, + tint = if (voted && votesKnown) Color.Black else PrairieSecondaryText, + modifier = Modifier.size(20.dp), + ) + } + } + PrairieDropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + if (canPromote) { + OptionRow("Start this one", icon = Icons.Filled.PlayArrow) { + menu = false + actions.promote(suggestion) + } + } + if (canRemove) { + OptionRow("Remove suggestion", icon = Icons.Outlined.Delete, destructive = true) { + menu = false + actions.remove(suggestion) + } + } + } + } +} + +@Composable +private fun SuggestionText(suggestion: Suggestion, modifier: Modifier) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + suggestion.title, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + color = PrairieOnSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val detail = listOfNotNull( + suggestion.subtitle.takeIf { it.isNotBlank() }, + suggestion.note.takeIf { it.isNotBlank() }?.let { "“$it”" }, + ).joinToString(" · ") + if (detail.isNotEmpty()) { + Text(detail, fontSize = 12.sp, color = PrairieSecondaryText, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } +} + +// MARK: Suggestions (host-pick mode) + +/** + * Guests' suggestions in a host-pick lobby. The host queues one as the pick + * ("Up next") rather than starting it; anyone can pull their own. + */ +@Composable +private fun WatchPartyHostPickSuggestions( + room: RoomSnapshot, + suggestions: List, + eligibility: WatchPartyEligibility, + connected: Boolean, + actions: WatchPartyRoomActions, +) { + val manages = room.selfRole == MemberRole.Host && room.selfCanManageRoom + Column(verticalArrangement = Arrangement.spacedBy((WatchPartyMetrics.CAPTION * 0.6f).dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + WatchPartyEyebrow("Suggestions · ${suggestions.size}") + Spacer(Modifier.width(12.dp).weight(1f)) + Text( + if (manages) "Queue one to put it up next." else "The host decides what plays.", + fontSize = WatchPartyMetrics.CAPTION.sp, + color = PrairieSecondaryText, + maxLines = 1, + ) + } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + suggestions.forEach { suggestion -> + HostPickSuggestionRow( + suggestion = suggestion, + queued = suggestion.contentId == room.selectedContentId, + canQueue = eligibility.canQueue && connected, + canRemove = !eligibility.busy && canRemoveSuggestion(room, suggestion), + actions = actions, + ) + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun HostPickSuggestionRow( + suggestion: Suggestion, + queued: Boolean, + canQueue: Boolean, + canRemove: Boolean, + actions: WatchPartyRoomActions, +) { + var menu by remember { mutableStateOf(false) } + val shape = RoundedCornerShape(14.dp) + Box { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .fillMaxWidth() + .clip(shape) + .background(WatchPartyColors.chromeFill) + .border(1.dp, WatchPartyColors.chromeBorder, shape) + .combinedClickable( + enabled = canRemove, + onClick = {}, + onLongClick = if (canRemove) ({ menu = true }) else null, + onLongClickLabel = "More actions", + ) + .semantics { + contentDescription = suggestion.title + if (canRemove) { + customActions = listOf(CustomAccessibilityAction("Remove suggestion") { actions.remove(suggestion); true }) + } + } + .padding(8.dp), + ) { + WatchPartyPoster( + suggestion.posterUrl, + null, + WatchPartyMetrics.ballotPosterWidth, + cornerRadius = 6.dp, + hidden = WatchPartySpoilers.of(suggestion).hidesPoster(LocalEpisodeSpoilerPrefs.current), + ) + SuggestionText(suggestion, Modifier.weight(1f)) + when { + queued -> Text( + "Up next", + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + color = PrairieSecondaryText, + modifier = Modifier.padding(horizontal = 8.dp), + ) + canQueue -> WatchPartyButton( + text = "Queue", + kind = WatchPartyButtonKind.Secondary, + onClick = { actions.queue(suggestion) }, + modifier = Modifier.height(44.dp).semantics { contentDescription = "Queue ${suggestion.title}" }, + ) + } + } + PrairieDropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + OptionRow("Remove suggestion", icon = Icons.Outlined.Delete, destructive = true) { + menu = false + actions.remove(suggestion) + } + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartyStyle.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartyStyle.kt new file mode 100644 index 0000000000..ec98e58b66 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartyStyle.kt @@ -0,0 +1,625 @@ +package org.prairieserver.prairie.android.ui.screens.watchparty + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.QrCode2 +import androidx.compose.material.icons.outlined.Movie +import androidx.compose.material3.Icon +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.alpha +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.prairieserver.prairie.android.ui.theme.PrairieBackground +import org.prairieserver.prairie.android.ui.theme.PrairieOnSurface +import org.prairieserver.prairie.android.ui.theme.PrairieSecondaryText +import org.prairieserver.prairie.android.ui.theme.PrairieSurfaceElevated +import org.prairieserver.prairie.common.ui.components.SpoilerImage +import org.prairieserver.prairie.model.watchtogether.RoomMember +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.watchtogether.WatchPartySeatState +import org.prairieserver.prairie.watchtogether.watchPartyAvatarIndex +import org.prairieserver.prairie.watchtogether.watchPartyPresenceSummary +import org.prairieserver.prairie.watchtogether.watchPartySeatOrder +import org.prairieserver.prairie.watchtogether.watchPartySeatState +import org.prairieserver.prairie.watchtogether.watchPartySeatStatus + +/* + * The Watch Party look, after prairie-apple's WatchPartyComponents (iOS values): + * the film's artwork is the room, members sit over it as seats, and actions + * are white-on-black pills. + */ + +internal object WatchPartyMetrics { + val seat = 56.dp + val seatGap = 12.dp + val pageInset = 20.dp + const val HERO_TITLE = 28f + const val BODY = 15f + const val CAPTION = 13f + const val EYEBROW = 11f + const val CODE = 20f + val ballotPosterWidth = 44.dp +} + +internal object WatchPartyColors { + val chromeFill = Color.White.copy(alpha = 0.10f) + val chromeBorder = Color.White.copy(alpha = 0.14f) + val chromeSelectedFill = Color.White.copy(alpha = 0.18f) + val outline = Color.White.copy(alpha = 0.28f) + val amber = Color(0xFFF59E0B) + val amberText = Color(0xFFF5C563) + val emerald = Color(0xFF10B981) + val rose = Color(0xFFF43F5E) +} + +@Composable +internal fun WatchPartyEyebrow(text: String, modifier: Modifier = Modifier, color: Color = PrairieSecondaryText) { + Text( + text = text.uppercase(), + fontSize = WatchPartyMetrics.EYEBROW.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = (WatchPartyMetrics.EYEBROW * 0.12f).sp, + color = color, + modifier = modifier, + ) +} + +/** + * Full-bleed artwork under the room. A poster (when that is all there is) is + * blurred and dimmed so it tints the page rather than competing with it. + */ +@Composable +internal fun WatchPartyBackdrop( + url: String?, + thumbhash: String? = null, + isPoster: Boolean = false, + modifier: Modifier = Modifier, + /** Spoiler protection hides this artwork: an unwatched episode's still. */ + hidden: Boolean = false, +) { + Box(modifier.fillMaxSize().background(PrairieBackground)) { + // Each artwork keeps its own treatment while it fades out, so an + // outgoing protected still is never drawn unhidden. A layer showing + // the current artwork is hidden at once when protection turns on. + val target = url?.takeIf { it.isNotBlank() }?.let { BackdropArt(it, thumbhash, isPoster, hidden) } + Crossfade(targetState = target, animationSpec = tween(400), label = "backdrop") { art -> + if (art != null) { + SpoilerImage( + url = art.url, + thumbhash = art.thumbhash, + hidden = art.hidden || (target?.hidden == true && art.url == target.url), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .then(if (art.isPoster) Modifier.blur(28.dp).alpha(0.7f) else Modifier), + ) + } + } + Box( + Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + 0f to Color.Black.copy(alpha = 0.6f), + 0.22f to Color.Black.copy(alpha = 0.25f), + 0.45f to Color.Black.copy(alpha = 0.72f), + 0.62f to Color.Black.copy(alpha = 0.94f), + 0.78f to Color.Black, + ), + ), + ) + } +} + +private data class BackdropArt(val url: String, val thumbhash: String?, val isPoster: Boolean, val hidden: Boolean) + +/** Transparent bar: a round Back and the centred page title. */ +@Composable +internal fun WatchPartyTopBar(onBack: (() -> Unit)?, modifier: Modifier = Modifier, title: String = "Watch Party") { + Box(modifier.fillMaxWidth().height(56.dp).padding(horizontal = 16.dp)) { + if (onBack != null) { + WatchPartyCircleButton( + icon = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Navigate back", + onClick = onBack, + size = 44.dp, + modifier = Modifier.align(Alignment.CenterStart), + ) + } + Text( + title, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + color = PrairieOnSurface, + modifier = Modifier.align(Alignment.Center), + ) + } +} + +@Composable +internal fun WatchPartyCircleButton( + icon: ImageVector, + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + size: Dp = 36.dp, + enabled: Boolean = true, + shape: Shape = CircleShape, +) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .size(size) + .clip(shape) + .background(WatchPartyColors.chromeFill) + .border(1.dp, WatchPartyColors.chromeBorder, shape) + .clickable(enabled = enabled, role = Role.Button, onClick = onClick) + .semantics { this.contentDescription = contentDescription }, + ) { + Icon(icon, contentDescription = null, tint = PrairieOnSurface, modifier = Modifier.size(size * 0.5f)) + } +} + +/** The room code is the invitation: one monospaced pill that opens it. */ +@Composable +internal fun WatchPartyCodePill(code: String, onClick: () -> Unit, modifier: Modifier = Modifier) { + val size = WatchPartyMetrics.CODE + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy((size * 0.5f).dp), + modifier = modifier + .clip(CircleShape) + .background(WatchPartyColors.chromeFill) + .border(1.dp, WatchPartyColors.chromeBorder, CircleShape) + .clickable(role = Role.Button, onClick = onClick) + .clearAndSetSemantics { contentDescription = "Party code ${code.toList().joinToString(" ")}. Invite friends" } + .padding(horizontal = (size * 0.75f).dp, vertical = (size * 0.4f).dp), + ) { + Text( + code, + fontSize = size.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + letterSpacing = (size * 0.18f).sp, + color = PrairieOnSurface, + maxLines = 1, + ) + Icon( + Icons.Filled.QrCode2, + contentDescription = null, + tint = PrairieSecondaryText, + modifier = Modifier.size((size * 0.9f).dp), + ) + } +} + +private val avatarPalette = listOf( + Color(0xFF5A4A8A) to Color(0xFF2B2450), + Color(0xFF8A5A3A) to Color(0xFF4A2C1C), + Color(0xFF3A7A6A) to Color(0xFF1C3F36), + Color(0xFF7A3A5A) to Color(0xFF3F1C30), + Color(0xFF4A6A8A) to Color(0xFF213546), + Color(0xFF7A6A2A) to Color(0xFF3F3616), +) + +/** The member's avatar colours, chosen as Apple chooses them so a member keeps one colour everywhere. */ +internal fun watchPartyAvatarGradient(member: RoomMember): Brush { + val (start, end) = avatarPalette[watchPartyAvatarIndex(member, avatarPalette.size)] + return Brush.linearGradient(listOf(start, end)) +} + +/** + * One member as a seat: an avatar disc whose ring encodes state. Dashed grey + * is here but not ready, solid white with a tick is ready (or watching), + * dotted and dimmed is reconnecting. + */ +@Composable +internal fun WatchPartySeat(member: RoomMember, state: WatchPartySeatState, size: Dp = WatchPartyMetrics.seat) { + val ring = size * 1.14f + val ready = state == WatchPartySeatState.Ready || state == WatchPartySeatState.Watching + val away = state == WatchPartySeatState.Away + val name = watchPartyMemberName(member) + val status = watchPartySeatStatus(state) + val description = buildList { + add(name) + if (member.isSelf) add("you") + if (member.isHost) add("host") + add( + when (state) { + WatchPartySeatState.Ready -> "ready" + WatchPartySeatState.Watching -> "watching" + else -> status.orEmpty() + }, + ) + }.filter { it.isNotBlank() }.joinToString(", ") + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(size * 0.11f), + modifier = Modifier.width(size * 1.4f).semantics(mergeDescendants = true) { contentDescription = description }, + ) { + Box(Modifier.padding(top = size * 0.1f).size(ring), contentAlignment = Alignment.Center) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(size) + .alpha(if (away) 0.45f else 1f) + .clip(CircleShape) + .background(watchPartyAvatarGradient(member)), + ) { + Text( + name.trim().take(1).uppercase(), + fontSize = (size.value * 0.36f).sp, + fontWeight = FontWeight.SemiBold, + color = PrairieOnSurface, + ) + } + SeatRing(state = state, modifier = Modifier.size(ring)) + if (member.isHost) { + Text( + "HOST", + fontSize = 8.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.sp, + color = PrairieOnSurface, + modifier = Modifier + .align(Alignment.TopCenter) + .offset(y = -(size * 0.12f)) + .clip(RoundedCornerShape(4.dp)) + .background(Color.Black) + .border(1.dp, WatchPartyColors.chromeBorder, RoundedCornerShape(4.dp)) + .padding(horizontal = 5.dp, vertical = 1.dp), + ) + } + if (ready) { + // Sits on the ring at 4:30, cutting into it. + val offset = ring / 2 * 0.7071f + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .offset(x = offset, y = offset) + .size(size * 0.34f) + .clip(CircleShape) + .background(Color.Black) + .padding(size * 0.03f) + .clip(CircleShape) + .background(PrairieOnSurface), + ) { + Icon( + Icons.Filled.Check, + contentDescription = null, + tint = Color.Black, + modifier = Modifier.size(size * 0.2f), + ) + } + } + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + buildAnnotatedString { + append(name) + if (member.isSelf) { + withStyle(SpanStyle(color = PrairieSecondaryText, fontWeight = FontWeight.Normal)) { append(" · you") } + } + }, + fontSize = (size.value * 0.19f).sp, + fontWeight = FontWeight.Medium, + color = PrairieOnSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + ) + if (status != null) { + Text( + status, + fontSize = (size.value * 0.16f).sp, + color = PrairieSecondaryText, + maxLines = 1, + ) + } + } + } +} + +@Composable +private fun SeatRing(state: WatchPartySeatState, modifier: Modifier) { + Canvas(modifier) { + val width = this.size.minDimension * 0.035f / 1.14f + val radius = (this.size.minDimension - width) / 2 + when (state) { + WatchPartySeatState.Ready, WatchPartySeatState.Watching -> + drawCircle(PrairieOnSurface, radius = radius, style = Stroke(width)) + WatchPartySeatState.Away -> drawCircle( + PrairieOnSurface.copy(alpha = 0.25f), + radius = radius, + style = Stroke( + width, + cap = StrokeCap.Round, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(0.1f, width * 3)), + ), + ) + else -> drawCircle( + PrairieOnSurface.copy(alpha = 0.38f), + radius = radius, + style = Stroke(width, pathEffect = PathEffect.dashPathEffect(floatArrayOf(width * 3, width * 2.2f))), + ) + } + } +} + +/** The dashed "+" seat that opens the invitation. */ +@Composable +private fun WatchPartyInviteSeat(onClick: () -> Unit, size: Dp = WatchPartyMetrics.seat) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(size * 0.11f), + modifier = Modifier + .width(size * 1.4f) + .clip(RoundedCornerShape(12.dp)) + .clickable(role = Role.Button, onClick = onClick) + .clearAndSetSemantics { contentDescription = "Invite friends" }, + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .padding(top = size * 0.1f) + .size(size * 1.14f) + .border(size * 0.035f, WatchPartyColors.outline, CircleShape), + ) { + Icon(Icons.Filled.Add, contentDescription = null, tint = PrairieSecondaryText, modifier = Modifier.size(size * 0.4f)) + } + Text("Invite", fontSize = (size.value * 0.19f).sp, fontWeight = FontWeight.Medium, color = PrairieSecondaryText) + } +} + +/** "Here now" with its presence summary, then the seats and the Invite seat. */ +@Composable +internal fun WatchPartySeatsRow( + members: List, + phase: RoomPhase, + onInvite: () -> Unit, + modifier: Modifier = Modifier, + seatSize: Dp = WatchPartyMetrics.seat, +) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(seatSize * 0.25f)) { + Row(horizontalArrangement = Arrangement.spacedBy(seatSize * 0.2f), verticalAlignment = Alignment.CenterVertically) { + WatchPartyEyebrow("Here now") + Text( + watchPartyPresenceSummary(members, phase), + fontSize = WatchPartyMetrics.CAPTION.sp, + color = PrairieSecondaryText.copy(alpha = 0.7f), + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(WatchPartyMetrics.seatGap), + verticalAlignment = Alignment.Top, + modifier = Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 2.dp), + ) { + watchPartySeatOrder(members).forEach { member -> + WatchPartySeat(member, watchPartySeatState(member, phase), seatSize) + } + WatchPartyInviteSeat(onClick = onInvite, size = seatSize) + } + } +} + +internal enum class WatchPartyButtonKind { Primary, Secondary, Outlined } + +/** Apple's lobby button: 52dp, 14dp corners, white fill for the primary. */ +@Composable +internal fun WatchPartyButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + kind: WatchPartyButtonKind = WatchPartyButtonKind.Primary, + enabled: Boolean = true, + icon: ImageVector? = null, + trailing: (@Composable RowScope.() -> Unit)? = null, +) { + val shape = RoundedCornerShape(14.dp) + val foreground = if (kind == WatchPartyButtonKind.Primary) Color.Black else PrairieOnSurface + val background = when (kind) { + WatchPartyButtonKind.Primary -> PrairieOnSurface + WatchPartyButtonKind.Secondary -> WatchPartyColors.chromeFill + WatchPartyButtonKind.Outlined -> Color.Transparent + } + val border = when (kind) { + WatchPartyButtonKind.Primary -> Color.Transparent + WatchPartyButtonKind.Secondary -> WatchPartyColors.chromeBorder + WatchPartyButtonKind.Outlined -> PrairieOnSurface + } + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .heightIn(min = 52.dp) + .alpha(if (enabled) 1f else 0.45f) + .clip(shape) + .background(background) + .border(BorderStroke(1.dp, border), shape) + .clickable(enabled = enabled, role = Role.Button, onClick = onClick) + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + if (icon != null) { + Icon(icon, contentDescription = null, tint = foreground, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + } + Text( + text, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + color = foreground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + trailing?.invoke(this) + } +} + +internal enum class WatchPartyBannerTone { Warning, Neutral } + +/** One banner for anything the member should know. [action] adds trailing buttons. */ +@Composable +internal fun WatchPartyBanner( + text: String, + modifier: Modifier = Modifier, + tone: WatchPartyBannerTone = WatchPartyBannerTone.Neutral, + action: (@Composable () -> Unit)? = null, +) { + val warning = tone == WatchPartyBannerTone.Warning + val shape = RoundedCornerShape(12.dp) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .clip(shape) + .background(if (warning) WatchPartyColors.amber.copy(alpha = 0.12f) else WatchPartyColors.chromeFill) + .border(1.dp, if (warning) WatchPartyColors.amber.copy(alpha = 0.3f) else WatchPartyColors.chromeBorder, shape) + .padding(start = 13.dp, end = if (action != null) 4.dp else 13.dp, top = 10.dp, bottom = 10.dp), + ) { + Box( + Modifier + .size((WatchPartyMetrics.CAPTION * 0.6f).dp) + .clip(CircleShape) + .background(if (warning) WatchPartyColors.amber else PrairieSecondaryText), + ) + Spacer(Modifier.width(10.dp)) + Text( + text, + fontSize = (WatchPartyMetrics.CAPTION + 1).sp, + color = if (warning) WatchPartyColors.amberText else PrairieOnSurface, + modifier = Modifier.weight(1f), + ) + action?.invoke() + } +} + +@Composable +internal fun WatchPartyPoster( + url: String?, + thumbhash: String?, + width: Dp, + modifier: Modifier = Modifier, + cornerRadius: Dp = 8.dp, + /** Spoiler protection hides this artwork: an unwatched episode's still. */ + hidden: Boolean = false, +) { + val shape = RoundedCornerShape(cornerRadius) + Box( + contentAlignment = Alignment.Center, + modifier = modifier.width(width).height(width * 1.5f).clip(shape).background(PrairieSurfaceElevated), + ) { + if (url.isNullOrBlank()) { + Icon(Icons.Outlined.Movie, contentDescription = null, tint = PrairieSecondaryText) + } else { + SpoilerImage( + url = url, + thumbhash = thumbhash, + hidden = hidden, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + } +} + +/** A poster with Apple's lifted shadow, for the lobby hero and the confirmation page. */ +@Composable +internal fun WatchPartyHeroPoster(url: String?, thumbhash: String?, width: Dp, hidden: Boolean = false) { + WatchPartyPoster( + url = url, + thumbhash = thumbhash, + width = width, + hidden = hidden, + modifier = Modifier.shadow(16.dp, RoundedCornerShape(8.dp), ambientColor = Color.Black, spotColor = Color.Black), + ) +} + +/** Soft black under pinned bottom actions. */ +internal val WatchPartyBottomScrim = Brush.verticalGradient( + 0f to Color.Transparent, + 0.35f to Color.Black.copy(alpha = 0.9f), + 1f to Color.Black, +) + +/** The quiet trailing hint line under the lobby's buttons. */ +@Composable +internal fun WatchPartyHint(text: String, modifier: Modifier = Modifier) { + Text( + text, + fontSize = WatchPartyMetrics.CAPTION.sp, + color = PrairieSecondaryText.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + modifier = modifier.fillMaxWidth(), + ) +} + +/** Title-sized heading helper. */ +@Composable +internal fun WatchPartyHeroTitle(text: String, color: Color = PrairieOnSurface, size: Float = WatchPartyMetrics.HERO_TITLE, maxLines: Int = 3) { + Text( + text, + fontSize = size.sp, + lineHeight = (size * 1.12f).sp, + fontWeight = FontWeight.Bold, + letterSpacing = (-size * 0.02f).sp, + color = color, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + ) +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartySupport.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartySupport.kt new file mode 100644 index 0000000000..d43ca560f7 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartySupport.kt @@ -0,0 +1,116 @@ +package org.prairieserver.prairie.android.ui.screens.watchparty + +import java.security.MessageDigest +import org.prairieserver.prairie.model.catalog.FileVersion +import org.prairieserver.prairie.model.watchtogether.RoomMember +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.RoomPlaybackState +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.repository.WatchPartyEndReason +import org.prairieserver.prairie.watchtogether.WatchPartyInvite +import org.prairieserver.prairie.watchtogether.parseWatchPartyInvite + +/** + * A `prairie://watch-party?server=…&token=…` app link, validated by the shared + * invitation parser. Anything else (including `prairie://invite`, the account + * invitation) is not a party link. + */ +internal fun watchPartyAppLinkOrNull(rawUri: String?): WatchPartyInvite.Link? { + val text = rawUri?.trim()?.takeIf { it.isNotEmpty() } ?: return null + if (!text.startsWith("prairie://", ignoreCase = true)) return null + return parseWatchPartyInvite(text) as? WatchPartyInvite.Link +} + +/** + * A stable, non-reversible id for an invitation link. It names the pending + * invitation in the hub route and in the Activity's consumed-route marker, so + * a task rebuilt after process death does not join again, while the token + * itself stays out of every route and saved state. + */ +internal fun watchPartyInviteHandoffId(link: WatchPartyInvite.Link): String { + val digest = MessageDigest.getInstance("SHA-256") + .digest("${link.serverUrl}\n${link.token}".toByteArray(Charsets.UTF_8)) + return digest.take(INVITE_ID_BYTES).joinToString("") { "%02x".format(it) } +} + +private const val INVITE_ID_BYTES = 12 + +/** What the lobby and player say when this device's party ended. */ +internal fun watchPartyEndedMessage(reason: String): String = when (reason) { + WatchPartyEndReason.Replaced -> "This profile joined the Watch Party on another device." + WatchPartyEndReason.ConnectionLost -> "Lost connection to the party." + WatchPartyEndReason.Unauthorized -> "Sign in again to use Watch Party." + else -> "The Watch Party has ended." +} + +/** The party itself goes on after these, so the hub offers Rejoin. */ +internal fun watchPartyEndedOffersRejoin(reason: String): Boolean = + reason == WatchPartyEndReason.Replaced || reason == WatchPartyEndReason.ConnectionLost + +internal fun watchPartyMemberName(member: RoomMember): String = + member.displayName.ifBlank { "Someone" } + +/** "Alex", "Alex and Sam", "Alex, Sam, and Kim", "Alex, Sam, and 3 others". */ +internal fun formatWatchPartyNames(names: List): String = when (names.size) { + 0 -> "" + 1 -> names[0] + 2 -> "${names[0]} and ${names[1]}" + 3 -> "${names[0]}, ${names[1]}, and ${names[2]}" + else -> "${names[0]}, ${names[1]}, and ${names.size - 2} others" +} + +/** The other members the room is waiting for while it syncs playback. */ +internal fun watchPartyWaitingNames(room: RoomSnapshot?): List { + if (room == null || room.phase != RoomPhase.Playing || room.playbackState != RoomPlaybackState.Waiting) { + return emptyList() + } + return room.members.filter { it.isSyncing && !it.isSelf }.map(::watchPartyMemberName) +} + +/** + * Members the room went on without: they were syncing while the room waited + * and are still not ready once it plays again at the same selection. The + * viewer is left out; their own status is the catching-up notice. A member + * who left the room is not "left behind". + */ +internal fun watchPartyLeftBehindNames(previous: RoomSnapshot?, next: RoomSnapshot?): List { + if (previous == null || next == null) return emptyList() + if (previous.roomId != next.roomId || previous.selectionRevision != next.selectionRevision) return emptyList() + if (previous.phase != RoomPhase.Playing || next.phase != RoomPhase.Playing) return emptyList() + if (previous.playbackState != RoomPlaybackState.Waiting || next.playbackState != RoomPlaybackState.Playing) { + return emptyList() + } + return previous.members + .filter { it.isSyncing && !it.isSelf } + .mapNotNull { before -> + val after = next.members.firstOrNull { it.isSameMemberAs(before) } ?: return@mapNotNull null + watchPartyMemberName(after).takeIf { !after.isReady } + } +} + +private fun RoomMember.isSameMemberAs(other: RoomMember): Boolean = + userId.isNotBlank() && profileId.isNotBlank() && userId == other.userId && profileId == other.profileId + +/** Share text: the host's invitation link with the code, or the code alone for guests. */ +internal fun watchPartyShareText(code: String, inviteUrl: String?): String = + if (inviteUrl != null) { + "Join my Watch Party on Prairie: $inviteUrl\nOr enter the code $code." + } else { + "Join my Watch Party on Prairie with the code $code." + } + +/** Quality chips for the staged version, for example "4K" and "HDR". */ +internal fun watchPartyQualityChips(version: FileVersion?): List { + version ?: return emptyList() + val resolution = version.resolution?.lowercase().orEmpty() + return listOfNotNull( + "4K".takeIf { "2160" in resolution || "4k" in resolution }, + "HDR".takeIf { version.hdr }, + ) +} + +/** "2h 35m" or "48m", as Apple's `WatchPartyFacts.runtime`. */ +internal fun watchPartyRuntime(minutes: Int?): String? { + val value = minutes?.takeIf { it > 0 } ?: return null + return if (value >= 60) "${value / 60}h ${value % 60}m" else "${value}m" +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/SuggestToRoomViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/SuggestToRoomViewModel.kt deleted file mode 100644 index 68f05f0f01..0000000000 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/SuggestToRoomViewModel.kt +++ /dev/null @@ -1,61 +0,0 @@ -package org.prairieserver.prairie.android.ui.screens.watchtogether - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import org.prairieserver.prairie.model.watchtogether.AddSuggestionRequest -import org.prairieserver.prairie.model.watchtogether.RoomSnapshot -import org.prairieserver.prairie.network.ApiResult -import org.prairieserver.prairie.network.errorMessage -import org.prairieserver.prairie.repository.WatchTogetherRepository - -class SuggestToRoomViewModel( - private val repository: WatchTogetherRepository, -) : ViewModel() { - data class UiState( - val isBusy: Boolean = false, - val notice: String? = null, - val error: String? = null, - ) - - private val _uiState = MutableStateFlow(UiState()) - val uiState: StateFlow = _uiState.asStateFlow() - val room: StateFlow = repository.roomSnapshot - - fun suggest( - contentId: String, - contentType: String, - title: String, - subtitle: String?, - posterUrl: String?, - ) { - if (_uiState.value.isBusy) return - _uiState.update { it.copy(isBusy = true, error = null, notice = null) } - viewModelScope.launch { - val result = repository.addSuggestion( - AddSuggestionRequest( - contentId = contentId, - contentType = contentType, - title = title, - subtitle = subtitle, - posterUrl = posterUrl, - ), - ) - when (result) { - is ApiResult.Success -> - _uiState.update { it.copy(isBusy = false, notice = "Suggested to the room") } - is ApiResult.Error, is ApiResult.NetworkError -> - _uiState.update { - it.copy(isBusy = false, error = result.errorMessage("Could not suggest that")) - } - } - } - } - - fun consumeNotice() = _uiState.update { it.copy(notice = null) } - fun clearError() = _uiState.update { it.copy(error = null) } -} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntrySheet.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntrySheet.kt deleted file mode 100644 index b857ec5d98..0000000000 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntrySheet.kt +++ /dev/null @@ -1,135 +0,0 @@ -package org.prairieserver.prairie.android.ui.screens.watchtogether - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.SheetValue -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.unit.dp -import org.prairieserver.prairie.watchtogether.canDismissRoomEntry -import org.koin.compose.viewmodel.koinViewModel - -/** - * Watch Together entry sheet, opened from the item-detail overflow. - * Host = create a room with this title pre-selected. Join = enter an - * invite code. On success [onNavigate] fires with the resolved route - * (synced player or lobby). Styled after the other detail bottom sheets. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun WatchTogetherEntrySheet( - contentId: String, - fileId: Int?, - onNavigate: (String) -> Unit, - onDismiss: () -> Unit, - viewModel: WatchTogetherEntryViewModel = koinViewModel(), -) { - val state by viewModel.uiState.collectAsState() - var code by remember { mutableStateOf("") } - var showJoin by remember { mutableStateOf(false) } - val latestBusy by rememberUpdatedState(state.busy) - - LaunchedEffect(state.destination) { - val dest = state.destination ?: return@LaunchedEffect - viewModel.consumeDestination() - onDismiss() - onNavigate(dest) - } - - ModalBottomSheet( - onDismissRequest = { - if (canDismissRoomEntry(state.busy)) onDismiss() - }, - sheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = true, - confirmValueChange = { target -> - target != SheetValue.Hidden || canDismissRoomEntry(latestBusy) - }, - ), - containerColor = MaterialTheme.colorScheme.surface, - ) { - Text( - text = "Watch Together", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), - ) - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - - Column( - modifier = Modifier.fillMaxWidth().padding(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - state.error?.let { - Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium) - } - - if (!showJoin) { - Button( - onClick = { viewModel.host(contentId, fileId) }, - enabled = !state.busy, - modifier = Modifier.fillMaxWidth(), - ) { Text(if (state.busy) "Creating…" else "Host a room") } - - OutlinedButton( - onClick = { viewModel.hostEmptyVoteRoom() }, - enabled = !state.busy, - modifier = Modifier.fillMaxWidth(), - ) { Text("Host a vote room") } - - OutlinedButton( - onClick = { viewModel.clearError(); showJoin = true }, - enabled = !state.busy, - modifier = Modifier.fillMaxWidth(), - ) { Text("Join by code") } - } else { - OutlinedTextField( - value = code, - onValueChange = { code = it.uppercase().take(8) }, - label = { Text("Invite code") }, - singleLine = true, - keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Characters), - modifier = Modifier.fillMaxWidth(), - ) - Button( - onClick = { viewModel.joinByCode(code) }, - enabled = !state.busy && code.length >= 4, - modifier = Modifier.fillMaxWidth(), - ) { - if (state.busy) CircularProgressIndicator(modifier = Modifier.height(18.dp)) - else Text("Join") - } - OutlinedButton( - onClick = { viewModel.clearError(); showJoin = false }, - enabled = !state.busy, - modifier = Modifier.fillMaxWidth(), - ) { Text("Back") } - } - } - Spacer(Modifier.height(24.dp)) - } -} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntryViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntryViewModel.kt deleted file mode 100644 index 06b9e5ea98..0000000000 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntryViewModel.kt +++ /dev/null @@ -1,162 +0,0 @@ -package org.prairieserver.prairie.android.ui.screens.watchtogether - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import org.prairieserver.prairie.android.ui.navigation.Route -import org.prairieserver.prairie.model.watchtogether.CreateRoomRequest -import org.prairieserver.prairie.model.watchtogether.JoinRoomRequest -import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode -import org.prairieserver.prairie.model.watchtogether.RoomSnapshot -import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest -import org.prairieserver.prairie.network.ApiResult -import org.prairieserver.prairie.network.errorMessage -import org.prairieserver.prairie.watchtogether.WatchTogetherEntryGateway -import org.prairieserver.prairie.watchtogether.WatchTogetherEntryTarget -import org.prairieserver.prairie.watchtogether.resumableWatchTogetherRoom -import org.prairieserver.prairie.watchtogether.watchTogetherEntryTarget -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -/** - * Pure navigation decision: after create/join, where do we send the user? - * If the room already has a selected title -> straight to the synced player - * (carrying roomId). Otherwise -> the lobby to wait / vote / pick. - * - * Kept top-level + pure so it is unit-testable without Compose or the repo. - */ -fun watchTogetherDestination(room: RoomSnapshot): String = - when (watchTogetherEntryTarget(room)) { - WatchTogetherEntryTarget.Player -> Route.Player( - contentId = requireNotNull(room.selectedContentId), - fileId = room.selectedFileId, - roomId = room.roomId, - ).route - WatchTogetherEntryTarget.Lobby -> Route.WatchTogetherLobby(roomId = room.roomId).route - } - -/** - * Backs the [WatchTogetherEntrySheet]. Hosts a room (create + set this title as - * the room selection) or joins an existing room by invite code, then resolves a - * navigation [UiState.destination] the sheet observes. - * - * The gateway stores the room JWT internally on create/join and reads the - * active roomId from its own snapshot, so [setSelection] takes only the request. - * Create/join/selection all return a `{room, room_access_token}` [RoomResponse] - * wrapper; the snapshot lives at `.data.room`. - */ -class WatchTogetherEntryViewModel( - private val gateway: WatchTogetherEntryGateway, -) : ViewModel() { - - data class UiState( - val busy: Boolean = false, - val error: String? = null, - /** Set once a create/join resolves — the sheet observes this and navigates. */ - val destination: String? = null, - ) - - private val _uiState = MutableStateFlow(UiState()) - val uiState: StateFlow = _uiState.asStateFlow() - val currentRoom: StateFlow = gateway.roomSnapshot - .map(::resumableWatchTogetherRoom) - .stateIn( - viewModelScope, - SharingStarted.Eagerly, - resumableWatchTogetherRoom(gateway.roomSnapshot.value), - ) - - /** Host flow: create a room with this title pre-selected as the room selection. */ - fun host( - contentId: String, - fileId: Int?, - selectionMode: RoomSelectionMode = RoomSelectionMode.HostPick, - ) { - if (selectionMode == RoomSelectionMode.Vote) { - hostEmptyVoteRoom() - return - } - if (_uiState.value.busy) return - _uiState.update { it.copy(busy = true, error = null) } - viewModelScope.launch { - when ( - val created = gateway.createRoom( - CreateRoomRequest(selectionMode = selectionMode.wire), - ) - ) { - is ApiResult.Success -> { - // Set this title as the room selection so everyone lands on it. - when ( - val sel = gateway.setSelection( - SetSelectionRequest(contentId = contentId, fileId = fileId), - ) - ) { - is ApiResult.Success -> finish(sel.data.room) - is ApiResult.Error, is ApiResult.NetworkError -> - fail(sel.errorMessage("Failed to set selection")) - } - } - is ApiResult.Error, is ApiResult.NetworkError -> - fail(created.errorMessage("Failed to create room")) - } - } - } - - /** Host flow for the menu action: create an empty vote room, then enter its lobby. */ - fun hostEmptyVoteRoom() { - if (_uiState.value.busy) return - _uiState.update { it.copy(busy = true, error = null) } - viewModelScope.launch { - when ( - val created = gateway.createRoom( - CreateRoomRequest(selectionMode = RoomSelectionMode.Vote.wire), - ) - ) { - is ApiResult.Success -> finish(created.data.room) - is ApiResult.Error, is ApiResult.NetworkError -> - fail(created.errorMessage("Failed to create room")) - } - } - } - - /** Resume the valid room snapshot already held by the process session. */ - fun resumeCurrentRoom() { - if (_uiState.value.busy) return - val room = resumableWatchTogetherRoom(gateway.roomSnapshot.value) - if (room == null) { - fail("Current room is no longer available") - } else { - finish(room) - } - } - - /** Join flow: resolve an invite code; route to player if a selection exists, else lobby. */ - fun joinByCode(code: String) { - val trimmed = code.trim() - if (_uiState.value.busy || trimmed.isBlank()) return - _uiState.update { it.copy(busy = true, error = null) } - viewModelScope.launch { - when (val joined = gateway.joinRoom(JoinRoomRequest(code = trimmed))) { - is ApiResult.Success -> finish(joined.data.room) - is ApiResult.Error, is ApiResult.NetworkError -> - fail(joined.errorMessage("Could not join — check the code")) - } - } - } - - private fun finish(room: RoomSnapshot) { - _uiState.update { it.copy(busy = false, error = null, destination = watchTogetherDestination(room)) } - } - - private fun fail(message: String) { - _uiState.update { it.copy(busy = false, error = message) } - } - - fun consumeDestination() = _uiState.update { it.copy(destination = null) } - fun clearError() = _uiState.update { it.copy(error = null) } -} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyScreen.kt deleted file mode 100644 index 48cc79a488..0000000000 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyScreen.kt +++ /dev/null @@ -1,226 +0,0 @@ -package org.prairieserver.prairie.android.ui.screens.watchtogether - -import android.content.Intent -import android.widget.Toast -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.HowToVote -import androidx.compose.material.icons.filled.Share -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import org.prairieserver.prairie.model.watchtogether.MemberRole -import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode -import org.prairieserver.prairie.model.watchtogether.Suggestion -import org.prairieserver.prairie.watchtogether.isVoteRoom -import org.prairieserver.prairie.watchtogether.roomVoteWinner -import org.koin.compose.viewmodel.koinViewModel -import org.koin.core.parameter.parametersOf - -/** - * Watch Together lobby. Shows member count / role / selection mode, the - * suggestion list (vote / unvote, host promote + remove), an invite-code share - * affordance, and auto-navigates everyone into the synced player once the host - * picks/promotes a title (room transitions to [org.prairieserver.prairie.model.watchtogether.RoomPhase.Playing] - * with a selection). - * - * The "add a suggestion" affordance is deferred — the host pre-selection comes - * from item-detail; this screen focuses on vote/unvote/promote/pick + invite. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun WatchTogetherLobbyScreen( - roomId: String, - onNavigateToPlayer: (String) -> Unit, - onBack: () -> Unit, - viewModel: WatchTogetherLobbyViewModel = koinViewModel(parameters = { parametersOf(roomId) }), -) { - val room by viewModel.room.collectAsState() - val suggestions by viewModel.suggestions.collectAsState() - val closedReason by viewModel.roomClosedReason.collectAsState() - val context = LocalContext.current - - // Auto-navigate into the synced player when the room starts playing. - LaunchedEffect(room?.phase, room?.selectedContentId, room?.memberCount, room?.selfRole) { - val snapshot = room ?: return@LaunchedEffect - lobbyPlayerDestinationOrNull(snapshot)?.let { onNavigateToPlayer(it) } - } - - // Server closed the room (host left / explicit close) — back out. - LaunchedEffect(closedReason) { - if (closedReason != null) onBack() - } - - LaunchedEffect(viewModel) { - viewModel.errors.collect { message -> - if (message.isNotBlank()) { - Toast.makeText(context, message, Toast.LENGTH_SHORT).show() - } - } - } - - // Role drives only the cosmetic header label; mutating controls gate on the - // server's per-recipient management capability so a demoted/grace-period host - // (selfRole still "host" but management revoked) doesn't see dead buttons. - val isHostLabel = room?.selfRole == MemberRole.Host - val canManage = room?.selfCanManageRoom == true - - Scaffold( - topBar = { - TopAppBar( - title = { Text("Watch Together") }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back to browse", - ) - } - }, - actions = { - TextButton( - onClick = { - viewModel.leave() - onBack() - }, - ) { - Text("Leave room") - } - if (canManage) { - TextButton(onClick = { viewModel.closeRoom() }) { Text("Close") } - } - }, - ) - }, - ) { padding -> - Column( - Modifier.padding(padding).fillMaxSize().padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - val snapshot = room - if (snapshot == null) { - CircularProgressIndicator() - } else { - Text( - "${snapshot.memberCount} in room · ${if (isHostLabel) "Host" else "Guest"}", - style = MaterialTheme.typography.titleMedium, - ) - Text( - "Mode: ${selectionModeLabel(snapshot.selectionMode)}", - style = MaterialTheme.typography.bodyMedium, - ) - - // Invite code share row - Row(verticalAlignment = Alignment.CenterVertically) { - Text("Invite code: ${snapshot.code}", style = MaterialTheme.typography.bodyLarge) - Spacer(Modifier.width(12.dp)) - IconButton(onClick = { - val send = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra( - Intent.EXTRA_TEXT, - "Join my Watch Together room with code ${snapshot.code}", - ) - } - context.startActivity(Intent.createChooser(send, "Share invite code")) - }) { Icon(Icons.Filled.Share, contentDescription = "Share invite code") } - } - - HorizontalDivider() - val isVoteRoom = room.isVoteRoom() - val winner = roomVoteWinner(suggestions) - if (isVoteRoom && canManage) { - Button( - onClick = { winner?.let { viewModel.promote(it.id) } }, - enabled = winner != null, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - winner?.let { "Start winner: ${it.title}" } - ?: "Start winner — no votes yet", - ) - } - } - Text("Suggestions", style = MaterialTheme.typography.titleSmall) - LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { - items(suggestions, key = { it.id }) { s -> - SuggestionRow( - suggestion = s, - canManage = canManage, - isWinning = isVoteRoom && winner?.id == s.id, - onVote = { if (s.votedByMe) viewModel.unvote(s.id) else viewModel.vote(s.id) }, - onPromote = { viewModel.promote(s.id) }, - onRemove = { viewModel.removeSuggestion(s.id) }, - ) - } - } - } - } - } -} - -private fun selectionModeLabel(mode: RoomSelectionMode): String = when (mode) { - RoomSelectionMode.HostPick -> "Host pick" - RoomSelectionMode.Vote -> "Vote" - RoomSelectionMode.Unknown -> "—" -} - -@Composable -private fun SuggestionRow( - suggestion: Suggestion, - canManage: Boolean, - isWinning: Boolean, - onVote: () -> Unit, - onPromote: () -> Unit, - onRemove: () -> Unit, -) { - Card(Modifier.fillMaxWidth()) { - Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { - Column(Modifier.weight(1f)) { - Text(suggestion.title, style = MaterialTheme.typography.bodyLarge) - if (isWinning) { - Text("WINNING", style = MaterialTheme.typography.labelSmall) - } - if (suggestion.subtitle.isNotBlank()) { - Text(suggestion.subtitle, style = MaterialTheme.typography.bodySmall) - } - } - TextButton(onClick = onVote) { - Icon(Icons.Filled.HowToVote, contentDescription = "Vote") - Spacer(Modifier.width(4.dp)) - Text("${suggestion.voteCount}${if (suggestion.votedByMe) " ✓" else ""}") - } - if (canManage) { - TextButton(onClick = onPromote) { Text("Pick") } - TextButton(onClick = onRemove) { Text("Remove") } - } - } - } -} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyViewModel.kt deleted file mode 100644 index 0fe7ebeb1d..0000000000 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyViewModel.kt +++ /dev/null @@ -1,109 +0,0 @@ -package org.prairieserver.prairie.android.ui.screens.watchtogether - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import org.prairieserver.prairie.android.ui.navigation.Route -import org.prairieserver.prairie.model.watchtogether.PromoteSuggestionRequest -import org.prairieserver.prairie.model.watchtogether.MemberRole -import org.prairieserver.prairie.model.watchtogether.RoomPhase -import org.prairieserver.prairie.model.watchtogether.RoomSnapshot -import org.prairieserver.prairie.model.watchtogether.Suggestion -import org.prairieserver.prairie.network.ApiResult -import org.prairieserver.prairie.network.errorMessage -import org.prairieserver.prairie.repository.WatchTogetherRepository -import org.prairieserver.prairie.watchtogether.RoomSession -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch - -/** - * Pure: should the lobby jump to the synced player? Only once the room is - * actually playing AND a title is selected. Returns the player route or null. - * - * Compares against the real [RoomPhase] enum (not a wire string) — the landed - * shared model uses lenient enums, see WatchTogetherModels.kt. - */ -fun lobbyPlayerDestinationOrNull(room: RoomSnapshot): String? = - if (room.phase == RoomPhase.Playing && - !room.selectedContentId.isNullOrBlank() && - !(room.selfRole == MemberRole.Host && room.memberCount <= 1) - ) { - Route.Player( - contentId = room.selectedContentId!!, - fileId = room.selectedFileId, - roomId = room.roomId, - ).route - } else { - null - } - -/** - * Backs [WatchTogetherLobbyScreen]. Binds the per-room websocket on enter so - * snapshots/suggestions flow into the state flows, and exposes vote / unvote / - * host-promote / host-pick / close-room ops. - * - * The repository owns the room JWT and reads the active roomId from its own - * snapshot, so the room-scoped ops take only request/id params (not a roomId). - * The application-scoped [RoomSession] owns reconnect and survives navigation - * from this lobby into the synced player. - */ -class WatchTogetherLobbyViewModel( - private val roomId: String, - private val repository: WatchTogetherRepository, - private val roomSession: RoomSession, -) : ViewModel() { - - init { - roomSession.adopt(roomId) - } - - val room: StateFlow = repository.roomSnapshot - .stateIn(viewModelScope, SharingStarted.Eagerly, repository.roomSnapshot.value) - val suggestions: StateFlow> = repository.suggestions - .stateIn(viewModelScope, SharingStarted.Eagerly, repository.suggestions.value) - val roomClosedReason: StateFlow = repository.roomClosedReason - .stateIn(viewModelScope, SharingStarted.Eagerly, repository.roomClosedReason.value) - val errors = repository.errors - - fun vote(suggestionId: String) = - launchOperation("Could not vote") { repository.vote(suggestionId) } - - fun unvote(suggestionId: String) = - launchOperation("Could not remove vote") { repository.unvote(suggestionId) } - - fun removeSuggestion(suggestionId: String) = - launchOperation("Could not remove suggestion") { - repository.deleteSuggestion(suggestionId) - } - - /** Host: promote a suggestion to the room selection (moves everyone to the player). */ - fun promote(suggestionId: String) = - launchOperation("Could not start suggestion") { - repository.promoteSuggestion(PromoteSuggestionRequest(suggestionId = suggestionId)) - } - - fun closeRoom() = launchOperation("Could not close room") { repository.closeRoom() } - - private fun launchOperation( - fallback: String, - operation: suspend () -> ApiResult, - ) = viewModelScope.launch { - val result = operation() - if (result !is ApiResult.Success) { - repository.reportDeliveryFailure(result.errorMessage(fallback)) - } - } - - /** Guest/host leave: tear down the WS + clear room state. */ - fun leave() { - roomSession.depart() - } - - override fun onCleared() { - super.onCleared() - // Do NOT reset here — the player binding reuses the same repo connection - // when we auto-navigate into the synced player. reset() is called - // explicitly via leave() when the user backs out without playing. - } -} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherMenuEntrySheet.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherMenuEntrySheet.kt deleted file mode 100644 index d2e466b2b8..0000000000 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherMenuEntrySheet.kt +++ /dev/null @@ -1,127 +0,0 @@ -package org.prairieserver.prairie.android.ui.screens.watchtogether - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.SheetValue -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import org.koin.compose.viewmodel.koinViewModel -import org.prairieserver.prairie.watchtogether.canDismissRoomEntry - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun WatchTogetherMenuEntrySheet( - onNavigate: (String) -> Unit, - onDismiss: () -> Unit, - viewModel: WatchTogetherEntryViewModel = koinViewModel(), -) { - val state by viewModel.uiState.collectAsState() - val currentRoom by viewModel.currentRoom.collectAsState() - var code by rememberSaveable { mutableStateOf("") } - var showJoin by rememberSaveable { mutableStateOf(false) } - val latestBusy by rememberUpdatedState(state.busy) - - LaunchedEffect(state.destination) { - val destination = state.destination ?: return@LaunchedEffect - viewModel.consumeDestination() - onDismiss() - onNavigate(destination) - } - - ModalBottomSheet( - onDismissRequest = { - if (canDismissRoomEntry(state.busy)) { - viewModel.clearError() - onDismiss() - } - }, - sheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = true, - confirmValueChange = { target -> - target != SheetValue.Hidden || canDismissRoomEntry(latestBusy) - }, - ), - ) { - Text( - text = "Watch Together", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), - ) - HorizontalDivider() - - Column( - modifier = Modifier.fillMaxWidth().padding(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - if (!showJoin) { - if (currentRoom != null) { - Button( - onClick = { viewModel.resumeCurrentRoom() }, - enabled = !state.busy, - modifier = Modifier.fillMaxWidth(), - ) { Text("Resume current room") } - } - Button( - onClick = { viewModel.hostEmptyVoteRoom() }, - enabled = !state.busy, - modifier = Modifier.fillMaxWidth(), - ) { Text(if (state.busy) "Creating…" else "Host a room") } - OutlinedButton( - onClick = { viewModel.clearError(); showJoin = true }, - enabled = !state.busy, - modifier = Modifier.fillMaxWidth(), - ) { Text("Join by code") } - } else { - OutlinedTextField( - value = code, - onValueChange = { - code = it.uppercase().filter(Char::isLetterOrDigit).take(8) - }, - label = { Text("Invite code") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Button( - onClick = { viewModel.joinByCode(code) }, - enabled = !state.busy && code.length >= 4, - modifier = Modifier.fillMaxWidth(), - ) { - if (state.busy) CircularProgressIndicator(modifier = Modifier.size(18.dp)) - else Text("Join") - } - OutlinedButton( - onClick = { viewModel.clearError(); showJoin = false }, - enabled = !state.busy, - modifier = Modifier.fillMaxWidth(), - ) { Text("Back") } - } - state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } - } - Spacer(Modifier.height(24.dp)) - } -} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/theme/Color.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/theme/Color.kt index 8d688dd65e..dcc4b0657d 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/theme/Color.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/theme/Color.kt @@ -55,8 +55,34 @@ val PrairieOpaqueControlBorder = Color.White.copy(alpha = 0.46f) // settings values, and they fill M3's `surfaceContainer*` ladder — which // `darkColorScheme` otherwise leaves on its purple-tinted baseline. -/** Lifted page ground for form-shaped screens. Web `--background`. */ -val PrairieSettingsBackground = Color(0xFF141417) +/** + * Settings page ground. Pure black, like the Apple apps' Settings backdrop + * (`prairieBackground`), so the grouped cells read as an inset list. + */ +val PrairieSettingsBackground = Color(0xFF000000) + +// --- Settings grouped list (Apple Settings parity) --- +// +// The Apple apps draw Settings as a native inset-grouped list in Apple's +// dark-mode grouped palette (prairie-apple `Colors.swift`). Settings uses these +// rather than the web card values above so both phones show the same list; +// its text uses the chrome's `PrairieOnSurface` / `PrairieSecondaryText`, which are +// already Apple's `prairieOnSurface` / `prairieSecondaryText`. + +/** Grouped row background. Apple `prairieGroupedCell`. */ +val PrairieGroupedCell = Color(0xFF1C1C1E) + +/** Graphite tile behind an overview row's glyph. Apple `prairieIconTile`. */ +val PrairieIconTile = Color(0xFF3A3A3C) + +/** Switch track while on. Apple `prairieSwitchOn`, the system dark-mode green. */ +val PrairieSwitchOn = Color(0xFF30D158) + +/** Switch track while off, Apple's dark grouped-cell switch fill. */ +val PrairieSwitchOff = Color(0xFF39393D) + +/** Hairline between grouped rows, Apple's dark-mode separator. */ +val PrairieGroupedSeparator = Color(0xFF38383A) /** Grouped card surface. Web `--card`. */ val PrairieSurfaceContainer = Color(0xFF1C1C20) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/theme/Spacing.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/theme/Spacing.kt index 7f0d8499a3..934d22c948 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/theme/Spacing.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/theme/Spacing.kt @@ -34,14 +34,13 @@ object Spacing { /** * Metrics for the grouped-settings surface. * - * Mirrors the Silo web client's settings pages: a lifted page ground, opaque - * grouped cards with a generous radius, a lettered section heading sitting - * *above* its card, and rows that are tall enough to carry a label over a - * description. Android row mechanics are kept — the control stays trailing - * rather than stacking under the label the way the web layout does. + * Mirrors the Apple apps' Settings (prairie-apple #546): a native inset-grouped + * list on a black ground, one-line rows with the current value trailing, a + * title-case section header above each group and an optional footer below it. + * Overview rows carry a graphite icon tile; sub-page rows are text only. * * Every value the settings tree needs lives here so the next visual pass has - * one file to edit instead of twenty-two literals spread across six files. + * one file to edit instead of literals spread across the settings files. */ object SettingsDimens { /** Page gutter for the settings list. */ @@ -53,71 +52,94 @@ object SettingsDimens { /** Trailing scroll runway so the last card clears the navigation bar. */ val pageBottomSpacer = Spacing.xxxl - /** Gap between two grouped cards, heading included. */ - val sectionGap = 22.dp + /** Gap between two groups, header and footer included. */ + val sectionGap = 20.dp - /** Grouped card corner radius. */ + /** Dialog corner radius. [PrairieConfirmDialog] shares it. */ val cardRadius = 20.dp - /** Inset of the section heading relative to the card's leading edge. */ - val headerStartInset = Spacing.xs + /** Inset-grouped list corner radius, Apple's large grouped-cell rounding. */ + val groupRadius = 26.dp - /** Gap between a section heading and its card. */ + /** Header and footer inset, aligned with the row text inside the group. */ + val headerStartInset = Spacing.lg + + /** Gap between a section header and its group. */ val headerBottomGap = Spacing.sm - /** Minimum row height. Every row honours this so a row with a description - * and a row without still read as the same list. */ - val rowMinHeight = 60.dp + /** Gap between a group and its footer. */ + val footerTopGap = Spacing.sm + + /** Minimum row height on a sub-page (Apple's 52pt grouped row). */ + val rowMinHeight = 52.dp + + /** Minimum height of an overview row with an icon tile (Apple's 60pt). */ + val overviewRowMinHeight = 60.dp /** Row content insets. */ val rowHorizontalPadding = Spacing.lg val rowVerticalPadding = Spacing.md - /** Gap between a row label and its description. */ + /** Gap between a row label and a description, where a row still has one. */ val rowLabelGap = Spacing.xxs - /** - * Gap between the row text block and its trailing control — chevron, - * switch, radio. Never text: a trailing *value* rides on the label's own - * line instead (see [rowLabelValueGap]), so a description can never end up - * a hairsbreadth from it. - */ - val rowTrailingGap = Spacing.md + /** Gap between the row text block and its trailing control. */ + val rowTrailingGap = Spacing.sm /** * Minimum gap between a row label and the trailing value sharing its line. - * Only binds when the label is long enough to reach the value; a short - * label leaves the value trailing-aligned with slack between them. + * Only binds when the label is long enough to reach the value. */ val rowLabelValueGap = Spacing.md /** * Cap on a trailing value's width, so a long one (a server URL) cannot * crush the label it sits beside. The value ellipsizes at this width; the - * label wraps. Sized so the longest authored value on this surface — - * "30 seconds before end" — still renders whole. + * label wraps. */ - val rowValueMaxWidth = 170.dp + val rowValueMaxWidth = 190.dp - /** Hairline between rows. */ + /** Overview icon tile: Apple's 30pt square with continuous 0.24 rounding. */ + val iconTileSize = 30.dp + val iconTileRadius = 7.dp + val iconGlyphSize = 17.dp + + /** Gap between the icon tile and the row label. */ + val iconTileGap = 14.dp + + /** Hairline between popup-menu rows; also the menus' border width. */ val dividerThickness = 1.dp + /** Hairline between grouped settings rows, Apple's single-pixel separator. */ + val separatorThickness = 0.5.dp + /** Divider inset, aligned to the row label. */ val dividerStartInset = Spacing.lg + /** Divider trailing inset. */ + val dividerEndInset = Spacing.lg + /** Disclosure chevron. */ - val chevronSize = 18.dp + val chevronSize = 20.dp - /** Account header avatar. */ + /** Menu-picker indicator, the search glyph, and a menu row's checkmark. */ + val menuIndicatorSize = 18.dp + + /** Leading slot that holds a menu row's checkmark, kept for every row. */ + val menuCheckWidth = 28.dp + + /** Account card avatar (Apple `ProfileAvatarView` at 56pt). */ val avatarSize = 56.dp - val avatarIconSize = 30.dp val avatarGap = 14.dp + /** Settings overview search field. */ + val searchFieldHeight = 44.dp + /** Inset for prose blocks that sit inside a card rather than on a row. */ val proseHorizontalPadding = Spacing.lg val proseVerticalPadding = Spacing.md - /** Divider opacity over [PrairieSurfaceContainer]. */ + /** Divider opacity over [PrairieSurfaceContainer], for popup menus. */ const val dividerAlpha = 0.55f /** Opacity applied to a disabled row's text and controls. */ @@ -145,7 +167,7 @@ object SettingsDimens { */ object MenuDimens { /** Popup corner radius. */ - val cornerRadius = 16.dp + val cornerRadius = 20.dp /** Menu row height. Clears the 48dp touch-target minimum exactly. */ val rowMinHeight = 48.dp @@ -158,7 +180,7 @@ object MenuDimens { * otherwise size down to a sliver; Material's own menus carry a similar * minimum. */ - val minWidth = 184.dp + val minWidth = 220.dp /** Outline separating the popup from whatever artwork sits behind it. */ val borderThickness = SettingsDimens.dividerThickness @@ -192,49 +214,68 @@ fun Modifier.siloRowTopDivider(show: Boolean): Modifier = } /** - * Type ramp for the grouped-settings surface. - * - * The M3 ramp in [PrairieTypography] mirrors the iOS point sizes and has no slot - * for a row description, so the settings rows previously had a single 16sp - * label and nothing else. These four styles are the hierarchy the web client - * uses: a lettered caps heading, a medium-weight label, a muted description, - * and a smaller trailing value that reads as a picker's current choice rather - * than as a second label. + * Type ramp for the grouped-settings surface: Apple's grouped list at Android + * sizes. Row labels and values share one regular-weight size, the value in the + * secondary colour; headers are title case and semibold; footers are small. */ object SettingsTextStyles { + /** The large "Settings" title at the top of the overview. */ + val largeTitle = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 34.sp, + lineHeight = 41.sp, + ) + val sectionHeader = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.SemiBold, - fontSize = 13.sp, - lineHeight = 16.sp, - letterSpacing = 0.9.sp, + fontSize = 16.sp, + lineHeight = 21.sp, ) val rowLabel = TextStyle( fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 14.5.sp, - lineHeight = 19.sp, + fontWeight = FontWeight.Normal, + fontSize = 17.sp, + lineHeight = 22.sp, ) + /** Section footers and the few rows that still carry a description. */ val rowDescription = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, - fontSize = 12.5.sp, + fontSize = 13.sp, lineHeight = 17.sp, ) val rowValue = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, - fontSize = 13.5.sp, - lineHeight = 18.sp, + fontSize = 17.sp, + lineHeight = 22.sp, ) val accountName = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.SemiBold, - fontSize = 19.sp, - lineHeight = 24.sp, + fontSize = 20.sp, + lineHeight = 25.sp, + ) + + /** The account card's "Admin" capsule. */ + val badge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, + lineHeight = 16.sp, + ) + + /** Popup menu rows (`PrairieMenuItem`), kept at the menus' own size. */ + val menuLabel = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 14.5.sp, + lineHeight = 19.sp, ) } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/theme/Type.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/theme/Type.kt index 8663047bf1..0502345881 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/theme/Type.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/theme/Type.kt @@ -2,13 +2,27 @@ package org.prairieserver.prairie.android.ui.theme import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp +import org.prairieserver.prairie.android.R // Mirrors iosApp/iosApp/Theme/Typography.swift — system sans (Roboto on Android, the // closest analog to SF), no custom display font, exact iOS pt sizes mapped to sp. +/** + * Inter, the face the TV app already uses for all of its type. On the phone + * it is reserved for the video player's chrome, where tabular figures keep + * timecodes from jittering and the controls should read the same as on TV. + */ +val PrairieInterFamily = FontFamily( + Font(R.font.inter_regular, FontWeight.Normal), + Font(R.font.inter_medium, FontWeight.Medium), + Font(R.font.inter_semibold, FontWeight.SemiBold), + Font(R.font.inter_bold, FontWeight.Bold), +) + val PrairieHeroTitle = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Bold, diff --git a/androidApp/src/androidMain/res/font/inter_bold.otf b/androidApp/src/androidMain/res/font/inter_bold.otf new file mode 100644 index 0000000000..07cd5f0810 Binary files /dev/null and b/androidApp/src/androidMain/res/font/inter_bold.otf differ diff --git a/androidApp/src/androidMain/res/font/inter_medium.otf b/androidApp/src/androidMain/res/font/inter_medium.otf new file mode 100644 index 0000000000..8108180e92 Binary files /dev/null and b/androidApp/src/androidMain/res/font/inter_medium.otf differ diff --git a/androidApp/src/androidMain/res/font/inter_regular.otf b/androidApp/src/androidMain/res/font/inter_regular.otf new file mode 100644 index 0000000000..13c3ec4857 Binary files /dev/null and b/androidApp/src/androidMain/res/font/inter_regular.otf differ diff --git a/androidApp/src/androidMain/res/font/inter_semibold.otf b/androidApp/src/androidMain/res/font/inter_semibold.otf new file mode 100644 index 0000000000..b433abba59 Binary files /dev/null and b/androidApp/src/androidMain/res/font/inter_semibold.otf differ diff --git a/androidApp/src/androidMain/res/values/strings.xml b/androidApp/src/androidMain/res/values/strings.xml index bc058d4978..00ae73f2ba 100644 --- a/androidApp/src/androidMain/res/values/strings.xml +++ b/androidApp/src/androidMain/res/values/strings.xml @@ -40,4 +40,103 @@ Coming soon + + + Sign in a TV + Sign in a TV + Enter the code on your TV\'s sign-in screen. + Code on your TV + Server + Your TV shows the server name it\'s signing in to. + Continue + Try %1$s + Sign in %1$s? + Sign in this TV? + Check that your TV shows + You\'ll sign it in to %1$s as %2$s. + You\'ll sign it in to %1$s with your account. + %1$s (%2$s) + Not you? Switch account + Switch account? + You\'ll be signed out of %1$s on this device, then asked which account to sign in with. The TV\'s code stays ready to approve. + this server + Sign out and switch + Not you? Sign out + Sign out? + You\'ll be signed out of %1$s on this device. Sign in with the account the TV should use, then approve it. The TV\'s code stays ready to approve. + Sign out + Couldn\'t confirm the account on %1$s. + Try again + Anyone using this TV can pick from your profiles. Profiles with a PIN stay locked. + Only approve a TV that\'s in front of you right now. + Requested from %1$s + %1$s · requested %2$s + Requested %1$s + just now + Sign in TV + Not now + Done. Your TV is signing in. + Your TV is signed in. + The TV stopped this sign-in. + This code expired before the TV signed in. + This sign-in was declined. + Approved. Check your TV to see that it signed in. + Sign-in declined. The TV was not signed in. + Done + Sign in + Enter the code shown on your TV. + Sign in before approving this TV. + We couldn\'t find that code. Check the code on your TV. + We couldn\'t find that code on %1$s. Check the code on your TV, or try another server. + This code expired. Your TV is showing a new one; scan it again. + That TV is already signed in. + This sign-in was declined. + The TV stopped this sign-in. Check the code on your TV. + Network error. Check your connection. + The sign-in provider can\'t be reached right now, so this device couldn\'t confirm your account. You\'re still signed in. Try again in a moment. + Couldn\'t sign in the TV. Try again. + Android TV + Apple TV + + + Set up %1$s + Sign %1$s in to your servers from this phone. + Set up + Sign in %1$s to %2$s? + + A TV nearby named “%1$s” says it\'s on its sign-in screen. You\'ll check its code before approving. + Sign in + Not now + Check that %1$s shows + Yes, this matches + Doesn\'t match + Signing in… + Continue on %1$s — allow this sign-in. + %1$s is signed in + Signed in to %1$s. + Sign-in didn\'t finish + Older TV apps show %1$s instead. + Nearby TV + Set up %1$s + TV setup complete + %1$s is ready. + Setup didn\'t finish + Setting up… + Continue on %1$s — allow this phone to set it up. + Connecting… + Waiting for %1$s to show its code. + Approving %1$s on %2$s. + %1$s signed in. + Please wait. + Choose servers + Sign %1$s in to… + Continue + Cancel + Close + Done + Try again + Skip Recap + Skip Credits diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/auth/InMemoryPendingNativeSignInStore.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/auth/InMemoryPendingNativeSignInStore.kt new file mode 100644 index 0000000000..edcb1be597 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/auth/InMemoryPendingNativeSignInStore.kt @@ -0,0 +1,17 @@ +package org.prairieserver.prairie.android.auth + +/** A [PendingNativeSignInStore] for tests; the app keeps the flow in encrypted preferences. */ +class InMemoryPendingNativeSignInStore : PendingNativeSignInStore { + private var pending: PendingNativeSignIn? = null + override fun load(): PendingNativeSignIn? = pending + override fun save(pending: PendingNativeSignIn) { this.pending = pending } + override fun clear() { pending = null } +} + +/** An [AccountChoiceStore] for tests; the app keeps the requests in encrypted preferences. */ +class InMemoryAccountChoiceStore : AccountChoiceStore { + private val requested = java.util.concurrent.ConcurrentHashMap.newKeySet() + override fun request(serverEntryId: String) { requested += serverEntryId } + override fun isRequested(serverEntryId: String): Boolean = serverEntryId in requested + override fun clear(serverEntryId: String) { requested -= serverEntryId } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/auth/NativeSignInCompleterTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/auth/NativeSignInCompleterTest.kt new file mode 100644 index 0000000000..a57da5eb79 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/auth/NativeSignInCompleterTest.kt @@ -0,0 +1,251 @@ +package org.prairieserver.prairie.android.auth + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respondError +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.runTest +import org.prairieserver.prairie.model.auth.AccountIdentities +import org.prairieserver.prairie.model.auth.AccountIdentity +import org.prairieserver.prairie.model.auth.AccountIdentityLinkTicket +import org.prairieserver.prairie.model.auth.ExternalSignInCapabilities +import org.prairieserver.prairie.model.auth.LoginResponse +import org.prairieserver.prairie.model.auth.OAuthHandshakeCapabilities +import org.prairieserver.prairie.model.auth.SignInProviders +import org.prairieserver.prairie.model.auth.User +import org.prairieserver.prairie.model.server.ServerEntry +import org.prairieserver.prairie.network.AccountSessionExpectation +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.ServerRegistry +import org.prairieserver.prairie.network.TokenManager +import org.prairieserver.prairie.network.TokenManagerImpl +import org.prairieserver.prairie.network.api.AuthApi +import org.prairieserver.prairie.network.api.ExternalSignInApi +import org.prairieserver.prairie.network.apiv2.ApiV2Gate +import org.prairieserver.prairie.repository.AuthRepository +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * Where [RepositoryNativeSignInCompleter] sends a native flow's code: only to + * the saved server's base URL, and only while it is still at the origin the + * flow started on. The saved server keeps its address and id. + */ +class NativeSignInCompleterTest { + private val http = HttpClient(MockEngine { respondError(HttpStatusCode.ServiceUnavailable) }) + + @AfterTest fun tearDown() = http.close() + + private val user = User("1", "alice", "a@example.test", "user") + + private inner class Fixture(saved: ServerEntry) { + val registry = ListServerRegistry(saved) + val tokens = RecordingTokens(registry) + val api = RecordingApi(user) + val completer = RepositoryNativeSignInCompleter( + authRepository = AuthRepository(AuthApi(http, ApiV2Gate.Unrestricted), tokens, serverRegistry = registry), + api = api, + tokenManager = tokens, + ) + + suspend fun start() { + tokens.setServerUrl(registry.activeEntry.value!!.url) + tokens.activeServerId = registry.activeServerId.value + } + } + + private fun pending(startOrigin: String, purpose: NativeSignInPurpose = NativeSignInPurpose.SignIn, entryId: String) = + pending(purpose).copy(serverEntryId = entryId, startOrigin = startOrigin, verifiedServerId = "srv-1") + + @Test + fun aSignInRedeemsAtTheSavedBase() = runTest { + val f = Fixture(ServerEntry(id = "pub", url = "https://prairie.example.test/prairie", verifiedServerId = "srv-1")) + f.start() + val signedIn = f.completer.signIn(pending("https://prairie.example.test", entryId = "pub"), "code-1") + assertEquals(user, assertIs>(signedIn).data) + assertEquals(listOf("https://prairie.example.test/prairie"), f.api.redeemedAt) + assertEquals(listOf("pub"), f.tokens.installedOn) + } + + /** A server saved by its LAN address signs in there and stays there. */ + @Test + fun aLanSavedServerSignsInAndStaysOnItsLanAddress() = runTest { + val lan = ServerEntry( + id = "lan", url = "http://192.168.1.10:8096", fetchedName = "Home", userOverrideName = "Den", + verifiedServerId = "srv-1", + ) + val f = Fixture(lan) + f.start() + assertIs>(f.completer.signIn(pending("http://192.168.1.10:8096", entryId = "lan"), "code-1")) + assertEquals(listOf("http://192.168.1.10:8096"), f.api.redeemedAt) + assertEquals(listOf(lan), f.registry.entries.value, "the saved server is unchanged") + assertEquals(listOf("lan"), f.tokens.installedOn) + } + + /** + * The saved address changed while the browser was open: the code isn't + * redeemed at the new address, which isn't where the flow started. + */ + @Test + fun aSavedServerOnAnotherOriginThanTheFlowRedeemsNothing() = runTest { + val f = Fixture(ServerEntry(id = "pub", url = "https://elsewhere.example.test", verifiedServerId = "srv-1")) + f.start() + val refused = assertIs(f.completer.signIn(pending("https://prairie.example.test", entryId = "pub"), "c")) + assertEquals(NativeSignInMessages.ACCOUNT_CHANGED, NativeSignInCoordinator.completionReason(refused)) + assertTrue(f.api.redeemedAt.isEmpty()) + assertTrue(f.tokens.installedOn.isEmpty()) + } + + /** + * The flow began signed out, then a password sign-in landed while the + * browser was open: the provider's code no longer speaks for this + * session and replaces nothing. + */ + @Test + fun aSignInSinceTheFlowBeganRedeemsNothing() = runTest { + val f = Fixture(ServerEntry(id = "pub", url = "https://prairie.example.test", verifiedServerId = "srv-1")) + f.start() + f.tokens.loginSessionIds["pub"] = "password-login" + val refused = assertIs(f.completer.signIn(pending("https://prairie.example.test", entryId = "pub"), "c")) + assertEquals(NativeSignInMessages.ACCOUNT_CHANGED, NativeSignInCoordinator.completionReason(refused)) + assertTrue(f.api.redeemedAt.isEmpty()) + assertTrue(f.tokens.installedOn.isEmpty()) + } + + @Test + fun aFlowForAnotherSavedServerRedeemsNothing() = runTest { + val f = Fixture(ServerEntry(id = "pub", url = "https://prairie.example.test", verifiedServerId = "srv-1")) + f.start() + assertIs(f.completer.signIn(pending("https://prairie.example.test", entryId = "other"), "c")) + assertTrue(f.api.redeemedAt.isEmpty()) + } + + /** Linking from a LAN-saved server confirms the code at the LAN address, with the account's session. */ + @Test + fun linkingConfirmsAtTheSavedBase() = runTest { + val lan = ServerEntry(id = "lan", url = "http://192.168.1.10:8096", verifiedServerId = "srv-1") + val f = Fixture(lan) + f.start() + f.tokens.scope = AuthScopeSnapshot("lan", null, "http://192.168.1.10:8096", null, identityGeneration = 3, credentialEpoch = 4) + f.tokens.loginSessionIds["lan"] = "login-1" + val linking = pending("http://192.168.1.10:8096", NativeSignInPurpose.Link, entryId = "lan") + .copy(loginSessionId = "login-1") + assertIs>(f.completer.link(linking, "code-1")) + assertEquals(listOf("http://192.168.1.10:8096"), f.api.linkedAt) + assertEquals(listOf(lan), f.registry.entries.value) + + // Another login, no login, or a flow started on another origin confirms nothing. + assertIs(f.completer.link(linking.copy(loginSessionId = "login-2"), "code-1")) + assertIs(f.completer.link(linking.copy(loginSessionId = null), "code-1")) + assertIs(f.completer.link(linking.copy(startOrigin = "https://prairie.example.test"), "code-1")) + assertEquals(1, f.api.linkedAt.size) + } + + /** Records where codes were redeemed. */ + private class RecordingApi(private val user: User) : ExternalSignInApi { + val redeemedAt = mutableListOf() + val linkedAt = mutableListOf() + override suspend fun completeOAuthLogin(serverUrl: String, code: String, codeVerifier: String): ApiResult { + redeemedAt += serverUrl + return ApiResult.Success(LoginResponse("access", "refresh", 3600, user)) + } + override suspend fun completeLink(scope: AuthScopeSnapshot, code: String, codeVerifier: String): ApiResult { + linkedAt += scope.serverUrl + return ApiResult.Success(Unit) + } + override suspend fun listProviders(serverUrl: String): ApiResult = TODO() + override suspend fun oauthCapabilities(serverUrl: String): ApiResult = TODO() + override suspend fun externalSignInCapabilities(scope: AuthScopeSnapshot): ApiResult = TODO() + override suspend fun listIdentities(scope: AuthScopeSnapshot): ApiResult = TODO() + override suspend fun deleteIdentity(scope: AuthScopeSnapshot, identityId: String): ApiResult = TODO() + override suspend fun createLinkTicket(scope: AuthScopeSnapshot, installationId: String, password: String): + ApiResult = TODO() + override suspend fun linkWithCredentials( + scope: AuthScopeSnapshot, + installationId: String, + password: String, + username: String, + directoryPassword: String, + ): ApiResult = TODO() + override suspend fun signInWithNetworkIdentity(serverUrl: String, signInPath: String): ApiResult = TODO() + override suspend fun linkWithNetwork(scope: AuthScopeSnapshot, installationId: String, password: String): + ApiResult = TODO() + } + + /** + * [TokenManagerImpl], recording which saved server each sign-in installed + * its session on. Like the app's manager, its session expectation names + * the active saved server, and its scope carries the address that server + * is saved at now. + */ + private class RecordingTokens( + private val registry: ServerRegistry, + private val inner: TokenManagerImpl = TokenManagerImpl(), + ) : TokenManager by inner { + val installedOn = mutableListOf() + var scope: AuthScopeSnapshot? = null + var activeServerId: String? = null + val loginSessionIds = mutableMapOf() + + override suspend fun loginSessionId(serverId: String): String? = loginSessionIds[serverId] + + override suspend fun captureAccountSessionExpectation(): AccountSessionExpectation? = + inner.captureAccountSessionExpectation()?.copy(serverId = activeServerId) + + override suspend fun snapshotCurrentScope(): AuthScopeSnapshot? = scope?.let { captured -> + registry.entries.value.firstOrNull { it.id == captured.serverId }?.let { captured.copy(serverUrl = it.url) } + } + + override suspend fun replaceAccountSession( + serverId: String?, + serverUrl: String?, + accessToken: String, + refreshToken: String, + expiresIn: Long, + profileId: String?, + profileToken: String?, + expectedIdentity: AccountSessionExpectation?, + ) { + installedOn += serverId + inner.replaceAccountSession(serverId, serverUrl, accessToken, refreshToken, expiresIn, profileId, profileToken, expectedIdentity) + } + } + + /** Saved servers keyed by address, like the app's registry. */ + private class ListServerRegistry(first: ServerEntry) : ServerRegistry { + override val entries = MutableStateFlow(listOf(first)) + override val activeServerId = MutableStateFlow(first.id) + private val active = MutableStateFlow(first) + override val activeEntry: StateFlow = active + + private fun edit(id: String, change: (ServerEntry) -> ServerEntry) { + entries.value = entries.value.map { if (it.id == id) change(it) else it } + active.value = entries.value.firstOrNull { it.id == activeServerId.value } + } + + override suspend fun addOrUpdate(url: String, fetchedName: String?): String { + val normalized = url.trimEnd('/') + val id = "id:$normalized" + if (entries.value.none { it.id == id }) entries.value = entries.value + ServerEntry(id = id, url = normalized, fetchedName = fetchedName) + return id + } + override suspend fun rename(serverId: String, userOverrideName: String?) = edit(serverId) { it.copy(userOverrideName = userOverrideName) } + override suspend fun setFetchedName(serverId: String, fetchedName: String?) = edit(serverId) { it.copy(fetchedName = fetchedName) } + override suspend fun setVerifiedServerId(serverId: String, verifiedServerId: String?) = + edit(serverId) { it.copy(verifiedServerId = verifiedServerId) } + override suspend fun setProfileId(serverId: String, profileId: String?) = Unit + override suspend fun remove(serverId: String) = Unit + override suspend fun signOut(serverId: String) = Unit + override suspend fun switchTo(serverId: String) { + activeServerId.value = serverId + active.value = entries.value.firstOrNull { it.id == serverId } + } + override suspend fun touchActive() = Unit + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/auth/NativeSignInLinkTransportTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/auth/NativeSignInLinkTransportTest.kt new file mode 100644 index 0000000000..a0bde657c0 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/auth/NativeSignInLinkTransportTest.kt @@ -0,0 +1,199 @@ +package org.prairieserver.prairie.android.auth + +import android.app.Application +import android.content.Context +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.test.runTest +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.prairieserver.prairie.network.AndroidServerRegistry +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.EncryptedTokenManagerImpl +import org.prairieserver.prairie.network.PrairieAuthPlugin +import org.prairieserver.prairie.network.PrairieJson +import org.prairieserver.prairie.network.api.AuthApi +import org.prairieserver.prairie.network.api.DefaultExternalSignInApi +import org.prairieserver.prairie.network.apiv2.ApiV2Gate +import org.prairieserver.prairie.repository.AuthRepository +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * A server saved at its LAN address signs in and links there, through the + * app's own registry, token manager, [PrairieAuthPlugin] and API: the code and + * the account's bearer go to the LAN address, and the saved server keeps its + * address and id (no move, no second entry). + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [28]) +class NativeSignInLinkTransportTest { + private class Sent(val url: String, val authorization: String?) + + private val sent = mutableListOf() + private var linkStatus = HttpStatusCode.NoContent + private val http = HttpClient( + MockEngine { request -> + sent += Sent(request.url.toString(), request.headers[HttpHeaders.Authorization]) + when { + request.url.encodedPath.endsWith("/auth/oauth/complete") -> respond( + """{"access_token":"sso-access","refresh_token":"sso-refresh","expires_in":3600,"next":"/",""" + + """"user":{"id":"1","username":"alice","email":"a@example.test","role":"user"}}""", + HttpStatusCode.OK, + headersOf(HttpHeaders.ContentType, "application/json"), + ) + linkStatus == HttpStatusCode.NoContent -> respond("", linkStatus) + else -> respond( + """{"type":"https://prairieserver.org/docs/api/v2/problems/invalid_grant","title":"Bad request","status":400}""", + linkStatus, + headersOf(HttpHeaders.ContentType, "application/problem+json"), + ) + } + }, + ) + + @AfterTest fun tearDown() = http.close() + + private inner class Fixture { + val prefs = RuntimeEnvironment.getApplication() + .getSharedPreferences("native-sign-in-link-transport", Context.MODE_PRIVATE) + .also { it.edit().clear().commit() } + var registry = AndroidServerRegistry(prefs) + lateinit var tokens: EncryptedTokenManagerImpl + lateinit var lanId: String + lateinit var completer: RepositoryNativeSignInCompleter + + suspend fun savedAtLan(signedIn: Boolean): AuthScopeSnapshot? { + lanId = registry.addOrUpdate(LAN) + registry.setVerifiedServerId(lanId, "srv-1") + registry.switchTo(lanId) + build() + tokens.switchActiveServer(lanId) + if (signedIn) tokens.saveTokens("lan-access", "lan-refresh", 3600) + return tokens.snapshotCurrentScope() + } + + /** A new process: a new registry and token manager over the same saved state. */ + fun recreate() { + registry = AndroidServerRegistry(prefs) + build() + } + + private fun build() { + tokens = EncryptedTokenManagerImpl(prefs, registry) + val client = http.config { + install(ContentNegotiation) { json(PrairieJson) } + install(PrairieAuthPlugin) { tokenManager = tokens } + } + completer = RepositoryNativeSignInCompleter( + authRepository = AuthRepository(AuthApi(client, ApiV2Gate.Unrestricted), tokens, serverRegistry = registry), + api = DefaultExternalSignInApi(client, ApiV2Gate.Unrestricted), + tokenManager = tokens, + ) + } + + fun flow(purpose: NativeSignInPurpose, loginSessionId: String? = null) = PendingNativeSignIn( + purpose = purpose, + serverEntryId = lanId, + verifiedServerId = "srv-1", + startOrigin = LAN, + appState = "state", + codeVerifier = "verifier", + providerName = "Keycloak", + startedAtEpochMs = 0L, + loginSessionId = loginSessionId, + ) + } + + @Test + fun aLanSavedServerSignsInAndStaysOnItsLanAddress() = runTest { + val f = Fixture() + f.savedAtLan(signedIn = false) + + assertIs>(f.completer.signIn(f.flow(NativeSignInPurpose.SignIn), "code-1")) + + assertEquals("$LAN/api/v2/auth/oauth/complete", sent.single().url) + val saved = f.registry.entries.value.single() + assertEquals(f.lanId, saved.id) + assertEquals(LAN, saved.url, "the saved address never changes") + assertEquals(f.lanId, f.registry.activeServerId.value) + assertEquals("sso-access", f.tokens.getAccessToken()) + } + + @Test + fun linkingConfirmsAtTheLanAddressWithTheAccountsBearer() = runTest { + val f = Fixture() + requireNotNull(f.savedAtLan(signedIn = true)) + + assertIs>(f.completer.link(f.flow(NativeSignInPurpose.Link, f.tokens.loginSessionId(f.lanId)), "code-1")) + + val confirmation = sent.single() + assertEquals("$LAN/api/v2/account/identities/link-complete", confirmation.url) + assertEquals("Bearer lan-access", confirmation.authorization) + assertEquals(LAN, f.registry.entries.value.single().url) + assertEquals("lan-access", f.tokens.getAccessToken(), "the session stays signed in") + } + + @Test + fun aRefusedLinkChangesNothing() = runTest { + val f = Fixture() + requireNotNull(f.savedAtLan(signedIn = true)) + linkStatus = HttpStatusCode.BadRequest + + val refused = assertIs( + f.completer.link(f.flow(NativeSignInPurpose.Link, f.tokens.loginSessionId(f.lanId)), "code-1"), + ) + + assertEquals("state_invalid", NativeSignInCoordinator.completionReason(refused)) + assertEquals(LAN, f.registry.entries.value.single().url) + assertEquals("lan-access", f.tokens.getAccessToken()) + } + + /** + * The system killed the app while the browser was open: a new token + * manager over the same saved credentials still confirms the link, since + * the login it began with is still signed in. + */ + @Test + fun aLinkFinishesAfterTheProcessIsRecreated() = runTest { + val f = Fixture() + requireNotNull(f.savedAtLan(signedIn = true)) + val linking = f.flow(NativeSignInPurpose.Link, requireNotNull(f.tokens.loginSessionId(f.lanId))) + + f.recreate() + + assertIs>(f.completer.link(linking, "code-1")) + assertEquals("$LAN/api/v2/account/identities/link-complete", sent.single().url) + } + + /** A password sign-in while the browser was open supersedes the provider flow. */ + @Test + fun aSignInSinceTheFlowBeganInstallsNothing() = runTest { + val f = Fixture() + f.savedAtLan(signedIn = false) + val flow = f.flow(NativeSignInPurpose.SignIn, f.tokens.loginSessionId(f.lanId)) + f.tokens.replaceAccountSession(serverId = f.lanId, accessToken = "pw-access", refreshToken = "pw-refresh", expiresIn = 3600) + + val refused = assertIs(f.completer.signIn(flow, "code-1")) + + assertEquals(NativeSignInMessages.ACCOUNT_CHANGED, NativeSignInCoordinator.completionReason(refused)) + assertTrue(sent.isEmpty()) + assertEquals("pw-access", f.tokens.getAccessToken()) + } + + private companion object { + const val LAN = "http://192.168.1.10:8096" + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/auth/NativeSignInPersistenceTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/auth/NativeSignInPersistenceTest.kt new file mode 100644 index 0000000000..0e45362f75 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/auth/NativeSignInPersistenceTest.kt @@ -0,0 +1,112 @@ +package org.prairieserver.prairie.android.auth + +import android.content.Context +import android.content.SharedPreferences +import androidx.test.core.app.ApplicationProvider +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The on-disk native sign-in state that must survive process death: the + * system can kill the app while the browser is open, and a fresh store over + * the same preferences must still finish the flow. [SharedPrefsPendingNativeSignInStore.load] + * drops anything it can't decode, so a serialization regression would lose + * the callback silently; these tests catch that. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = android.app.Application::class) +class NativeSignInPersistenceTest { + private lateinit var prefs: SharedPreferences + + @BeforeTest + fun setUp() { + prefs = ApplicationProvider.getApplicationContext() + .getSharedPreferences("native_sign_in_test", Context.MODE_PRIVATE) + prefs.edit().clear().commit() + } + + @Test + fun aPendingLinkSurvivesANewStoreWithEveryField() { + SharedPrefsPendingNativeSignInStore(prefs).save(LINK) + + // A new process builds a new store over the same preferences. + assertEquals(LINK, SharedPrefsPendingNativeSignInStore(prefs).load()) + } + + @Test + fun aPendingSignInSurvivesWithoutTheLinkFields() { + val signIn = LINK.copy(purpose = NativeSignInPurpose.SignIn, loginSessionId = null) + SharedPrefsPendingNativeSignInStore(prefs).save(signIn) + + assertEquals(signIn, SharedPrefsPendingNativeSignInStore(prefs).load()) + } + + /** A flow saved by the current shape, as it sits on disk, still decodes after an update. */ + @Test + fun theStoredShapeDecodes() { + prefs.edit().putString(PENDING_KEY, STORED_LINK_JSON).commit() + + assertEquals(LINK, SharedPrefsPendingNativeSignInStore(prefs).load()) + } + + @Test + fun clearRemovesThePendingFlow() { + val store = SharedPrefsPendingNativeSignInStore(prefs) + store.save(LINK) + store.clear() + + assertNull(store.load()) + assertNull(SharedPrefsPendingNativeSignInStore(prefs).load()) + assertFalse(prefs.contains(PENDING_KEY)) + } + + @Test + fun accountChoicesSurviveANewStoreAndClearOneServerAtATime() { + val store = SharedPrefsAccountChoiceStore(prefs) + store.request("entry-1") + store.request("entry-2") + + val reloaded = SharedPrefsAccountChoiceStore(prefs) + assertTrue(reloaded.isRequested("entry-1")) + assertTrue(reloaded.isRequested("entry-2")) + assertFalse(reloaded.isRequested("entry-3")) + + reloaded.clear("entry-1") + assertFalse(SharedPrefsAccountChoiceStore(prefs).isRequested("entry-1")) + assertTrue(SharedPrefsAccountChoiceStore(prefs).isRequested("entry-2")) + + reloaded.clear("entry-2") + assertFalse(SharedPrefsAccountChoiceStore(prefs).isRequested("entry-2")) + assertFalse(prefs.contains(ACCOUNT_CHOICE_KEY)) + } + + private companion object { + const val PENDING_KEY = "native_sign_in_pending" + const val ACCOUNT_CHOICE_KEY = "native_sign_in_account_choice" + + val LINK = PendingNativeSignIn( + purpose = NativeSignInPurpose.Link, + serverEntryId = "entry-1", + verifiedServerId = "server-1", + startOrigin = "https://prairie.test", + appState = "app-state", + codeVerifier = "code-verifier", + providerName = "Keycloak", + startedAtEpochMs = 1_759_300_000_000, + loginSessionId = "login-1", + ) + + /** [LINK] as the current serializer writes it. */ + const val STORED_LINK_JSON = + """{"purpose":"Link","serverEntryId":"entry-1","verifiedServerId":"server-1",""" + + """"startOrigin":"https://prairie.test","appState":"app-state","codeVerifier":"code-verifier",""" + + """"providerName":"Keycloak","startedAtEpochMs":1759300000000,"loginSessionId":"login-1"}""" + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/auth/NativeSignInTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/auth/NativeSignInTest.kt new file mode 100644 index 0000000000..5e3358a477 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/auth/NativeSignInTest.kt @@ -0,0 +1,569 @@ +package org.prairieserver.prairie.android.auth + +import java.io.File +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.runTest +import org.prairieserver.prairie.model.auth.User +import org.prairieserver.prairie.network.ApiResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class NativeSignInProtocolTest { + @Test + fun challengeIsRfc7636S256() { + // RFC 7636 Appendix B. + assertEquals( + "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + NativeSignInProtocol.challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"), + ) + } + + @Test + fun verifierAndStateAreFreshUnreservedTokens() { + val unreserved = Regex("^[A-Za-z0-9._~-]+$") + val verifiers = List(20) { NativeSignInProtocol.newCodeVerifier() } + verifiers.forEach { + assertEquals(43, it.length) + assertTrue(unreserved.matches(it), it) + } + assertEquals(verifiers.size, verifiers.toSet().size) + val state = NativeSignInProtocol.newAppState() + assertTrue(unreserved.matches(state) && state.length in 1..512) + assertEquals(43, NativeSignInProtocol.challenge(verifiers.first()).length) + } + + @Test + fun startUrlAddsThePkceAndStateParameters() { + val verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + assertEquals( + "https://prairie.example.test/api/v2/auth/oauth/5/native/start" + + "?code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&app_state=abc", + NativeSignInProtocol.startUrl("https://prairie.example.test/", "/api/v2/auth/oauth/5/native/start", "abc", verifier), + ) + val linking = NativeSignInProtocol.startUrl("https://h/prairie", "/api/v2/auth/oauth/5/native/start?x=1#frag", "s", verifier, linkTicket = "t k") + assertEquals( + "https://h/prairie/api/v2/auth/oauth/5/native/start?x=1&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + + "&code_challenge_method=S256&app_state=s&link_ticket=t+k", + linking, + ) + assertFalse(assertNotNull(linking).contains(verifier), "the verifier itself never leaves the app") + } + + /** The start opens on the saved base and nowhere else: a path that names another host opens nothing. */ + @Test + fun startUrlNeverLeavesTheSavedBase() { + assertNull(NativeSignInProtocol.startUrl("https://prairie.example.test", "//evil.example.test/x", "s", "v")) + assertNull(NativeSignInProtocol.startUrl("https://prairie.example.test", "https://evil.example.test/x", "s", "v")) + assertNull(NativeSignInProtocol.startUrl("not a url", "/api/v2/auth/oauth/5/native/start", "s", "v")) + assertEquals( + "http://192.168.1.10:8096", + NativeSignInProtocol.origin(NativeSignInProtocol.startUrl("http://192.168.1.10:8096", "/api/v2/auth/oauth/5/native/start", "s", "v")), + ) + } + + @Test + fun startUrlCarriesAPrompt() { + val url = assertNotNull( + NativeSignInProtocol.startUrl("https://h", "/start", "s", "v", prompt = NativeSignInProtocol.PROMPT_SELECT_ACCOUNT), + ) + assertTrue(url.contains("&prompt=select_account"), url) + assertFalse(assertNotNull(NativeSignInProtocol.startUrl("https://h", "/start", "s", "v")).contains("prompt=")) + } + + @Test + fun originsCompareSchemeHostAndEffectivePort() { + assertEquals("https://prairie.example.test", NativeSignInProtocol.origin("https://Prairie.Example.test:443/api/v2/x?y=1")) + assertEquals("http://10.0.0.2:8096", NativeSignInProtocol.origin("http://10.0.0.2:8096/")) + assertEquals("http://prairie.lan", NativeSignInProtocol.origin("http://prairie.lan:80")) + assertNotEquals(NativeSignInProtocol.origin("https://prairie.example.test"), NativeSignInProtocol.origin("http://prairie.example.test")) + assertNotEquals(NativeSignInProtocol.origin("https://prairie.example.test"), NativeSignInProtocol.origin("https://prairie.example.test:8443")) + listOf(null, "", "prairie.example.test", "ftp://prairie.example.test", "https://user@prairie.example.test", "https:///x", "ws://prairie.lan") + .forEach { assertNull(NativeSignInProtocol.origin(it), "$it") } + } + + /** + * The same canonical origin as the rest of the app, which accepts an + * underscore host that `java.net.URI` reads as having none. (Hosts a + * browser rewrites differ; see the next test.) + */ + @Test + fun originsAgreeWithTheAppsCanonicalOrigin() { + assertEquals("http://silo_server:8096", NativeSignInProtocol.origin("http://silo_server:8096/api/v2/auth/oauth/5/native/start")) + listOf("http://silo_server:8096", "https://[fd00::1]:8920/x", "https://Prairie.Example.test:443").forEach { + assertEquals(org.prairieserver.prairie.network.canonicalHttpOrigin(it), NativeSignInProtocol.origin(it), it) + } + } + + /** + * The server names in `iss` the `Host` the browser sent, which a browser + * writes in punycode and with a compressed IPv6 literal. A base saved in + * Unicode or with an uncompressed literal compares in that same form. + */ + @Test + fun originsWriteTheHostTheWayABrowserSendsIt() { + assertEquals("https://xn--bcher-kva.example", NativeSignInProtocol.origin("https://bücher.example/prairie")) + assertEquals(NativeSignInProtocol.origin("https://xn--bcher-kva.example"), NativeSignInProtocol.origin("https://BÜCHER.example")) + assertEquals("http://[fd00::1]:8096", NativeSignInProtocol.origin("http://[fd00:0:0:0:0:0:0:1]:8096")) + assertEquals("http://[fd00::1]:8096", NativeSignInProtocol.origin("http://[FD00:0000::0001]:8096/")) + assertEquals("https://[2001:db8::1:0:0:1]", NativeSignInProtocol.origin("https://[2001:db8:0:0:1:0:0:1]:443")) + assertEquals("http://[2001:db8:0:1:1:1:1:1]", NativeSignInProtocol.origin("http://[2001:db8:0:1:1:1:1:1]")) + assertEquals("http://[::]", NativeSignInProtocol.origin("http://[0:0:0:0:0:0:0:0]")) + assertTrue(NativeSignInCoordinator.issuedBy("https://xn--bcher-kva.example", NativeSignInProtocol.origin("https://bücher.example")!!)) + assertTrue(NativeSignInCoordinator.issuedBy("http://[fd00::1]:8096", NativeSignInProtocol.origin("http://[fd00:0:0:0:0:0:0:1]:8096")!!)) + } +} + +class NativeSignInCallbackTest { + @Test + fun parsesOnlyTheExactAppRedirect() { + val parsed = assertNotNull( + NativeSignInCallback.parse("org.prairieserver.prairie:/auth/callback?code=c%2B1&state=s&server=srv-1"), + ) + assertEquals("c+1", parsed.code) + assertEquals("s", parsed.state) + assertEquals("srv-1", parsed.server) + assertNull(parsed.error) + assertFalse(parsed.link) + assertNull(parsed.iss) + assertEquals( + "https://prairie.example.test", + assertNotNull(NativeSignInCallback.parse("org.prairieserver.prairie:/auth/callback?code=c&iss=https%3A%2F%2Fprairie.example.test")).iss, + ) + // A raw iss (some servers don't encode the colon and slashes) parses the same. + assertEquals( + "https://prairie.example.test:8443", + assertNotNull(NativeSignInCallback.parse("org.prairieserver.prairie:/auth/callback?code=c&iss=https://prairie.example.test:8443")).iss, + ) + + val error = assertNotNull(NativeSignInCallback.parse("org.prairieserver.prairie:/auth/callback?error=not_permitted&state=s&server=x")) + assertEquals("not_permitted", error.error) + assertNull(error.code) + assertTrue(assertNotNull(NativeSignInCallback.parse("org.prairieserver.prairie:/auth/callback?code=c&link=1")).link) + + listOf( + null, + "", + "org.prairieserver.prairie:/auth/other?code=c", + "org.prairieserver.prairie:/auth/callback/extra?code=c", + "org.prairieserver.prairie://auth/callback?code=c", + "org.prairieserver.prairie://evil.example/auth/callback?code=c", + "org.prairieserver.prairie:auth/callback?code=c", + "org.prairieserver.prairie:/auth/callback", + "prairie:/auth/callback?code=c", + "https://prairie.example.test/auth/callback?code=c", + ).forEach { assertNull(NativeSignInCallback.parse(it), "$it") } + } + + @Test + fun aRepeatedParameterKeepsTheFirstValue() { + val parsed = assertNotNull(NativeSignInCallback.parse("org.prairieserver.prairie:/auth/callback?state=a&state=b&code=c")) + assertEquals("a", parsed.state) + } + + @Test + fun secretsStayOutOfLogs() { + val parsed = assertNotNull(NativeSignInCallback.parse("org.prairieserver.prairie:/auth/callback?code=SECRET&state=STATE&server=x")) + assertFalse(parsed.toString().contains("SECRET")) + assertFalse(parsed.toString().contains("STATE")) + val pending = pending(NativeSignInPurpose.SignIn) + assertFalse(pending.toString().contains(pending.codeVerifier)) + assertFalse(pending.toString().contains(pending.appState)) + } +} + +class NativeSignInManifestTest { + @Test + fun phoneReceivesTheAppRedirectWithoutAutoVerify() { + val manifest = File("src/androidMain/AndroidManifest.xml").readText() + val activity = manifest.substringAfter("""android:name=".auth.NativeSignInCallbackActivity"""").substringBefore("") + assertTrue(activity.contains("""android:exported="true"""")) + assertTrue(activity.contains("""""")) + assertTrue(activity.contains("android.intent.category.BROWSABLE")) + assertFalse(activity.contains("autoVerify")) + } + + @Test + fun tvNeverClaimsTheAppRedirect() { + val tv = File("../androidTvApp/src/androidMain/AndroidManifest.xml").readText() + assertFalse(tv.contains("org.prairieserver.prairie\""), "TVs never run a provider sign-in") + } +} + +class NativeSignInCoordinatorTest { + private var now = 1_000_000L + + private class FakeCompleter : NativeSignInCompleter { + val signIns = mutableListOf>() + val links = mutableListOf>() + var signInResult: ApiResult = ApiResult.Success(User("1", "alice", "a@example.test", "user")) + var linkResult: ApiResult = ApiResult.Success(Unit) + val startOrigins = mutableListOf() + override suspend fun signIn(pending: PendingNativeSignIn, code: String): ApiResult { + signIns += code to pending.codeVerifier + startOrigins += pending.startOrigin + return signInResult + } + override suspend fun link(pending: PendingNativeSignIn, code: String): ApiResult { + links += code to pending.codeVerifier + return linkResult + } + } + + private fun coordinator( + store: PendingNativeSignInStore, + completer: NativeSignInCompleter, + scope: CoroutineScope, + ) = NativeSignInCoordinator(store, completer, scope, InMemoryAccountChoiceStore(), clock = { now }) + + private fun callback( + state: String?, + code: String? = "code-1", + server: String? = "srv-1", + error: String? = null, + link: Boolean = false, + iss: String? = "https://prairie.example.test", + ) = NativeSignInCallback(code = code, state = state, server = server, error = error, link = link, iss = iss) + + private suspend fun NativeSignInCoordinator.start( + purpose: NativeSignInPurpose = NativeSignInPurpose.SignIn, + prompt: String? = null, + ): String { + val url = assertIs( + begin( + purpose = purpose, + serverEntryId = "entry-1", + serverUrl = "https://prairie.example.test/", + verifiedServerId = "srv-1", + providerName = "Keycloak", + nativeStartPath = START_PATH, + linkTicket = if (purpose == NativeSignInPurpose.Link) "ticket" else null, + prompt = prompt, + ), + ).url + assertTrue(url.contains("code_challenge_method=S256")) + lastStartUrl = url + return url.substringAfter("app_state=").substringBefore('&') + } + + private var lastStartUrl = "" + + /** + * The relay: a hostile saved server's native start (on its own origin) + * redirects the browser to the real server's native start with this app's + * challenge and `app_state`. The real server's redirect carries a valid + * code and `state`, and `server` matches because the hostile server + * echoed the real id, but `iss` names where the real server's native + * start arrived, its own origin, not the saved one. Nothing is redeemed + * anywhere. + */ + @Test + fun aRelayedSignInComesBackWithAnotherIssuerAndIsDiscarded() = runTest { + val store = InMemoryPendingNativeSignInStore() + val completer = FakeCompleter() + val coordinator = coordinator(store, completer, this) + val start = assertIs( + coordinator.begin( + purpose = NativeSignInPurpose.SignIn, + serverEntryId = "hostile-entry", + serverUrl = "https://hostile.example.test", + verifiedServerId = "srv-real", + providerName = "Keycloak", + nativeStartPath = START_PATH, + ), + ) + val state = start.url.substringAfter("app_state=").substringBefore('&') + assertTrue(coordinator.finish(callback(state = state, server = "srv-real", iss = "https://real.example.test"))) + val failed = assertIs(coordinator.result.value) + assertEquals(NativeSignInMessages.ISSUER_MISMATCH, failed.reason) + assertEquals("This sign-in came back from a different server. Nothing was signed in.", failed.message) + assertTrue(completer.signIns.isEmpty() && completer.links.isEmpty()) + assertNull(store.load(), "the flow is spent") + } + + /** A sign-out while the code is being redeemed: the superseded flow reports nothing, not even Finishing. */ + @Test + fun aFlowDiscardedDuringRedemptionPublishesNothing() = runTest { + lateinit var coordinator: NativeSignInCoordinator + val completer = object : NativeSignInCompleter { + override suspend fun signIn(pending: PendingNativeSignIn, code: String): ApiResult { + coordinator.discardPending() + return ApiResult.Error(0, "identity_changed", "The account or server changed.") + } + override suspend fun link(pending: PendingNativeSignIn, code: String): ApiResult = ApiResult.Success(Unit) + } + coordinator = coordinator(InMemoryPendingNativeSignInStore(), completer, this) + + assertTrue(coordinator.finish(callback(state = coordinator.start()))) + + assertNull(coordinator.result.value) + } + + @Test + fun aRedirectWithoutAnIssuerIsDiscarded() = runTest { + val completer = FakeCompleter() + val coordinator = coordinator(InMemoryPendingNativeSignInStore(), completer, this) + assertTrue(coordinator.finish(callback(state = coordinator.start(), iss = null))) + assertEquals(NativeSignInMessages.ISSUER_MISMATCH, assertIs(coordinator.result.value).reason) + // An error redirect needs it too. + assertTrue(coordinator.finish(callback(state = coordinator.start(), code = null, error = "not_permitted", iss = null))) + assertEquals(NativeSignInMessages.ISSUER_MISMATCH, assertIs(coordinator.result.value).reason) + assertTrue(completer.signIns.isEmpty()) + } + + /** + * A server saved by its LAN address signs in there: the native start + * opens on the LAN base, the server answers with the LAN origin as `iss`, and the + * code is redeemed for the LAN entry. The public origin as `iss` is + * another origin than the one opened, so it is refused. + */ + @Test + fun aLanSavedServerSignsInOnItsLanAddress() = runTest { + val store = InMemoryPendingNativeSignInStore() + val completer = FakeCompleter() + val coordinator = coordinator(store, completer, this) + suspend fun begin() = assertIs( + coordinator.begin(NativeSignInPurpose.SignIn, "lan", "http://192.168.1.10:8096", "srv-1", "Keycloak", START_PATH), + ).url + val url = begin() + assertTrue(url.startsWith("http://192.168.1.10:8096/api/v2/auth/oauth/5/native/start?"), url) + assertEquals("http://192.168.1.10:8096", store.load()?.startOrigin) + assertTrue(coordinator.finish(callback(state = url.substringAfter("app_state=").substringBefore('&'), iss = "https://prairie.example.test"))) + assertEquals(NativeSignInMessages.ISSUER_MISMATCH, assertIs(coordinator.result.value).reason) + assertTrue(completer.startOrigins.isEmpty()) + + val again = begin().substringAfter("app_state=").substringBefore('&') + assertTrue(coordinator.finish(callback(state = again, iss = "http://192.168.1.10:8096"))) + assertEquals("lan", assertIs(coordinator.result.value).serverEntryId) + assertEquals(listOf("http://192.168.1.10:8096"), completer.startOrigins) + } + + /** A path the app can't place on the saved base starts nothing. */ + @Test + fun aStartThatCannotOpenOnTheSavedBaseIsRefused() = runTest { + val store = InMemoryPendingNativeSignInStore() + val coordinator = coordinator(store, FakeCompleter(), this) + assertIs( + coordinator.begin(NativeSignInPurpose.SignIn, "pub", "https://prairie.example.test", "srv-1", "Keycloak", "//evil.example.test/x"), + ) + assertIs( + coordinator.begin(NativeSignInPurpose.SignIn, "pub", "not a url", "srv-1", "Keycloak", START_PATH), + ) + assertNull(store.load()) + } + + @Test + fun anIssuerOtherThanTheOpenedOriginIsRefusedWithoutRedeeming() = runTest { + val completer = FakeCompleter() + val coordinator = coordinator(InMemoryPendingNativeSignInStore(), completer, this) + assertTrue(coordinator.finish(callback(state = coordinator.start(), iss = "https://real.example.test"))) + assertEquals(NativeSignInMessages.ISSUER_MISMATCH, assertIs(coordinator.result.value).reason) + assertTrue(completer.signIns.isEmpty()) + + assertTrue(coordinator.finish(callback(state = coordinator.start(), iss = "https://PRAIRIE.example.test:443"))) + assertIs(coordinator.result.value) + assertEquals(1, completer.signIns.size) + } + + @Test + fun switchAccountAsksForAnotherAccountOnSignInOnly() = runTest { + val coordinator = coordinator(InMemoryPendingNativeSignInStore(), FakeCompleter(), this) + assertFalse(coordinator.accountChoiceRequested("entry-1")) + coordinator.requestAccountChoice("entry-1") + assertTrue(coordinator.accountChoiceRequested("entry-1")) + assertFalse(coordinator.accountChoiceRequested("entry-2")) + + coordinator.start(NativeSignInPurpose.SignIn, prompt = NativeSignInProtocol.PROMPT_SELECT_ACCOUNT) + assertTrue(lastStartUrl.contains("prompt=select_account"), lastStartUrl) + coordinator.start(NativeSignInPurpose.Link, prompt = NativeSignInProtocol.PROMPT_SELECT_ACCOUNT) + assertFalse(lastStartUrl.contains("prompt="), "a linking flow never asks to pick an account") + + // Signing in ends it. + assertTrue(coordinator.finish(callback(state = coordinator.start()))) + assertIs(coordinator.result.value) + assertFalse(coordinator.accountChoiceRequested("entry-1")) + } + + @Test + fun aCallbackWithNoPendingFlowIsIgnored() = runTest { + val completer = FakeCompleter() + val coordinator = coordinator(InMemoryPendingNativeSignInStore(), completer, this) + assertFalse(coordinator.finish(callback(state = "anything"))) + assertNull(coordinator.result.value) + assertTrue(completer.signIns.isEmpty()) + } + + @Test + fun aForeignStateIsIgnoredAndTheRealRedirectStillFinishes() = runTest { + val store = InMemoryPendingNativeSignInStore() + val completer = FakeCompleter() + val coordinator = coordinator(store, completer, this) + val state = coordinator.start() + assertFalse(coordinator.finish(callback(state = "hijacker"))) + assertFalse(coordinator.finish(callback(state = null))) + assertNotNull(store.load(), "the pending flow waits for the real redirect") + assertNull(coordinator.result.value) + + assertTrue(coordinator.finish(callback(state = state))) + assertIs(coordinator.result.value) + assertEquals("code-1", completer.signIns.single().first) + assertEquals(43, completer.signIns.single().second.length) + assertNull(store.load(), "the flow is spent") + assertFalse(coordinator.finish(callback(state = state)), "a replayed redirect finds no flow") + assertEquals(1, completer.signIns.size) + } + + @Test + fun anExpiredFlowIsDropped() = runTest { + val store = InMemoryPendingNativeSignInStore() + val coordinator = coordinator(store, FakeCompleter(), this) + val state = coordinator.start() + now += NativeSignInProtocol.FLOW_LIFETIME_MS + 1 + assertFalse(coordinator.finish(callback(state = state))) + assertNull(store.load()) + } + + @Test + fun anotherServerIsRefusedWithoutRedeemingTheCode() = runTest { + val completer = FakeCompleter() + val coordinator = coordinator(InMemoryPendingNativeSignInStore(), completer, this) + val state = coordinator.start() + assertTrue(coordinator.finish(callback(state = state, server = "srv-other"))) + val failed = assertIs(coordinator.result.value) + assertEquals(NativeSignInMessages.WRONG_SERVER, failed.reason) + assertTrue(completer.signIns.isEmpty()) + + val again = coordinator.start() + assertTrue(coordinator.finish(callback(state = again, server = null))) + assertEquals(NativeSignInMessages.WRONG_SERVER, assertIs(coordinator.result.value).reason) + } + + @Test + fun serverReasonsBecomeReadableFailures() = runTest { + val coordinator = coordinator(InMemoryPendingNativeSignInStore(), FakeCompleter(), this) + for (reason in listOf("not_permitted", "email_in_use", "identity_linked_elsewhere", "account_disabled", + "provider_unavailable", "state_invalid", "session_expired", "already_linked", "login_failed", "account_required", + "password_expired")) { + val state = coordinator.start() + assertTrue(coordinator.finish(callback(state = state, code = null, error = reason))) + val failed = assertIs(coordinator.result.value) + assertEquals(reason, failed.reason) + assertEquals(NativeSignInMessages.forReason(reason, "Keycloak"), failed.message) + coordinator.consume(failed) + assertNull(coordinator.result.value) + } + } + + @Test + fun aRedirectForTheOtherPurposeIsRefused() = runTest { + val completer = FakeCompleter() + val coordinator = coordinator(InMemoryPendingNativeSignInStore(), completer, this) + val state = coordinator.start(NativeSignInPurpose.SignIn) + assertTrue(coordinator.finish(callback(state = state, link = true))) + assertEquals("login_failed", assertIs(coordinator.result.value).reason) + assertTrue(completer.signIns.isEmpty() && completer.links.isEmpty()) + } + + @Test + fun linkingConfirmsTheCodeWithTheVerifier() = runTest { + val completer = FakeCompleter() + val coordinator = coordinator(InMemoryPendingNativeSignInStore(), completer, this) + val state = coordinator.start(NativeSignInPurpose.Link) + assertTrue(coordinator.finish(callback(state = state, link = true))) + assertEquals("Keycloak", assertIs(coordinator.result.value).providerName) + assertEquals("code-1", completer.links.single().first) + + completer.linkResult = ApiResult.Error(409, "identity_linked_elsewhere", "") + val second = coordinator.start(NativeSignInPurpose.Link) + coordinator.finish(callback(state = second, link = true)) + assertEquals("identity_linked_elsewhere", assertIs(coordinator.result.value).reason) + } + + @Test + fun redemptionFailuresMapToReasons() = runTest { + val completer = FakeCompleter() + val coordinator = coordinator(InMemoryPendingNativeSignInStore(), completer, this) + completer.signInResult = ApiResult.NetworkError(RuntimeException("offline")) + coordinator.finish(callback(state = coordinator.start())) + assertEquals(NativeSignInMessages.NETWORK, assertIs(coordinator.result.value).reason) + + // The provider couldn't re-check the session before a fresh-bearer request. + completer.signInResult = ApiResult.NetworkError( + org.prairieserver.prairie.network.PrairieAuthUnavailableException( + org.prairieserver.prairie.network.PrairieAuthUnavailableException.PROVIDER_UNAVAILABLE, + ), + ) + coordinator.finish(callback(state = coordinator.start())) + assertEquals("provider_unavailable", assertIs(coordinator.result.value).reason) + + val table = mapOf( + ApiResult.Error(400, "invalid_grant", "") to "state_invalid", + ApiResult.Error(401, "invalid_token", "") to "session_expired", + ApiResult.Error(403, "not_permitted", "") to "not_permitted", + ApiResult.Error(403, "permission_denied", "") to NativeSignInMessages.PERMISSION_DENIED, + ApiResult.Error(403, "account_disabled", "") to "account_disabled", + ApiResult.Error(409, "conflict", "") to "already_linked", + ApiResult.Error(409, "identity_linked_elsewhere", "") to "identity_linked_elsewhere", + ApiResult.Error(409, "email_in_use", "") to "email_in_use", + ApiResult.Error(409, "already_linked", "") to "already_linked", + ApiResult.Error(403, "account_required", "") to NativeSignInMessages.ACCOUNT_REQUIRED, + ApiResult.Error(503, "", "") to "provider_unavailable", + ApiResult.Error(0, "identity_changed", "") to NativeSignInMessages.ACCOUNT_CHANGED, + ApiResult.Error(500, "internal", "") to "login_failed", + ApiResult.Error(400, "password_expired", "") to "password_expired", + ApiResult.Error(429, "rate_limited", "") to NativeSignInMessages.RATE_LIMITED, + ApiResult.Error(429, "", "") to NativeSignInMessages.RATE_LIMITED, + ApiResult.Error(403, "", "") to "not_permitted", + ApiResult.Error(0, NativeSignInMessages.UPDATE_REQUIRED, "") to NativeSignInMessages.UPDATE_REQUIRED, + ) + table.forEach { (error, reason) -> assertEquals(reason, NativeSignInCoordinator.completionReason(error), "$error") } + } + + @Test + fun everyReasonHasItsOwnText() { + val generic = NativeSignInMessages.forReason("something_new", "Keycloak") + listOf("not_permitted", "email_in_use", "identity_linked_elsewhere", "account_disabled", "provider_unavailable", + "state_invalid", "session_expired", "already_linked", "password_expired", NativeSignInMessages.WRONG_SERVER, + NativeSignInMessages.ISSUER_MISMATCH, + NativeSignInMessages.ACCOUNT_REQUIRED, NativeSignInMessages.ACCOUNT_CHANGED, NativeSignInMessages.PERMISSION_DENIED, + NativeSignInMessages.NETWORK, NativeSignInMessages.NO_BROWSER, NativeSignInMessages.RATE_LIMITED, + NativeSignInMessages.UPDATE_REQUIRED).forEach { + assertNotEquals(generic, NativeSignInMessages.forReason(it, "Keycloak"), it) + assertFalse(NativeSignInMessages.forReason(it, "Keycloak").contains(it) && it.contains('_'), "no raw code in $it") + } + } +} + +internal const val START_PATH = "/api/v2/auth/oauth/5/native/start" + +internal fun pending(purpose: NativeSignInPurpose) = PendingNativeSignIn( + purpose = purpose, + serverEntryId = "entry-1", + verifiedServerId = "srv-1", + startOrigin = "https://prairie.example.test", + appState = NativeSignInProtocol.newAppState(), + codeVerifier = NativeSignInProtocol.newCodeVerifier(), + providerName = "Keycloak", + startedAtEpochMs = 0L, +) + +/** The nearby-TV confirmation uses the same content model as prairie-apple's CompanionPairingCard. */ +class CompanionConfirmCopyTest { + @Test + fun confirmationCopyMatchesTheIphoneApp() { + val strings = File("src/androidMain/res/values/strings.xml").readText() + listOf( + "companion_confirm_yes\">Yes, this matches<", + "sign_in_tv_profiles_note\">Anyone using this TV can pick from your profiles. Profiles with a PIN stay locked.<", + "sign_in_tv_warning\">Only approve a TV that\\'s in front of you right now.<", + "sign_in_tv_server_account\">You\\'ll sign it in to %1\$s as %2\$s.<", + "companion_offer_signin_body\">A TV nearby named “%1\$s” says it\\'s on its sign-in screen. You\\'ll check its code before approving.<", + "companion_confirm_older_tv\">Older TV apps show %1\$s instead.<", + ).forEach { assertTrue(strings.contains(it), it) } + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/auth/ProfileSwitchTeardownTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/auth/ProfileSwitchTeardownTest.kt new file mode 100644 index 0000000000..b765958773 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/auth/ProfileSwitchTeardownTest.kt @@ -0,0 +1,126 @@ +package org.prairieserver.prairie.android.auth + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.prairieserver.prairie.common.settings.EpisodeSpoilerStore +import org.prairieserver.prairie.common.settings.PlayerSettingsStore +import org.prairieserver.prairie.common.settings.SeekIntervalStore +import org.prairieserver.prairie.common.settings.TitleArtStore +import org.prairieserver.prairie.model.profile.ActiveProfileStore +import org.prairieserver.prairie.network.TokenManagerImpl +import org.prairieserver.prairie.network.api.ProfileApi +import org.prairieserver.prairie.network.apiv2.ApiV2Gate +import org.prairieserver.prairie.repository.ProfileRepository +import java.lang.reflect.Proxy +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * A settings write waits out the flusher's debounce, and the flusher drops it + * once its profile is no longer active. The phone switch has to push pending + * writes before it leaves the shell, as the TV switch does. + */ +class ProfileSwitchTeardownTest { + private val http = HttpClient(MockEngine { respond("", HttpStatusCode.NoContent) }) + + @AfterTest fun tearDown() = http.close() + + @Test + fun pendingSettingsAreFlushedBeforeLeavingTheShell() = runTest { + val events = mutableListOf() + val settings = object : PlayerSettingsStore by idle() { + override suspend fun flushPendingDeviceSettings() { + events += "flush" + } + } + val teardown = teardown(settings, onClear = { events += "clear:$it" }) + + teardown.switchProfile { events += "leave" } + + assertEquals("flush", events.first()) + assertEquals("leave", events[1], "the shell is left only after the flush") + assertEquals( + setOf("clear:overlays", "clear:cards", "clear:seek", "clear:titleArt", "clear:spoilers"), + events.drop(2).toSet(), + ) + } + + @Test + fun aFlushThatCannotFinishDoesNotBlockTheSwitch() = runTest { + var left = false + val settings = object : PlayerSettingsStore by idle() { + override suspend fun flushPendingDeviceSettings() = awaitCancellation() + } + + teardown(settings, flushTimeoutMs = 5_000).switchProfile { left = true } + + assertEquals(true, left) + assertEquals(5_000, currentTime) + } + + @Test + fun aSecondRequestWhileSwitchingIsIgnored() = runTest { + val flushGate = CompletableDeferred() + val settings = object : PlayerSettingsStore by idle() { + override suspend fun flushPendingDeviceSettings() = flushGate.await() + } + val teardown = teardown(settings) + var left = 0 + + val first = async { teardown.switchProfile { left++ } } + runCurrent() + assertTrue(teardown.switching.value, "the first switch is still pushing settings") + // A second tap during the push must not open a second profile picker. + assertFalse(teardown.switchProfile { left++ }) + + flushGate.complete(Unit) + assertTrue(first.await()) + assertEquals(1, left) + assertFalse(teardown.switching.value) + + // Once finished, the next switch runs normally. + assertTrue(teardown.switchProfile { left++ }) + assertEquals(2, left) + } + + private fun teardown( + settings: PlayerSettingsStore, + onClear: (String) -> Unit = {}, + flushTimeoutMs: Long = 5_000, + ) = ProfileSwitchTeardown( + playerSettingsStore = settings, + overlayPrefsStore = recordingClear("overlays", onClear), + activeProfileStore = ActiveProfileStore(ProfileRepository(ProfileApi(http, ApiV2Gate.Unrestricted), TokenManagerImpl())), + cardPresentationStore = recordingClear("cards", onClear), + seekIntervalStore = recordingClear("seek", onClear), + titleArtStore = recordingClear("titleArt", onClear), + episodeSpoilerStore = recordingClear("spoilers", onClear), + flushTimeoutMs = flushTimeoutMs, + ) + + /** A store that reports its `clear()` and otherwise answers nothing. */ + private inline fun recordingClear(name: String, crossinline onClear: (String) -> Unit): T = + Proxy.newProxyInstance(T::class.java.classLoader, arrayOf(T::class.java)) { _, method, _ -> + if (method.name == "clear") onClear(name) + if (Flow::class.java.isAssignableFrom(method.returnType)) emptyFlow() else Unit + } as T + + /** Collaborators the switch doesn't depend on: they answer nothing and emit nothing. */ + private inline fun idle(): T = + Proxy.newProxyInstance(T::class.java.classLoader, arrayOf(T::class.java)) { _, method, _ -> + if (Flow::class.java.isAssignableFrom(method.returnType)) emptyFlow() else Unit + } as T +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/auth/SignOutTeardownTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/auth/SignOutTeardownTest.kt new file mode 100644 index 0000000000..0cbfc7d5eb --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/auth/SignOutTeardownTest.kt @@ -0,0 +1,78 @@ +package org.prairieserver.prairie.android.auth + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.runTest +import org.prairieserver.prairie.model.feature.MetadataAiFeatureStore +import org.prairieserver.prairie.model.feature.RequestsFeatureStore +import org.prairieserver.prairie.model.profile.ActiveProfileStore +import org.prairieserver.prairie.network.ServerRegistry +import org.prairieserver.prairie.network.TokenManagerImpl +import org.prairieserver.prairie.network.api.AuthApi +import org.prairieserver.prairie.network.api.ProfileApi +import org.prairieserver.prairie.network.apiv2.ApiV2Gate +import org.prairieserver.prairie.repository.AuthRepository +import org.prairieserver.prairie.repository.MetadataAiRepository +import org.prairieserver.prairie.repository.ProfileRepository +import org.prairieserver.prairie.repository.RequestsRepository +import java.lang.reflect.Proxy +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * An explicit sign-out asks the server's next provider sign-in to offer + * another account (`prompt=select_account`): the provider's browser session + * outlives the Prairie one, and would otherwise sign the same person straight + * back in. + */ +class SignOutTeardownTest { + private val http = HttpClient(MockEngine { respond("", HttpStatusCode.NoContent) }) + + @AfterTest fun tearDown() = http.close() + + @Test + fun signingOutAsksTheSignedOutServerForAnAccountChoice() = runTest { + val tokens = TokenManagerImpl() + val coordinator = NativeSignInCoordinator( + InMemoryPendingNativeSignInStore(), + idle(), + backgroundScope, + InMemoryAccountChoiceStore(), + ) + val registry = idle(mapOf("getActiveServerId" to MutableStateFlow("home"))) + val teardown = SignOutTeardown( + authRepository = AuthRepository(AuthApi(http, ApiV2Gate.Unrestricted), tokens), + playerSettingsStore = idle(), + libraryPlaybackPrefsStore = idle(), + overlayPrefsStore = idle(), + activeProfileStore = ActiveProfileStore(ProfileRepository(ProfileApi(http, ApiV2Gate.Unrestricted), tokens)), + cardPresentationStore = idle(), + seekIntervalStore = idle(), + titleArtStore = idle(), + episodeSpoilerStore = idle(), + requestsFeatureStore = RequestsFeatureStore(RequestsRepository(idle())), + metadataAiFeatureStore = MetadataAiFeatureStore(MetadataAiRepository(idle())), + serverRegistry = registry, + nativeSignIn = coordinator, + ) + assertFalse(coordinator.accountChoiceRequested("home")) + + teardown.signOut() + + assertTrue(coordinator.accountChoiceRequested("home")) + assertFalse(coordinator.accountChoiceRequested("cabin"), "only the server signed out of") + } + + /** Collaborators the sign-out doesn't depend on: they answer nothing and emit nothing. */ + private inline fun idle(values: Map = emptyMap()): T = + Proxy.newProxyInstance(T::class.java.classLoader, arrayOf(T::class.java)) { _, method, _ -> + values[method.name] ?: if (Flow::class.java.isAssignableFrom(method.returnType)) emptyFlow() else Unit + } as T +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/pip/MobilePictureInPictureSourceTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/pip/MobilePictureInPictureSourceTest.kt index b064c10dd4..bfbaa3daad 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/pip/MobilePictureInPictureSourceTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/pip/MobilePictureInPictureSourceTest.kt @@ -47,11 +47,11 @@ class MobilePictureInPictureSourceTest { @Test fun mobileSettingsExposePipToggle() { // Pin the binding, not just the label: the label is copy and moves - // with the settings voice (it was "Picture-in-Picture" before the - // sentence-case pass), while a switch row bound to the PiP preference - // is what actually makes the toggle reachable. + // with the settings voice (back to "Picture-in-Picture" with the Apple + // title-case pass), while a switch row bound to the PiP preference is + // what actually makes the toggle reachable. assertTrue(settings.contains("checked = pictureInPictureEnabled")) assertTrue(settings.contains("onCheckedChange = onPictureInPictureEnabledChanged")) - assertTrue(settings.contains("Picture-in-picture")) + assertTrue(settings.contains("Picture-in-Picture")) } } diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/profiles/ProfileLaunchActionTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/profiles/ProfileLaunchActionTest.kt new file mode 100644 index 0000000000..99f7981de4 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/profiles/ProfileLaunchActionTest.kt @@ -0,0 +1,31 @@ +package org.prairieserver.prairie.android.profiles + +import kotlin.test.Test +import kotlin.test.assertEquals + +class ProfileLaunchActionTest { + + @Test + fun `asks who's watching only when the setting asks and the server can be used`() { + assertEquals( + ProfileLaunchAction.KeepProfile, + profileLaunchAction(requiresSelection = false, startsOfflineOnDownloads = false), + ) + assertEquals( + ProfileLaunchAction.KeepProfile, + profileLaunchAction(requiresSelection = false, startsOfflineOnDownloads = true), + ) + assertEquals( + ProfileLaunchAction.AskWhoIsWatching, + profileLaunchAction(requiresSelection = true, startsOfflineOnDownloads = false), + ) + } + + @Test + fun `offline with downloads keeps the profile instead of stranding the picker`() { + assertEquals( + ProfileLaunchAction.KeepProfileOffline, + profileLaunchAction(requiresSelection = true, startsOfflineOnDownloads = true), + ) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/layout/ResponsiveControlLayoutTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/layout/ResponsiveControlLayoutTest.kt index 7fcc1b5054..6394f14fa2 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/layout/ResponsiveControlLayoutTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/layout/ResponsiveControlLayoutTest.kt @@ -2,8 +2,6 @@ package org.prairieserver.prairie.android.ui.layout import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue class ResponsiveControlLayoutTest { @Test @@ -29,11 +27,4 @@ class ResponsiveControlLayoutTest { resolveAudiobookTransportLayout(availableWidthDp = 300f, hasChapters = true), ) } - - @Test - fun toolbarCompactsWhenActionsWouldEraseTheTitle() { - assertTrue(useCompactPlayerToolbar(availableWidthDp = 328f, trailingActionCount = 5)) - assertFalse(useCompactPlayerToolbar(availableWidthDp = 800f, trailingActionCount = 5)) - assertTrue(useCompactPlayerToolbar(availableWidthDp = 328f, trailingActionCount = 3)) - } } diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/navigation/CollectionRouteTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/navigation/CollectionRouteTest.kt index 2f07e0f9af..9ac5018f9d 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/navigation/CollectionRouteTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/navigation/CollectionRouteTest.kt @@ -31,6 +31,18 @@ class CollectionRouteTest { ) } + @Test + fun mixedLibraryCollectionRetainsSeriesScopeInItsDestination() { + assertEquals( + "collection/user?libraryId=7&source=user_collection&mediaScope=series", + libraryCollectionDetailRoute( + LibraryCollection(id = "user", name = "User", kind = "user_collections"), + libraryId = 7, + mediaScope = "series", + ), + ) + } + @Test fun legacyLibraryCollectionRouteRemainsSupported() { assertEquals( @@ -57,4 +69,21 @@ class CollectionRouteTest { assertEquals(1, Regex("navArgument\\(\"source\"\\)").findAll(collectionDestination).count()) } + + @Test + fun collectionItemsAndShufflesOpenWithoutALibraryScope() { + val collectionDestination = appNavigationSource.substringAfter( + "route = Route.CollectionDetail.ROUTE", + ).substringBefore( + "// ---- Detail screens ----", + ) + + // A library-scoped read 404s for an item filed in another library. + assertEquals( + 0, + Regex("Route\\.ItemDetail\\([^)]*libraryId").findAll(collectionDestination).count(), + ) + // A collection shuffle draws from the whole collection. + assertEquals(1, Regex("shufflePlayerRoute\\(shuffle\\)").findAll(collectionDestination).count()) + } } diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/navigation/DeviceLoginRouteParserTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/navigation/DeviceLoginRouteParserTest.kt index f950276a97..8da6e4061e 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/navigation/DeviceLoginRouteParserTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/navigation/DeviceLoginRouteParserTest.kt @@ -100,4 +100,29 @@ class DeviceLoginRouteParserTest { ) assertNull(deviceLoginPairRouteOrNull("https://prairie.example:0/device?code=A")) } + + @Test + fun `an app link without server is scoped by its url origin`() { + assertEquals( + "pair_device?code=48217730&serverOrigin=https%3A%2F%2Fhome.example%3A8443", + deviceLoginPairRouteOrNull("prairie://device?url=https%3A%2F%2FHome.example%3A8443%2F&code=48217730"), + ) + // The default port normalizes away, as for an https link. + assertEquals( + "pair_device?code=48217730&serverOrigin=https%3A%2F%2Fhome.example", + deviceLoginPairRouteOrNull("prairie://device?url=https%3A%2F%2Fhome.example%3A443&code=48217730"), + ) + } + + @Test + fun `an app link without server ignores an unreadable url`() { + assertEquals( + "pair_device?code=48217730", + deviceLoginPairRouteOrNull("prairie://device?url=javascript%3Aalert(1)&code=48217730"), + ) + assertEquals( + "pair_device?code=48217730", + deviceLoginPairRouteOrNull("prairie://device?url=https%3A%2F%2F&code=48217730"), + ) + } } diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/navigation/DeviceLoginServerIdentityMatchTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/navigation/DeviceLoginServerIdentityMatchTest.kt new file mode 100644 index 0000000000..52ca6f6424 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/navigation/DeviceLoginServerIdentityMatchTest.kt @@ -0,0 +1,99 @@ +package org.prairieserver.prairie.android.ui.navigation + +import kotlinx.coroutines.test.runTest +import org.prairieserver.prairie.model.server.ServerEntry +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * `prairie://device?server=&url=&code=` links name their server + * by deployment identity. These pin how that identity picks a saved server. + */ +class DeviceLoginServerIdentityMatchTest { + private val home = ServerEntry(id = "a", url = "https://home.example", fetchedName = "Home") + private val lan = ServerEntry(id = "b", url = "http://192.168.1.5:8090", fetchedName = "Home (LAN)") + private val other = ServerEntry(id = "c", url = "https://other.example", fetchedName = "Other") + + private suspend fun match( + linkUrl: String?, + active: ServerEntry?, + ids: Map, + required: String = "S-HOME", + ) = deviceLoginServerMatchByIdentity( + requiredServerId = required, + linkUrl = linkUrl, + activeEntry = active, + entries = listOf(home, lan, other), + identityOf = { ids[it.id] }, + ) + + @Test + fun activeServerWithTheIdentityProceedsEvenAtAnotherAddress() = runTest { + // The link names the public URL; the phone saved the LAN address of the same server. + assertEquals( + DeviceLoginServerMatch.Active, + match("https://home.example", lan, mapOf("b" to "S-HOME", "a" to "S-HOME")), + ) + } + + @Test + fun anotherSavedServerWithTheIdentityOffersTheSwitch() = runTest { + assertEquals( + DeviceLoginServerMatch.SwitchRequired(lan), + match("https://public.example", other, mapOf("c" to "S-OTHER", "b" to "S-HOME")), + ) + } + + @Test + fun noSavedServerWithTheIdentityIsUnknownEvenWhenTheAddressMatches() = runTest { + // Same address, different deployment: identity wins over URL spelling. + assertEquals( + DeviceLoginServerMatch.UnknownServer("https://home.example"), + match("https://home.example", home, mapOf("a" to "S-ELSE", "b" to null, "c" to "S-OTHER")), + ) + } + + @Test + fun unverifiableServersFallBackToTheLinkOrigin() = runTest { + assertEquals( + DeviceLoginServerMatch.Active, + match("https://home.example", home, emptyMap()), + ) + assertEquals( + DeviceLoginServerMatch.SwitchRequired(home), + match("https://home.example", other, mapOf("c" to "S-OTHER")), + ) + } + + @Test + fun noServerYetProceedsThroughSetup() = runTest { + assertEquals(DeviceLoginServerMatch.Active, match("https://home.example", null, emptyMap())) + } + + @Test + fun parserCarriesTheServerIdentityAndBase() { + assertEquals( + "pair_device?code=48217730&serverId=S-HOME&serverUrl=https%3A%2F%2Fhome.example%2Fprairie", + deviceLoginPairRouteOrNull("prairie://device?server=S-HOME&url=https%3A%2F%2Fhome.example%2Fprairie%2F&code=48217730"), + ) + // A url that isn't http(s) is dropped, not trusted. + assertEquals( + "pair_device?code=48217730&serverId=S-HOME", + deviceLoginPairRouteOrNull("prairie://device?server=S-HOME&url=javascript%3Aalert(1)&code=48217730"), + ) + // The TV's QR link opens the app too when the OS delivers it. + assertEquals( + "pair_device?code=48217730&serverOrigin=https%3A%2F%2Fhome.example", + deviceLoginPairRouteOrNull("https://home.example/activate?code=48217730"), + ) + } + + @Test + fun aCodeLinkForAnotherSavedServerIsApprovedThereWithoutSwitching() { + val match = DeviceLoginServerMatch.SwitchRequired(lan) + assertEquals(lan, match.inPlaceApprovalServer(token = null, code = "48217730")) + // Only the active server's lookup accepts a token: that still needs the switch. + assertEquals(null, match.inPlaceApprovalServer(token = "tok", code = null)) + assertEquals(null, match.inPlaceApprovalServer(token = null, code = " ")) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/auth/ApprovalAccountSwitchTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/auth/ApprovalAccountSwitchTest.kt new file mode 100644 index 0000000000..e48f71b788 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/auth/ApprovalAccountSwitchTest.kt @@ -0,0 +1,31 @@ +package org.prairieserver.prairie.android.ui.screens.auth + +import org.prairieserver.prairie.model.auth.SignInOptions +import org.prairieserver.prairie.model.auth.SignInProvider +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * "Not you?" on the approval card always shows; its wording follows what the + * next sign-in offers (prairie-apple `TVApprovalAccountSwitch`). + */ +class ApprovalAccountSwitchTest { + private val keycloak = SignInProvider( + id = "kc", + displayName = "Keycloak", + mode = SignInProvider.Mode.OAuth, + isDefault = false, + iconUrl = null, + installationId = "5", + nativeStartPath = "/api/v2/auth/oauth/5/native/start", + ) + + @Test + fun theWordingFollowsTheServersSignInOptions() { + assertEquals(ApprovalAccountSwitch.SignOut, ApprovalAccountSwitch.of(null), "options couldn't be read") + assertEquals(ApprovalAccountSwitch.SwitchAccount, ApprovalAccountSwitch.of(SignInOptions.PasswordOnly)) + val provider = SignInOptions(oauthProviders = listOf(keycloak), showPasswordForm = true, directoryProvider = null) + assertEquals(ApprovalAccountSwitch.SignOut, ApprovalAccountSwitch.of(provider)) + assertEquals(ApprovalAccountSwitch.ChooseAccount, ApprovalAccountSwitch.of(provider.copy(selectAccount = true))) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/auth/DevicePairingSignInRequestTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/auth/DevicePairingSignInRequestTest.kt new file mode 100644 index 0000000000..541733f18a --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/auth/DevicePairingSignInRequestTest.kt @@ -0,0 +1,30 @@ +package org.prairieserver.prairie.android.ui.screens.auth + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.prairieserver.prairie.viewmodel.DeviceApprovalServer +import org.prairieserver.prairie.viewmodel.DevicePairingError +import org.prairieserver.prairie.viewmodel.DevicePairingUiState + +/** "Sign in" after a 401 targets the server the code was looked up on, and keeps the code. */ +class DevicePairingSignInRequestTest { + private val home = DeviceApprovalServer("a", "https://home.example", "Home", isActive = true) + private val cabin = DeviceApprovalServer("b", "https://cabin.example", "Cabin") + + @Test + fun signInAfterA401OnAChosenNonActiveServerTargetsThatServerWithTheTypedCode() { + val state = DevicePairingUiState( + code = "48217730", + servers = listOf(home, cabin), + selectedServerId = "b", + error = DevicePairingError.SignInFirst, + ) + assertEquals("b" to "48217730", state.signInRequest()) + } + + @Test + fun noTypedCodeCarriesNone() { + val state = DevicePairingUiState(servers = listOf(home), selectedServerId = "a") + assertEquals("a" to null, state.signInRequest()) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/auth/LoginV2ConsumerTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/auth/LoginV2ConsumerTest.kt index 766413f82d..fa3800a1e8 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/auth/LoginV2ConsumerTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/auth/LoginV2ConsumerTest.kt @@ -15,7 +15,20 @@ import kotlinx.coroutines.test.* import org.prairieserver.prairie.network.PrairieJson import org.prairieserver.prairie.network.TokenManagerImpl import org.prairieserver.prairie.network.api.AuthApi +import org.prairieserver.prairie.android.auth.InMemoryAccountChoiceStore +import org.prairieserver.prairie.android.auth.InMemoryPendingNativeSignInStore +import org.prairieserver.prairie.android.auth.NativeSignInCompleter +import org.prairieserver.prairie.android.auth.NativeSignInCoordinator +import org.prairieserver.prairie.android.auth.PendingNativeSignIn +import org.prairieserver.prairie.model.server.ServerEntry +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.ServerRegistry +import org.prairieserver.prairie.network.api.DefaultExternalSignInApi +import org.prairieserver.prairie.network.api.DefaultServerIdentityApi import org.prairieserver.prairie.repository.AuthRepository +import org.prairieserver.prairie.repository.ExternalSignInRepository +import org.prairieserver.prairie.repository.ServerIdentityRepository +import kotlinx.coroutines.flow.MutableStateFlow import kotlin.test.* @OptIn(ExperimentalCoroutinesApi::class) @@ -32,7 +45,18 @@ class LoginV2ConsumerTest { respond("""{"access_token":"access","refresh_token":"refresh","expires_in":3600,"user":{"id":"1","username":"user","email":"u@example.test","role":"user"}}""", HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) }) { install(ContentNegotiation) { json(PrairieJson) } } - val vm = LoginViewModel(AuthRepository(AuthApi(client, ApiV2Gate.Unrestricted), tokens)) + // Provider discovery on its own client: this server lists none. + val discovery = HttpClient(MockEngine { respond("", HttpStatusCode.NotFound) }) { + install(ContentNegotiation) { json(PrairieJson) } + } + val vm = LoginViewModel( + AuthRepository(AuthApi(client, ApiV2Gate.Unrestricted), tokens), + ExternalSignInRepository(DefaultExternalSignInApi(discovery, ApiV2Gate.Unrestricted)), + noServers, + ServerIdentityRepository(noServers, DefaultServerIdentityApi(discovery)), + NativeSignInCoordinator(InMemoryPendingNativeSignInStore(), NoCompleter, backgroundScope, InMemoryAccountChoiceStore()), + tokens, + ) try { vm.onUsernameChanged("user"); vm.onPasswordChanged("password") vm.onLoginClick(); vm.onLoginClick(); entered.await() @@ -42,8 +66,28 @@ class LoginV2ConsumerTest { assertEquals(1, calls); assertEquals(!switchServer, settled.loginSuccess) assertEquals(if (switchServer) null else "access", tokens.getAccessToken()) if (switchServer) assertNotNull(settled.error) - } finally { vm.viewModelScope.cancel(); client.close() } + } finally { vm.viewModelScope.cancel(); client.close(); discovery.close() } } } finally { Dispatchers.resetMain() } } + + private object NoCompleter : NativeSignInCompleter { + override suspend fun signIn(pending: PendingNativeSignIn, code: String) = error("no provider sign-in here") + override suspend fun link(pending: PendingNativeSignIn, code: String): ApiResult = error("no linking here") + } + + /** No saved servers: the password path under test uses the token manager's URL alone. */ + private val noServers = object : ServerRegistry { + override val entries = MutableStateFlow(emptyList()) + override val activeServerId = MutableStateFlow(null) + override val activeEntry = MutableStateFlow(null) + override suspend fun addOrUpdate(url: String, fetchedName: String?) = error("unused") + override suspend fun rename(serverId: String, userOverrideName: String?) = Unit + override suspend fun setFetchedName(serverId: String, fetchedName: String?) = Unit + override suspend fun setProfileId(serverId: String, profileId: String?) = Unit + override suspend fun remove(serverId: String) = Unit + override suspend fun signOut(serverId: String) = Unit + override suspend fun switchTo(serverId: String) = Unit + override suspend fun touchActive() = Unit + } } diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/auth/LoginViewModelSsoTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/auth/LoginViewModelSsoTest.kt new file mode 100644 index 0000000000..34c6cc1c8b --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/auth/LoginViewModelSsoTest.kt @@ -0,0 +1,625 @@ +package org.prairieserver.prairie.android.ui.screens.auth + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.viewModelScope +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respondError +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.prairieserver.prairie.android.auth.InMemoryAccountChoiceStore +import org.prairieserver.prairie.android.auth.InMemoryPendingNativeSignInStore +import org.prairieserver.prairie.android.auth.NativeSignInCallback +import org.prairieserver.prairie.android.auth.NativeSignInCompleter +import org.prairieserver.prairie.android.auth.NativeSignInCoordinator +import org.prairieserver.prairie.android.auth.NativeSignInMessages +import org.prairieserver.prairie.android.auth.NativeSignInPurpose +import org.prairieserver.prairie.android.auth.NativeSignInStart +import org.prairieserver.prairie.android.auth.PendingNativeSignIn +import org.prairieserver.prairie.model.auth.AccountIdentities +import org.prairieserver.prairie.model.auth.AccountIdentity +import org.prairieserver.prairie.model.auth.AccountIdentityLinkTicket +import org.prairieserver.prairie.model.auth.ExternalSignInCapabilities +import org.prairieserver.prairie.model.auth.LoginResponse +import org.prairieserver.prairie.model.auth.NetworkIdentity +import org.prairieserver.prairie.model.auth.OAuthHandshakeCapabilities +import org.prairieserver.prairie.model.auth.SignInProvider +import org.prairieserver.prairie.model.auth.SignInProviders +import org.prairieserver.prairie.model.auth.User +import org.prairieserver.prairie.model.server.ServerEntry +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.ServerRegistry +import org.prairieserver.prairie.network.TokenManagerImpl +import org.prairieserver.prairie.network.api.AuthApi +import org.prairieserver.prairie.network.api.ExternalSignInApi +import org.prairieserver.prairie.network.api.ServerConnections +import org.prairieserver.prairie.network.api.ServerIdentityApi +import org.prairieserver.prairie.network.api.ServerIdentityProbe +import org.prairieserver.prairie.network.apiv2.ApiV2Gate +import org.prairieserver.prairie.repository.AuthRepository +import org.prairieserver.prairie.repository.ExternalSignInRepository +import org.prairieserver.prairie.repository.ServerIdentityRepository +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** The phone sign-in screen's provider paths: discovery, starting a flow, and the flow's result. */ +@OptIn(ExperimentalCoroutinesApi::class) +class LoginViewModelSsoTest { + private val dispatcher = StandardTestDispatcher() + + @BeforeTest fun setUp() = Dispatchers.setMain(dispatcher) + + @AfterTest fun tearDown() = Dispatchers.resetMain() + + private val keycloak = SignInProvider( + "plugin:5:oidc", "Keycloak", SignInProvider.Mode.OAuth, false, null, "5", + "/api/v2/auth/oauth/5/native/start", + ) + private val native = OAuthHandshakeCapabilities(available = true, native = true, linking = true, selectAccount = true) + private val tailscale = SignInProvider( + "plugin:7:tailscale", "Tailscale", SignInProvider.Mode.Network, false, null, "7", null, + networkSignInPath = "/api/v2/auth/network/7/sign-in", + networkIdentity = NetworkIdentity("Alice Example", "alice@example.test"), + ) + + private class Fixture( + scope: CoroutineScope, + serverUrl: String, + var providers: ApiResult, + var handshake: ApiResult, + var identity: ServerIdentityProbe, + ) { + val tokens = TokenManagerImpl() + val registry = OneServerRegistry(ServerEntry(id = "a", url = serverUrl)) + val store = InMemoryPendingNativeSignInStore() + val coordinator = NativeSignInCoordinator(store, SignsIn(), scope, InMemoryAccountChoiceStore()) + private val http = HttpClient(MockEngine { respondError(HttpStatusCode.ServiceUnavailable) }) + val authRepository = AuthRepository(AuthApi(http, ApiV2Gate.Unrestricted), tokens) + + /** What the network sign-in answers, and where each one was sent (saved base, path). */ + var networkAnswer: ApiResult = ApiResult.Error(403, "network_identity_required", "") + val networkCalls = mutableListOf>() + + /** When set, the network sign-in answers only once this completes. */ + var networkGate: CompletableDeferred? = null + val api = object : ExternalSignInApi { + override suspend fun listProviders(serverUrl: String) = providers + override suspend fun oauthCapabilities(serverUrl: String) = handshake + override suspend fun externalSignInCapabilities(scope: AuthScopeSnapshot): ApiResult = TODO() + override suspend fun completeOAuthLogin(serverUrl: String, code: String, codeVerifier: String): ApiResult = TODO() + override suspend fun listIdentities(scope: AuthScopeSnapshot): ApiResult = TODO() + override suspend fun deleteIdentity(scope: AuthScopeSnapshot, identityId: String): ApiResult = TODO() + override suspend fun createLinkTicket(scope: AuthScopeSnapshot, installationId: String, password: String): + ApiResult = TODO() + override suspend fun completeLink(scope: AuthScopeSnapshot, code: String, codeVerifier: String): ApiResult = TODO() + override suspend fun linkWithCredentials( + scope: AuthScopeSnapshot, + installationId: String, + password: String, + username: String, + directoryPassword: String, + ): ApiResult = TODO() + override suspend fun signInWithNetworkIdentity(serverUrl: String, signInPath: String): ApiResult { + networkCalls += serverUrl to signInPath + networkGate?.await() + return networkAnswer + } + override suspend fun linkWithNetwork(scope: AuthScopeSnapshot, installationId: String, password: String): + ApiResult = TODO() + } + val identityApi = object : ServerIdentityApi { + override suspend fun probeIdentity(serverUrl: String) = identity + override suspend fun connections(scope: AuthScopeSnapshot): ServerConnections? = null + } + + suspend fun viewModel(): LoginViewModel { + tokens.setServerUrl(registry.activeEntry.value!!.url) + return LoginViewModel( + authRepository = authRepository, + externalSignIn = ExternalSignInRepository(api), + serverRegistry = registry, + serverIdentities = ServerIdentityRepository(registry, identityApi), + nativeSignIn = coordinator, + tokenManager = tokens, + ) + } + + fun close() = http.close() + } + + private fun TestScope.fixture( + serverUrl: String = "https://prairie.example.test", + providers: List = listOf(keycloak), + identity: ServerIdentityProbe = ServerIdentityProbe.Identity("srv-1"), + ) = Fixture( + scope = backgroundScope, + serverUrl = serverUrl, + providers = ApiResult.Success(SignInProviders(providers, passwordLogin = true)), + handshake = ApiResult.Success(native), + identity = identity, + ) + + private fun LoginViewModel.close() = viewModelScope.cancel() + + @Test + fun aNetworkFailureOffersTheFormAndARetry() = runTest(dispatcher) { + val f = fixture() + f.providers = ApiResult.NetworkError(RuntimeException("offline")) + val vm = f.viewModel() + advanceUntilIdle() + assertTrue(vm.uiState.value.optionsUnavailable) + assertTrue(vm.uiState.value.showPasswordForm) + assertTrue(vm.uiState.value.providers.isEmpty()) + + f.providers = ApiResult.Success(SignInProviders(listOf(keycloak), passwordLogin = true)) + vm.loadOptions() + advanceUntilIdle() + assertFalse(vm.uiState.value.optionsUnavailable) + assertEquals(listOf(keycloak), vm.uiState.value.providers) + vm.close(); f.close() + } + + @Test + fun aServerWithoutDiscoveryIsPasswordOnlyWithoutARetry() = runTest(dispatcher) { + val f = fixture() + f.providers = ApiResult.Error(404, "not_found", "") + val vm = f.viewModel() + advanceUntilIdle() + assertFalse(vm.uiState.value.optionsUnavailable) + assertTrue(vm.uiState.value.showPasswordForm) + vm.close(); f.close() + } + + @Test + fun anUnconfirmedIdentityStartsNothing() = runTest(dispatcher) { + val f = fixture(identity = ServerIdentityProbe.Unreachable) + val vm = f.viewModel() + advanceUntilIdle() + vm.onProviderClick(keycloak) + advanceUntilIdle() + assertEquals("Couldn't confirm this server's identity. Check your connection and try again.", vm.uiState.value.error) + assertNull(vm.uiState.value.browserLaunch) + assertNull(vm.uiState.value.providerBusy) + assertNull(f.store.load()) + vm.close(); f.close() + } + + @Test + fun aServerErrorDuringDiscoveryOffersTheFormAndARetry() = runTest(dispatcher) { + val f = fixture() + f.providers = ApiResult.Error(502, "", "bad gateway") + val vm = f.viewModel() + advanceUntilIdle() + assertTrue(vm.uiState.value.optionsUnavailable) + assertTrue(vm.uiState.value.showPasswordForm) + vm.close(); f.close() + } + + @Test + fun noPasswordFormWhileDiscoveryRuns() = runTest(dispatcher) { + val f = fixture() + val vm = f.viewModel() + assertNull(vm.uiState.value.options) + assertFalse(vm.uiState.value.showPasswordForm) + advanceUntilIdle() + assertTrue(vm.uiState.value.showPasswordForm) + vm.close(); f.close() + } + + /** + * A server saved by its LAN address: the native start opens on the LAN + * base the app saved, and the flow is bound to the LAN origin. + */ + @Test + fun aLanSavedServerOpensTheNativeStartOnItsLanAddress() = runTest(dispatcher) { + val f = fixture(serverUrl = "http://192.168.1.10:8096") + val vm = f.viewModel() + advanceUntilIdle() + vm.onProviderClick(keycloak) + advanceUntilIdle() + assertTrue( + vm.uiState.value.browserLaunch.orEmpty().startsWith("http://192.168.1.10:8096/api/v2/auth/oauth/5/native/start?"), + vm.uiState.value.browserLaunch, + ) + assertNull(vm.uiState.value.error) + assertEquals("http://192.168.1.10:8096", f.store.load()?.startOrigin) + assertEquals(listOf("http://192.168.1.10:8096"), f.registry.entries.value.map { it.url }) + vm.close(); f.close() + } + + @Test + fun useADifferentAccountAsksTheProviderToOfferAnother() = runTest(dispatcher) { + val f = fixture() + val vm = f.viewModel() + advanceUntilIdle() + assertTrue(vm.uiState.value.offersAccountChoice) + vm.onUseDifferentAccount() + advanceUntilIdle() + assertTrue(vm.uiState.value.browserLaunch.orEmpty().contains("prompt=select_account")) + vm.onBrowserLaunchHandled(opened = true) + vm.close() + + // Several providers: the screen asks which one first. + val other = keycloak.copy(id = "plugin:6:oidc", displayName = "Authentik", installationId = "6") + val two = fixture(providers = listOf(keycloak, other)) + val chooser = two.viewModel() + advanceUntilIdle() + chooser.onUseDifferentAccount() + assertTrue(chooser.uiState.value.choosingAccountProvider) + assertNull(chooser.uiState.value.browserLaunch) + chooser.onAccountProviderChosen(other) + advanceUntilIdle() + assertFalse(chooser.uiState.value.choosingAccountProvider) + assertTrue(chooser.uiState.value.browserLaunch.orEmpty().contains("prompt=select_account")) + chooser.close(); two.close() + + // Not offered where the server doesn't take it. + f.handshake = ApiResult.Success(native.copy(selectAccount = false)) + val older = f.viewModel() + advanceUntilIdle() + assertFalse(older.uiState.value.offersAccountChoice) + older.onUseDifferentAccount() + advanceUntilIdle() + assertNull(older.uiState.value.browserLaunch) + older.close(); f.close() + } + + /** After an explicit sign-out, not after a session that expired. */ + @Test + fun anExplicitSignOutAsksForAnAccountOnce() = runTest(dispatcher) { + val f = fixture() + val vm = f.viewModel() + advanceUntilIdle() + vm.onProviderClick(keycloak) + advanceUntilIdle() + assertFalse(vm.uiState.value.browserLaunch.orEmpty().contains("prompt="), "an expired session never asks") + vm.onBrowserLaunchHandled(opened = true) + + f.coordinator.requestAccountChoice("a") + vm.onProviderClick(keycloak) + advanceUntilIdle() + val state = vm.uiState.value.browserLaunch.orEmpty().also { assertTrue(it.contains("prompt=select_account")) } + .substringAfter("app_state=").substringBefore('&') + vm.onBrowserLaunchHandled(opened = true) + f.coordinator.finish( + NativeSignInCallback(code = "c", state = state, server = "srv-1", error = null, link = false, iss = "https://prairie.example.test"), + ) + advanceUntilIdle() + assertFalse(f.coordinator.accountChoiceRequested("a"), "signing in ends it") + vm.close(); f.close() + } + + @Test + fun switchAccountAddsSelectAccountWhenTheServerTakesIt() = runTest(dispatcher) { + val f = fixture() + val vm = f.viewModel() + advanceUntilIdle() + vm.onProviderClick(keycloak) + advanceUntilIdle() + assertFalse(vm.uiState.value.browserLaunch.orEmpty().contains("prompt=")) + vm.onBrowserLaunchHandled(opened = true) + + f.coordinator.requestAccountChoice("a") + vm.onProviderClick(keycloak) + advanceUntilIdle() + assertTrue(vm.uiState.value.browserLaunch.orEmpty().contains("prompt=select_account")) + vm.onBrowserLaunchHandled(opened = true) + vm.close() + + // A server that doesn't advertise it never gets it. + f.handshake = ApiResult.Success(native.copy(selectAccount = false)) + val older = f.viewModel() + advanceUntilIdle() + older.onProviderClick(keycloak) + advanceUntilIdle() + assertFalse(older.uiState.value.browserLaunch.orEmpty().contains("prompt=")) + older.close(); f.close() + } + + /** + * "Not you? Switch account" where the provider takes `select_account`: + * the login screen starts that provider's sign-in by itself, once. + */ + @Test + fun switchAccountStartsTheOnlyProvidersSignInByItself() = runTest(dispatcher) { + val f = fixture() + f.coordinator.requestAccountChoice("a", autoStart = true) + val vm = f.viewModel() + advanceUntilIdle() + assertTrue(vm.uiState.value.browserLaunch.orEmpty().contains("prompt=select_account"), vm.uiState.value.browserLaunch) + vm.onBrowserLaunchHandled(opened = true) + vm.close() + + val again = f.viewModel() + advanceUntilIdle() + assertNull(again.uiState.value.browserLaunch, "only once per request") + again.close() + + // A plain sign-out asks for an account but opens nothing by itself. + f.coordinator.requestAccountChoice("a") + val plain = f.viewModel() + advanceUntilIdle() + assertNull(plain.uiState.value.browserLaunch) + plain.close(); f.close() + + // Several providers: the person picks one. + val other = keycloak.copy(id = "plugin:6:oidc", displayName = "Authentik", installationId = "6") + val two = fixture(providers = listOf(keycloak, other)) + two.coordinator.requestAccountChoice("a", autoStart = true) + val chooser = two.viewModel() + advanceUntilIdle() + assertNull(chooser.uiState.value.browserLaunch) + chooser.close(); two.close() + } + + /** The screen went away before the browser opened: the next login screen starts it instead. */ + @Test + fun anAutoStartTheScreenNeverOpenedIsHandedBack() = runTest(dispatcher) { + val f = fixture() + f.coordinator.requestAccountChoice("a", autoStart = true) + val store = ViewModelStore() + val built = f.viewModel() + val vm = ViewModelProvider( + store, + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = built as T + }, + )[LoginViewModel::class.java] + advanceUntilIdle() + assertTrue(vm.uiState.value.browserLaunch.orEmpty().contains("prompt=select_account")) + store.clear() + assertTrue(f.coordinator.consumeAutoStart("a"), "handed back") + + // Not after someone signed in there. + f.coordinator.requestAccountChoice("a", autoStart = true) + f.coordinator.clearAccountChoice("a") + assertFalse(f.coordinator.consumeAutoStart("a")) + f.coordinator.restoreAutoStart("a") + assertFalse(f.coordinator.consumeAutoStart("a")) + f.close() + } + + @Test + fun resultsForAnotherServerAreIgnored() = runTest(dispatcher) { + val f = fixture() + val vm = f.viewModel() + advanceUntilIdle() + val start = f.coordinator.begin( + NativeSignInPurpose.SignIn, "b", "https://prairie.example.test", "srv-1", "Keycloak", keycloak.nativeStartPath!!, + ) + val state = assertIs(start).url.substringAfter("app_state=").substringBefore('&') + f.coordinator.finish( + NativeSignInCallback( + code = null, state = state, server = "srv-1", error = "not_permitted", link = false, iss = "https://prairie.example.test", + ), + ) + advanceUntilIdle() + assertNull(vm.uiState.value.error, "another saved server's failure isn't this screen's") + vm.close(); f.close() + } + + @Test + fun aSignInResultRoutesOnlyWhileItsSessionIsInstalled() = runTest(dispatcher) { + val f = fixture() + val vm = f.viewModel() + advanceUntilIdle() + suspend fun finishOne() { + val start = f.coordinator.begin( + NativeSignInPurpose.SignIn, "a", "https://prairie.example.test", "srv-1", "Keycloak", keycloak.nativeStartPath!!, + ) + val state = assertIs(start).url.substringAfter("app_state=").substringBefore('&') + f.coordinator.finish( + NativeSignInCallback(code = "c", state = state, server = "srv-1", error = null, link = false, iss = "https://prairie.example.test"), + ) + advanceUntilIdle() + } + // Signed out again before the result arrived: nowhere to go. + finishOne() + assertFalse(vm.uiState.value.loginSuccess) + assertFalse(vm.uiState.value.isLoading) + + f.tokens.saveTokens("access", "refresh", 3600) + finishOne() + assertTrue(vm.uiState.value.loginSuccess, "this screen's server is active and its session installed") + vm.onLoginSuccessConsumed() + + // Another saved server became active meanwhile: not this screen's to route. + f.registry.activeServerId.value = "other" + finishOne() + assertFalse(vm.uiState.value.loginSuccess) + vm.close(); f.close() + } + + // --- Network identity ("Continue as …") --- + + /** + * Opened at its Tailscale address, the server lists Tailscale with the + * device's owner: one tap signs in, with no browser and no password, on + * the saved base (path prefix included), and the session lands like a + * password sign-in's. + */ + @Test + fun continueAsSignsInWithoutABrowserOnTheSavedBase() = runTest(dispatcher) { + val f = fixture(serverUrl = "https://prairie.tailnet.ts.net/prairie", providers = listOf(keycloak, tailscale)) + f.networkAnswer = ApiResult.Success(LoginResponse("net-access", "net-refresh", 3600, User("1", "alice", "a@example.test", "user"))) + val vm = f.viewModel() + advanceUntilIdle() + assertEquals(tailscale, vm.uiState.value.networkProvider) + assertEquals(listOf(keycloak), vm.uiState.value.providers, "the network provider is not a browser sign-in") + assertTrue(vm.uiState.value.showPasswordForm, "the password form stays") + assertEquals("Continue as Alice Example", continueAsLabel(tailscale)) + assertEquals("via Tailscale", continueViaLabel(tailscale)) + + vm.onNetworkSignIn() + assertTrue(vm.uiState.value.networkSignInBusy) + advanceUntilIdle() + assertEquals(listOf("https://prairie.tailnet.ts.net/prairie" to "/api/v2/auth/network/7/sign-in"), f.networkCalls) + assertTrue(vm.uiState.value.loginSuccess) + assertFalse(vm.uiState.value.networkSignInBusy) + assertNull(vm.uiState.value.error) + assertEquals("net-access", f.tokens.getAccessToken()) + assertEquals("net-refresh", f.tokens.getRefreshToken()) + assertNull(vm.uiState.value.browserLaunch) + assertNull(f.store.load(), "no native flow was started") + vm.close(); f.close() + } + + @Test + fun aRefusedNetworkSignInSaysWhyAndSavesNothing() = runTest(dispatcher) { + val f = fixture(providers = listOf(tailscale)) + f.networkAnswer = ApiResult.Error(403, "network_identity_required", "raw detail") + val vm = f.viewModel() + advanceUntilIdle() + vm.onNetworkSignIn() + advanceUntilIdle() + assertEquals("Open this server at its Tailscale address to sign in this way.", vm.uiState.value.error) + assertFalse(vm.uiState.value.loginSuccess) + assertFalse(vm.uiState.value.networkSignInBusy, "the button can be pressed again") + assertNull(f.tokens.getAccessToken()) + + f.networkAnswer = ApiResult.NetworkError(RuntimeException("offline")) + vm.onNetworkSignIn() + advanceUntilIdle() + assertEquals("Network error. Please check your connection.", vm.uiState.value.error) + assertEquals(2, f.networkCalls.size) + vm.close(); f.close() + } + + /** Reloading the options while "Continue as …" waits doesn't free the other ways in. */ + @Test + fun theOtherWaysInWaitForContinueAsAcrossAnOptionsReload() = runTest(dispatcher) { + val f = fixture(providers = listOf(keycloak, tailscale)) + val answer = CompletableDeferred() + f.networkGate = answer + val vm = f.viewModel() + advanceUntilIdle() + vm.onNetworkSignIn() + advanceUntilIdle() + + vm.loadOptions() + vm.onUsernameChanged("alice") + vm.onPasswordChanged("pw") + vm.onLoginClick() + assertTrue(vm.uiState.value.networkSignInBusy) + assertTrue(vm.uiState.value.signInBusy, "the password form is disabled while it waits") + assertFalse(vm.uiState.value.isLoading, "no password sign-in starts while the options reload") + advanceUntilIdle() + vm.onProviderClick(keycloak) + assertNull(vm.uiState.value.providerBusy, "no browser sign-in starts either") + + answer.complete(Unit) + advanceUntilIdle() + assertFalse(vm.uiState.value.networkSignInBusy) + assertFalse(vm.uiState.value.signInBusy) + assertEquals(1, f.networkCalls.size) + vm.close(); f.close() + } + + @Test + fun noContinueAsWithoutANetworkProvider() = runTest(dispatcher) { + val f = fixture(providers = listOf(keycloak, tailscale.copy(networkSignInPath = null))) + val vm = f.viewModel() + advanceUntilIdle() + assertNull(vm.uiState.value.networkProvider) + vm.onNetworkSignIn() + advanceUntilIdle() + assertTrue(f.networkCalls.isEmpty()) + vm.close(); f.close() + } + + @Test + fun theButtonNamesTheProviderWhenItNamesNobody() { + val nobody = tailscale.copy(networkIdentity = NetworkIdentity("", "")) + assertEquals("Continue with Tailscale", continueAsLabel(nobody)) + assertNull(continueViaLabel(nobody)) + assertEquals("Continue as alice", continueAsLabel(tailscale.copy(networkIdentity = NetworkIdentity("", "alice")))) + } + + @Test + fun networkSignInRefusalsHaveTheirOwnText() { + val cases = listOf( + 403 to "network_identity_required", + 403 to "not_permitted", + 403 to "account_required", + 403 to "permission_denied", + 409 to "email_in_use", + 409 to "identity_linked_elsewhere", + 404 to "not_found", + 503 to "provider_unavailable", + 429 to "rate_limited", + 500 to "internal", + ) + val texts = cases.map { (status, code) -> networkSignInMessage(ApiResult.Error(status, code, "server detail"), "Tailscale") } + assertEquals(texts.size, texts.toSet().size, texts.joinToString("\n")) + texts.forEach { assertFalse(it.contains("server detail"), "the server's detail is never shown") } + assertEquals("Tailscale doesn't allow this device on this server.", texts[1]) + assertEquals( + "An account with your email already exists. Sign in with your password, then connect Tailscale in Settings → Sign-in.", + texts[4], + ) + // Refusals other operations share read the same here. + assertEquals(NativeSignInMessages.forReason(NativeSignInMessages.ACCOUNT_REQUIRED), texts[2]) + assertEquals(NativeSignInMessages.forReason(NativeSignInMessages.RATE_LIMITED), texts[8]) + assertEquals(NativeSignInMessages.forReason("identity_linked_elsewhere", "Tailscale"), texts[5]) + } + + @Test + fun passwordRefusalsFromADirectoryHaveTheirOwnText() { + assertEquals( + NativeSignInMessages.forReason("email_in_use", "Directory"), + passwordLoginMessage(org.prairieserver.prairie.network.ApiResult.Error(409, "email_in_use", "raw"), directoryName = "Directory"), + ) + assertEquals( + NativeSignInMessages.forReason("identity_linked_elsewhere", "Directory"), + passwordLoginMessage(ApiResult.Error(409, "identity_linked_elsewhere", "raw"), directoryName = "Directory"), + ) + assertEquals( + NativeSignInMessages.forReason(NativeSignInMessages.RATE_LIMITED), + passwordLoginMessage(ApiResult.Error(429, "", "raw")), + ) + } + + /** Signs in whatever it is given; the tests look at routing, not redemption. */ + private class SignsIn : NativeSignInCompleter { + override suspend fun signIn(pending: PendingNativeSignIn, code: String): ApiResult = + ApiResult.Success(User("1", "alice", "a@example.test", "user")) + override suspend fun link(pending: PendingNativeSignIn, code: String): ApiResult = ApiResult.Success(Unit) + } + + private class OneServerRegistry(entry: ServerEntry) : ServerRegistry { + private val active = MutableStateFlow(entry) + override val entries: StateFlow> = MutableStateFlow(listOf(entry)) + override val activeServerId = MutableStateFlow(entry.id) + override val activeEntry: StateFlow = active + override suspend fun addOrUpdate(url: String, fetchedName: String?): String = active.value!!.id + override suspend fun rename(serverId: String, userOverrideName: String?) = Unit + override suspend fun setFetchedName(serverId: String, fetchedName: String?) = Unit + override suspend fun setProfileId(serverId: String, profileId: String?) = Unit + override suspend fun remove(serverId: String) = Unit + override suspend fun signOut(serverId: String) = Unit + override suspend fun switchTo(serverId: String) = Unit + override suspend fun touchActive() = Unit + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/collections/CollectionDetailViewModelTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/collections/CollectionDetailViewModelTest.kt index 0692875355..0002f6fd75 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/collections/CollectionDetailViewModelTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/collections/CollectionDetailViewModelTest.kt @@ -66,7 +66,7 @@ class CollectionDetailViewModelTest { } val viewModel = fixture.viewModel( - SavedStateHandle(mapOf("libraryId" to "7", "source" to "user_collection")), + SavedStateHandle(mapOf("libraryId" to "7", "source" to "user_collection", "mediaScope" to "series")), ) fixture.store.put("detail", viewModel) viewModel.initialize("user-1") @@ -75,13 +75,16 @@ class CollectionDetailViewModelTest { assertEquals("User Picks", viewModel.uiState.value.title) assertEquals(2, viewModel.uiState.value.total) assertFalse(viewModel.uiState.value.canManage) + // A shuffle request can't carry the Series scope, so it is not offered. + assertEquals(null, viewModel.shuffleScopeKind) viewModel.loadMore() - awaitState(viewModel) { !it.isLoadingMore && !it.hasMore } + awaitState(viewModel) { !it.isLoadingMore } + assertFalse(viewModel.uiState.value.hasMore, "${viewModel.uiState.value.error}; requests=$requests") assertEquals(2, requests.count { it.path == "/api/v2/catalog" }) assertTrue(requests.filter { it.path == "/api/v2/catalog" }.all { - it.parameters["source"] == "user_collection" + it.parameters["source"] == "user_collection" && it.parameters["type"] == "series" }) viewModel.refresh() @@ -91,6 +94,7 @@ class CollectionDetailViewModelTest { val refresh = requests.last { it.path == "/api/v2/catalog" } assertEquals("user_collection", refresh.parameters["source"]) + assertEquals("series", refresh.parameters["type"]) assertFalse("cursor" in refresh.parameters) } @@ -107,6 +111,8 @@ class CollectionDetailViewModelTest { "/api/v2/catalog" -> { assertEquals("library_collection", parameters["source"]) assertEquals("regular-1", parameters["collection_id"]) + assertEquals("movie", parameters["type"]) + assertEquals("7", parameters["library_id"]) catalogBody(total = 1, hasMore = false) } else -> error("Unexpected path $path") @@ -114,16 +120,18 @@ class CollectionDetailViewModelTest { } val viewModel = fixture.viewModel( - SavedStateHandle(mapOf("libraryId" to "7", "source" to "library_collection")), + SavedStateHandle(mapOf("libraryId" to "7", "source" to "library_collection", "mediaScope" to "movie")), ) fixture.store.put("detail", viewModel) viewModel.initialize("regular-1") awaitState(viewModel) { !it.isLoading } + assertEquals(null, viewModel.uiState.value.error) assertEquals("Server Picks", viewModel.uiState.value.title) assertEquals(1, viewModel.uiState.value.total) assertFalse(viewModel.uiState.value.canManage) assertEquals("library_collection", requests.single { it.path == "/api/v2/catalog" }.parameters["source"]) + assertEquals(null, viewModel.shuffleScopeKind) } @Test @@ -151,6 +159,7 @@ class CollectionDetailViewModelTest { assertEquals("Personal Picks", viewModel.uiState.value.title) assertTrue(viewModel.uiState.value.canManage) assertEquals("user_collection", requests.single { it.path == "/api/v2/catalog" }.parameters["source"]) + assertEquals(org.prairieserver.prairie.model.shuffle.ShuffleScopeKind.USER_COLLECTION, viewModel.shuffleScopeKind) } private fun runCollectionTest(block: suspend (MutableList) -> Unit) = runTest { @@ -223,7 +232,7 @@ class CollectionDetailViewModelTest { { "items":[], "page":{"has_more":$hasMore,"next_cursor":${nextCursor?.let { "\"$it\"" } ?: "null"}}, - "total":$total + "total":$total,"total_exact":true,"window_cursor":"window" } """.trimIndent() diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MobileDetailActionsTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MobileDetailActionsTest.kt index 5c7126802c..8e70adb2a0 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MobileDetailActionsTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MobileDetailActionsTest.kt @@ -5,17 +5,22 @@ import org.prairieserver.prairie.model.catalog.EpisodeListItem import org.prairieserver.prairie.model.catalog.ItemDetail import org.prairieserver.prairie.model.catalog.LeafItemUserData import org.prairieserver.prairie.model.catalog.Season +import org.prairieserver.prairie.model.download.DownloadRecord import org.prairieserver.prairie.model.download.DownloadsListResponse import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.AuthScopeSnapshot import org.prairieserver.prairie.network.PrairieJson import org.prairieserver.prairie.network.TokenManager import org.prairieserver.prairie.network.TokenManagerImpl +import org.prairieserver.prairie.repository.port.CatalogCachePort import org.prairieserver.prairie.repository.port.NoOpUserItemStatePort import org.prairieserver.prairie.repository.port.PersonalWrite import org.prairieserver.prairie.repository.port.PersonalWriteHandle import org.prairieserver.prairie.repository.port.UserItemStatePort import org.prairieserver.prairie.network.api.CatalogApi +import org.prairieserver.prairie.network.api.MetadataAiApi +import org.prairieserver.prairie.model.metadata.MetadataAiStatus +import org.prairieserver.prairie.model.metadata.MetadataTranslationJob import org.prairieserver.prairie.network.api.DownloadsApi import org.prairieserver.prairie.network.api.EbookReaderApi import org.prairieserver.prairie.network.api.PersonalDataApi @@ -58,6 +63,122 @@ import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class MobileDetailActionsTest { + @Test + fun delayedTranslationDoesNotRestoreANewerReloadsRemovedEpisodes() = runItemDetailTest { + assertTranslationPreservesReload(reloadAdmittedFirst = false) + } + + @Test + fun translationStartedDuringReloadPreservesThePublishedReload() = runItemDetailTest { + assertTranslationPreservesReload(reloadAdmittedFirst = true) + } + + @Test + fun delayedTranslationDoesNotRepopulateASeasonInvalidatedAfterAWatchedWrite() = runItemDetailTest { + val translationRead = CompletableDeferred() + var seasonOneReads = 0 + val client = dummyHttpClient(StandardTestDispatcher(testScheduler)) { request -> + when { + request.url.encodedPath.endsWith("/seasons/1/episodes") -> { + if (++seasonOneReads == 1) { + translationRead.await() + respond( + """{"items":[{"content_id":"season-1-episode-1","season_number":1,"episode_number":1,"user_data":{"played":false}}]}""", + HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json"), + ) + } else respond("", HttpStatusCode.ServiceUnavailable) + } + request.url.encodedPath.endsWith("/seasons/2/episodes") -> respond( + """{"items":[{"content_id":"season-2-episode-1","season_number":2,"episode_number":1}]}""", + HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json"), + ) + else -> respond("{}") + } + } + val viewModel = itemDetailViewModel( + RecordingPersonalDataRepository(mutableListOf(), engineDispatcher = StandardTestDispatcher(testScheduler)), + catalogRepository = CatalogRepository(CatalogApi(client)), + metadataApi = acceptingTranslationApi(), + ) + viewModel.seedSeriesDetail(listOf(EpisodeListItem( + "season-1-episode-1", 1, 1, pendingTranslationLanguage = "de", + ))) + viewModel.translateShownSeasonEpisodes() + advanceUntilIdle() + assertEquals(1, seasonOneReads) + viewModel.selectSeason(2) + advanceUntilIdle() + viewModel.setSeasonWatched(viewModel.uiState.value.seasons.first(), true) + advanceUntilIdle() + assertFalse(1 in viewModel.uiState.value.episodesBySeason) + + translationRead.complete(Unit) + advanceUntilIdle() + assertFalse(1 in viewModel.uiState.value.episodesBySeason) + assertEquals(2, viewModel.uiState.value.selectedSeasonNumber) + } + + private fun acceptingTranslationApi() = object : MetadataAiApi { + override suspend fun status() = ApiResult.Success(MetadataAiStatus()) + override suspend fun translateDescription( + contentId: String, + targetLanguage: String, + scope: AuthScopeSnapshot?, + ) = ApiResult.Success(MetadataTranslationJob("job", "season", contentId, targetLanguage, "pending")) + } + + private suspend fun kotlinx.coroutines.test.TestScope.assertTranslationPreservesReload(reloadAdmittedFirst: Boolean) { + val translationRead = CompletableDeferred() + val reloadRead = CompletableDeferred() + val translationReadNumber = if (reloadAdmittedFirst) 2 else 1 + var episodeReads = 0 + val client = dummyHttpClient(StandardTestDispatcher(testScheduler)) { request -> + when { + request.url.encodedPath.endsWith("/seasons/1/episodes") -> { + val admitted = ++episodeReads + val response = if (admitted == translationReadNumber) { + translationRead.await() + """{"items":[{"content_id":"season-1-episode-1","season_number":1,"episode_number":1,"overview":"Older"},{"content_id":"removed-episode","season_number":1,"episode_number":2}]}""" + } else { + reloadRead.await() + """{"items":[{"content_id":"season-1-episode-1","season_number":1,"episode_number":1,"overview":"Newer"}]}""" + } + respond(response, HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + } + else -> respond("{}") + } + } + val viewModel = itemDetailViewModel( + RecordingPersonalDataRepository(mutableListOf(), engineDispatcher = StandardTestDispatcher(testScheduler)), + catalogRepository = CatalogRepository(CatalogApi(client)), + metadataApi = acceptingTranslationApi(), + ) + viewModel.seedSeriesDetail(listOf(EpisodeListItem( + "season-1-episode-1", 1, 1, pendingTranslationLanguage = "de", + ))) + if (reloadAdmittedFirst) { + viewModel.refreshOnReturn() + advanceUntilIdle() + assertEquals(1, episodeReads) + } + viewModel.translateShownSeasonEpisodes() + advanceUntilIdle() + assertEquals(translationReadNumber, episodeReads) + + if (!reloadAdmittedFirst) { + viewModel.refreshOnReturn() + advanceUntilIdle() + } + reloadRead.complete(Unit) + advanceUntilIdle() + assertEquals("Newer", viewModel.uiState.value.episodes.single().overview) + translationRead.complete(Unit) + advanceUntilIdle() + assertEquals(listOf("season-1-episode-1"), viewModel.uiState.value.episodes.map { it.contentId }) + assertEquals("Newer", viewModel.uiState.value.episodes.single().overview) + assertEquals(viewModel.uiState.value.episodes, viewModel.uiState.value.episodesBySeason[1]) + } + @Test fun watchedTogglePersistsOptimisticStateOnSuccess() = runItemDetailTest { val repository = RecordingPersonalDataRepository( @@ -167,6 +288,301 @@ class MobileDetailActionsTest { assertEquals(true, viewModel.uiState.value.episodes.single().userData?.played) } + @Test + fun markingSeasonWatchedWritesTheSeasonAndRefreshesWhatItChanged() = runItemDetailTest { + val repository = RecordingPersonalDataRepository( + mutableListOf({ ApiResult.Success(Unit) }), + engineDispatcher = StandardTestDispatcher(testScheduler), + ) + val catalogRequests = mutableListOf() + val viewModel = itemDetailViewModel(repository, recordingCatalogRepository(catalogRequests)) + viewModel.seedSeriesDetail() + // uiState is a derived flow; let it observe the seeded state. + runCurrent() + + viewModel.setSeasonWatched(viewModel.uiState.value.seasons[1], true) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(listOf("/api/v2/watched/season-2"), repository.watchedPaths) + assertEquals(listOf(true), repository.watchedCalls) + assertEquals(true, state.seasons[1].userData?.played) + assertFalse(state.seasons[0].userData?.played == true) + assertFalse(state.episodes.single().userData?.played == true) + assertTrue("/api/v2/catalog/series/series-1/seasons" in catalogRequests) + assertTrue("/api/v2/catalog/series/series-1/seasons/1/episodes" in catalogRequests) + // The changed season reloads when it is next selected; the visible one is kept. + assertEquals(setOf(1), state.episodesBySeason.keys) + } + + @Test + fun markingVisibleSeasonWatchedChecksItsEpisodes() = runItemDetailTest { + val repository = RecordingPersonalDataRepository( + mutableListOf({ ApiResult.Success(Unit) }), + engineDispatcher = StandardTestDispatcher(testScheduler), + ) + val viewModel = itemDetailViewModel(repository, recordingCatalogRepository(mutableListOf())) + viewModel.seedSeriesDetail() + // uiState is a derived flow; let it observe the seeded state. + runCurrent() + + viewModel.setSeasonWatched(viewModel.uiState.value.seasons[0], true) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(listOf("/api/v2/watched/season-1"), repository.watchedPaths) + assertEquals(true, state.episodes.single().userData?.played) + assertEquals(true, state.episodesBySeason.getValue(1).single().userData?.played) + // Season 2 was not changed, so its cached episodes stay. + assertFalse(state.episodesBySeason.getValue(2).single().userData?.played == true) + } + + @Test + fun failedSeasonWatchedRestoresTheSeasonAndItsEpisodes() = runItemDetailTest { + val repository = RecordingPersonalDataRepository( + mutableListOf({ ApiResult.Error(500, "failed", "season failed") }), + engineDispatcher = StandardTestDispatcher(testScheduler), + ) + val catalogRequests = mutableListOf() + val viewModel = itemDetailViewModel(repository, recordingCatalogRepository(catalogRequests)) + viewModel.seedSeriesDetail() + // uiState is a derived flow; let it observe the seeded state. + runCurrent() + + viewModel.setSeasonWatched(viewModel.uiState.value.seasons[0], true) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(listOf("/api/v2/watched/season-1"), repository.watchedPaths) + assertFalse(state.seasons[0].userData?.played == true) + assertFalse(state.episodes.single().userData?.played == true) + assertFalse(state.episodesBySeason.getValue(1).single().userData?.played == true) + assertEquals(emptyList(), catalogRequests) + } + + @Test + fun failedSeasonWatchedKeepsEpisodesMarkedWhileItWasPending() = runItemDetailTest { + val repository = RecordingPersonalDataRepository( + mutableListOf( + { + delay(100) + ApiResult.Error(500, "failed", "season failed") + }, + { ApiResult.Success(Unit) }, + ), + engineDispatcher = StandardTestDispatcher(testScheduler), + ) + val viewModel = itemDetailViewModel(repository, recordingCatalogRepository(mutableListOf())) + viewModel.seedSeriesDetail( + seasonOneEpisodes = listOf( + EpisodeListItem(contentId = "s1e1", seasonNumber = 1, episodeNumber = 1), + EpisodeListItem( + contentId = "s1e2", + seasonNumber = 1, + episodeNumber = 2, + userData = LeafItemUserData(played = true), + ), + ), + ) + runCurrent() + + viewModel.setSeasonWatched(viewModel.uiState.value.seasons[0], true) + runCurrent() + viewModel.setEpisodeWatched("s1e2", false) + advanceUntilIdle() + + assertEquals(listOf("/api/v2/watched/season-1", "/api/v2/watched/s1e2"), repository.watchedPaths) + val state = viewModel.uiState.value + assertFalse(state.seasons[0].userData?.played == true) + val played = state.episodes.associate { it.contentId to (it.userData?.played == true) } + // E1 reverts with the failed season write; E2 keeps its own successful change. + assertEquals(mapOf("s1e1" to false, "s1e2" to false), played) + } + + @Test + fun failedSeasonAndEpisodeWritesBothRollBack() = runItemDetailTest { + val repository = RecordingPersonalDataRepository( + mutableListOf( + { + delay(100) + ApiResult.Error(500, "failed", "season failed") + }, + { ApiResult.Error(500, "failed", "episode failed") }, + ), + engineDispatcher = StandardTestDispatcher(testScheduler), + ) + val viewModel = itemDetailViewModel(repository, recordingCatalogRepository(mutableListOf())) + viewModel.seedSeriesDetail() + runCurrent() + + viewModel.setSeasonWatched(viewModel.uiState.value.seasons[0], true) + runCurrent() + viewModel.setEpisodeWatched("season-1-episode-1", false) + advanceUntilIdle() + + // The episode's failed write rolls back to the season's optimistic state; + // the season rollback must then still undo that. + assertFalse(viewModel.uiState.value.episodes.single().userData?.played == true) + assertFalse(viewModel.uiState.value.seasons[0].userData?.played == true) + } + + @Test + fun markingSeasonWatchedClearsTheVisibleResumePoint() = runItemDetailTest { + val repository = RecordingPersonalDataRepository( + mutableListOf({ ApiResult.Success(Unit) }), + engineDispatcher = StandardTestDispatcher(testScheduler), + ) + // Every catalog read fails, so the post-write refresh cannot correct the list. + val viewModel = itemDetailViewModel(repository, recordingCatalogRepository(mutableListOf())) + viewModel.seedSeriesDetail( + seasonOneEpisodes = listOf( + EpisodeListItem( + contentId = "s1e1", + seasonNumber = 1, + episodeNumber = 1, + userData = LeafItemUserData(isInProgress = true, positionSeconds = 120.0, durationSeconds = 1000.0), + ), + ), + ) + runCurrent() + + viewModel.setSeasonWatched(viewModel.uiState.value.seasons[0], true) + advanceUntilIdle() + + val episode = viewModel.uiState.value.episodes.single() + assertEquals(true, episode.userData?.played) + assertEquals(null, episode.userData?.positionSeconds) + assertFalse(episode.userData?.isInProgress == true) + } + + @Test + fun failedSeasonWriteDoesNotUndoANewerSeriesWrite() = runItemDetailTest { + val repository = RecordingPersonalDataRepository( + mutableListOf( + { + delay(100) + ApiResult.Error(500, "failed", "season failed") + }, + { ApiResult.Success(Unit) }, + ), + engineDispatcher = StandardTestDispatcher(testScheduler), + ) + val viewModel = itemDetailViewModel( + repository, + recordingCatalogRepository(mutableListOf()), + contentId = "series-1", + ) + viewModel.seedSeriesDetail() + runCurrent() + + viewModel.setSeasonWatched(viewModel.uiState.value.seasons[0], true) + runCurrent() + viewModel.toggleWatched() + advanceUntilIdle() + + assertEquals(listOf("/api/v2/watched/season-1", "/api/v2/watched/series-1"), repository.watchedPaths) + val state = viewModel.uiState.value + assertEquals(true, state.detail?.userData?.played) + assertEquals(true, state.seasons[0].userData?.played) + assertEquals(true, state.episodes.single().userData?.played) + } + + @Test + fun seriesWriteAppliesToAnEpisodeWhoseNewerWriteFailed() = runItemDetailTest { + val repository = RecordingPersonalDataRepository( + mutableListOf( + { + delay(100) + ApiResult.Success(Unit) + }, + { ApiResult.Error(500, "failed", "episode failed") }, + ), + engineDispatcher = StandardTestDispatcher(testScheduler), + ) + // Every catalog read fails, so only the series callback can update the episode. + val viewModel = itemDetailViewModel( + repository, + recordingCatalogRepository(mutableListOf()), + contentId = "series-1", + ) + viewModel.seedSeriesDetail() + runCurrent() + + viewModel.toggleWatched() + runCurrent() + viewModel.setEpisodeWatched("season-1-episode-1", true) + advanceUntilIdle() + + assertEquals(listOf("/api/v2/watched/series-1", "/api/v2/watched/season-1-episode-1"), repository.watchedPaths) + assertEquals(true, viewModel.uiState.value.episodes.single().userData?.played) + } + + @Test + fun seriesWriteKeepsAnEpisodeWrittenSuccessfullyWhileItWasPending() = runItemDetailTest { + val repository = RecordingPersonalDataRepository( + mutableListOf( + { + delay(100) + ApiResult.Success(Unit) + }, + { ApiResult.Success(Unit) }, + { ApiResult.Error(500, "failed", "episode failed") }, + ), + engineDispatcher = StandardTestDispatcher(testScheduler), + ) + val viewModel = itemDetailViewModel( + repository, + recordingCatalogRepository(mutableListOf()), + contentId = "series-1", + ) + viewModel.seedSeriesDetail( + seasonOneEpisodes = listOf( + EpisodeListItem( + contentId = "s1e1", + seasonNumber = 1, + episodeNumber = 1, + userData = LeafItemUserData(played = true), + ), + ), + ) + runCurrent() + + viewModel.toggleWatched() + runCurrent() + viewModel.setEpisodeWatched("s1e1", false) + runCurrent() + viewModel.setEpisodeWatched("s1e1", true) + advanceUntilIdle() + + // The successful unmark stands; the failed re-mark and the later series + // response do not overwrite it. + assertFalse(viewModel.uiState.value.episodes.single().userData?.played == true) + } + + @Test + fun markingSeriesWatchedRefreshesSeasonsAndVisibleEpisodes() = runItemDetailTest { + val repository = RecordingPersonalDataRepository( + mutableListOf({ ApiResult.Success(Unit) }), + engineDispatcher = StandardTestDispatcher(testScheduler), + ) + val catalogRequests = mutableListOf() + val viewModel = itemDetailViewModel( + repository, + recordingCatalogRepository(catalogRequests), + contentId = "series-1", + ) + viewModel.seedSeriesDetail() + + viewModel.toggleWatched() + advanceUntilIdle() + + assertEquals(listOf("/api/v2/watched/series-1"), repository.watchedPaths) + assertEquals(true, viewModel.uiState.value.detail?.userData?.played) + assertTrue("/api/v2/catalog/series/series-1/seasons" in catalogRequests) + assertTrue("/api/v2/catalog/series/series-1/seasons/1/episodes" in catalogRequests) + assertEquals(setOf(1), viewModel.uiState.value.episodesBySeason.keys) + } + @Test fun selectingSeriesEpisodeImmediatelyResetsItsPlaybackOverrides() = runItemDetailTest { val viewModel = itemDetailViewModel( @@ -194,6 +610,208 @@ class MobileDetailActionsTest { assertFalse(state.hasExplicitSubtitleSelection) } + @Test + fun accessChangeReloadsARefusedTitleOnceAccessReturns() = runItemDetailTest { + var available = true + val viewModel = itemDetailViewModel( + personalDataRepository = RecordingPersonalDataRepository(mutableListOf()), + catalogRepository = switchableCatalogRepository(available = { available }), + contentId = "movie-1", + ) + advanceUntilIdle() + assertEquals("movie-1", viewModel.uiState.value.detail?.contentId) + + available = false + viewModel.refreshAfterAccessChange() + advanceUntilIdle() + assertEquals(null, viewModel.uiState.value.detail) + + available = true + viewModel.refreshAfterAccessChange() + advanceUntilIdle() + + assertEquals("movie-1", viewModel.uiState.value.detail?.contentId) + assertEquals(null, viewModel.uiState.value.error) + } + + @Test + fun accessChangeOnAStillRefusedTitleDoesNotRepaintItsCachedDetail() = runItemDetailTest { + var available = true + val viewModel = itemDetailViewModel( + personalDataRepository = RecordingPersonalDataRepository(mutableListOf()), + catalogRepository = switchableCatalogRepository( + available = { available }, + cached = ItemDetail(contentId = "movie-1", type = "movie", title = "Cached"), + ), + contentId = "movie-1", + ) + advanceUntilIdle() + + available = false + viewModel.refreshAfterAccessChange() + advanceUntilIdle() + // A second, unrelated access change runs the full load, which paints + // the durable cached copy before the live request is refused again. + viewModel.refreshAfterAccessChange() + advanceUntilIdle() + + assertEquals(null, viewModel.uiState.value.detail) + assertTrue(viewModel.uiState.value.error != null) + } + + @Test + fun accessChangeDuringTheFirstLoadReplacesIt() = runItemDetailTest { + var available = true + val firstResponse = CompletableDeferred() + val viewModel = itemDetailViewModel( + personalDataRepository = RecordingPersonalDataRepository(mutableListOf()), + catalogRepository = switchableCatalogRepository(available = { available }, holdFirst = firstResponse), + contentId = "movie-1", + ) + // The first request is answered under the old policy but held back. + advanceUntilIdle() + assertTrue(viewModel.uiState.value.isLoading) + + available = false + viewModel.refreshAfterAccessChange() + firstResponse.complete(Unit) + advanceUntilIdle() + + assertEquals(null, viewModel.uiState.value.detail) + assertTrue(viewModel.uiState.value.error != null) + } + + @Test + fun aRefusedTitleWithACompletedDownloadKeepsItsCachedDetail() = runItemDetailTest { + val viewModel = itemDetailViewModel( + personalDataRepository = RecordingPersonalDataRepository(mutableListOf()), + catalogRepository = switchableCatalogRepository( + available = { false }, + cached = ItemDetail(contentId = "movie-1", type = "movie", title = "Cached"), + ), + contentId = "movie-1", + downloads = listOf( + DownloadRecord( + id = "download-1", + contentId = "movie-1", + mediaFileId = 7, + kind = "original", + status = "completed", + createdAt = "2026-01-01T00:00:00Z", + ), + ), + ) + advanceUntilIdle() + + // The server deleted the title; its local play and delete actions stay. + assertEquals("movie-1", viewModel.uiState.value.detail?.contentId) + assertTrue(viewModel.uiState.value.error != null) + } + + @Test + fun anOlderQuietRefreshDoesNotRestoreATitleAnAccessChangeRefused() = runItemDetailTest { + var available = true + val returnResponse = CompletableDeferred() + val viewModel = itemDetailViewModel( + personalDataRepository = RecordingPersonalDataRepository(mutableListOf()), + catalogRepository = switchableCatalogRepository( + available = { available }, + holdFirst = returnResponse, + holdRequest = 2, + ), + contentId = "movie-1", + ) + advanceUntilIdle() + // Back from the player: this read is answered under the old policy but held back. + viewModel.refreshOnReturn() + advanceUntilIdle() + + available = false + viewModel.refreshAfterAccessChange() + advanceUntilIdle() + returnResponse.complete(Unit) + advanceUntilIdle() + + assertEquals(null, viewModel.uiState.value.detail) + assertTrue(viewModel.uiState.value.error != null) + } + + @Test + fun aQuietRefreshReplacingAnAccessChangeRefreshStillShowsTheRefusal() = runItemDetailTest { + var available = true + val accessResponse = CompletableDeferred() + val viewModel = itemDetailViewModel( + personalDataRepository = RecordingPersonalDataRepository(mutableListOf()), + catalogRepository = switchableCatalogRepository( + available = { available }, + holdFirst = accessResponse, + holdRequest = 2, + ), + contentId = "movie-1", + ) + advanceUntilIdle() + + available = false + viewModel.refreshAfterAccessChange() + advanceUntilIdle() + // A return refresh replaces the unfinished access-change read. + viewModel.refreshOnReturn() + advanceUntilIdle() + accessResponse.complete(Unit) + advanceUntilIdle() + + assertEquals(null, viewModel.uiState.value.detail) + assertTrue(viewModel.uiState.value.error != null) + } + + /** + * Serves `movie-1` while [available] is true and a 404 problem otherwise. + * [cached] stands in for the durable detail cache, which a refusal does + * not evict. With [holdFirst], detail request number [holdRequest] reads + * [available] when it arrives but answers only once [holdFirst] completes. + */ + private fun kotlinx.coroutines.test.TestScope.switchableCatalogRepository( + available: () -> Boolean, + cached: ItemDetail? = null, + holdFirst: CompletableDeferred? = null, + holdRequest: Int = 1, + ): CatalogRepository { + val dispatcher = StandardTestDispatcher(testScheduler) + var detailRequests = 0 + val client = HttpClient( + MockEngine( + MockEngineConfig().apply { + this.dispatcher = dispatcher + addHandler { request -> + val isDetail = request.url.encodedPath == "/api/v2/catalog/items/movie-1" + val allowed = isDetail && available() + if (isDetail && ++detailRequests == holdRequest) holdFirst?.await() + when { + !isDetail -> respond("{}") + allowed -> respond( + """{"content_id":"movie-1","type":"movie","title":"Movie","cast":[],"crew":[],"versions":[],"subtitles":[]}""", + HttpStatusCode.OK, + headersOf(HttpHeaders.ContentType, "application/json"), + ) + else -> respond( + """{"type":"about:blank","title":"not_found","status":404,"detail":"Item not found"}""", + HttpStatusCode.NotFound, + headersOf(HttpHeaders.ContentType, "application/problem+json"), + ) + } + } + }, + ), + ) { install(ContentNegotiation) { json(PrairieJson) } } + return CatalogRepository( + CatalogApi(client), + catalogCache = object : CatalogCachePort { + override suspend fun getCachedItemDetail(contentId: String): ItemDetail? = cached + }, + requestDispatcher = dispatcher, + ) + } + private val viewModels = mutableListOf() /** @@ -212,6 +830,18 @@ class MobileDetailActionsTest { } } + /** Records catalog paths; every read fails to decode, so state stays as seeded. */ + private fun kotlinx.coroutines.test.TestScope.recordingCatalogRepository( + requests: MutableList, + ): CatalogRepository = CatalogRepository( + CatalogApi( + dummyHttpClient(StandardTestDispatcher(testScheduler)) { request -> + requests += request.url.encodedPath + respond("{}") + }, + ), + ) + /** A catalog client that never answers, so a selection's loading flag stays observable. */ private fun kotlinx.coroutines.test.TestScope.pendingCatalogRepository(): CatalogRepository = CatalogRepository( CatalogApi(dummyHttpClient(StandardTestDispatcher(testScheduler)) { CompletableDeferred().await(); respond("{}") }), @@ -221,34 +851,37 @@ class MobileDetailActionsTest { personalDataRepository: RecordingPersonalDataRepository, catalogRepository: CatalogRepository? = null, contentId: String? = null, + downloads: List = emptyList(), + metadataApi: MetadataAiApi? = null, ): ItemDetailViewModel { val dispatcher = StandardTestDispatcher(testScheduler) return ItemDetailViewModel( catalogRepository = catalogRepository ?: CatalogRepository(CatalogApi(dummyHttpClient(dispatcher))), personalDataRepository = personalDataRepository, - downloadsRepository = DownloadsRepository(EmptyDownloadsApi(dispatcher)), + downloadsRepository = DownloadsRepository(EmptyDownloadsApi(dispatcher, downloads)), downloadEnqueuer = unsafeInstance(), ebookReaderRepository = dummyEbookReaderRepository(dispatcher), recommendationRepository = RecommendationRepository(RecommendationApi(dummyHttpClient(dispatcher))), metadataAiRepository = org.prairieserver.prairie.repository.MetadataAiRepository( - org.prairieserver.prairie.network.api.DefaultMetadataAiApi(dummyHttpClient(dispatcher), gate = org.prairieserver.prairie.network.apiv2.ApiV2Gate.Unrestricted), + metadataApi ?: org.prairieserver.prairie.network.api.DefaultMetadataAiApi(dummyHttpClient(dispatcher), gate = org.prairieserver.prairie.network.apiv2.ApiV2Gate.Unrestricted), ), savedStateHandle = SavedStateHandle(contentId?.let { mapOf("contentId" to it) } ?: emptyMap()), ).also { viewModels += it } } @Suppress("UNCHECKED_CAST") - private fun ItemDetailViewModel.seedSeriesDetail() { - val field = ItemDetailViewModel::class.java.getDeclaredField("_uiState") - field.isAccessible = true - val flow = field.get(this) as MutableStateFlow - val seasonOneEpisodes = listOf( + private fun ItemDetailViewModel.seedSeriesDetail( + seasonOneEpisodes: List = listOf( EpisodeListItem( contentId = "season-1-episode-1", seasonNumber = 1, episodeNumber = 1, ), - ) + ), + ) { + val field = ItemDetailViewModel::class.java.getDeclaredField("_uiState") + field.isAccessible = true + val flow = field.get(this) as MutableStateFlow val seasonTwoEpisodes = listOf( EpisodeListItem( contentId = "season-2-episode-1", @@ -328,6 +961,7 @@ class MobileDetailActionsTest { private val scope: AuthScopeSnapshot, private val engineDispatcher: kotlinx.coroutines.CoroutineDispatcher?, val watchedCalls: MutableList, + val watchedPaths: MutableList, ) : PersonalDataRepository( personalDataApi = PersonalDataApi( HttpClient( @@ -339,6 +973,7 @@ class MobileDetailActionsTest { return@addHandler respond("", HttpStatusCode.NotFound) } watchedCalls += request.method == HttpMethod.Post + watchedPaths += request.url.encodedPath when (val outcome = responses.removeFirstOrNull()?.invoke() ?: ApiResult.Success(Unit)) { is ApiResult.Success -> respond("", HttpStatusCode.NoContent) is ApiResult.Error -> respond( @@ -368,6 +1003,7 @@ class MobileDetailActionsTest { AuthScopeSnapshot("s1", "p1", "https://prairie.example", "pt", identityGeneration = 1), engineDispatcher, mutableListOf(), + mutableListOf(), ) } @@ -375,14 +1011,17 @@ class MobileDetailActionsTest { override suspend fun current(): org.prairieserver.prairie.network.PrairieDeviceMetadata? = null } - private class EmptyDownloadsApi(dispatcher: CoroutineDispatcher) : DownloadsApi( + private class EmptyDownloadsApi( + dispatcher: CoroutineDispatcher, + private val records: List = emptyList(), + ) : DownloadsApi( registry = org.prairieserver.prairie.network.apiv2.DownloadRegistryV2Api(dummyHttpClient(dispatcher), org.prairieserver.prairie.network.TokenManagerImpl(), NoDevices, org.prairieserver.prairie.network.apiv2.ApiV2Gate.Unrestricted), tokens = org.prairieserver.prairie.network.TokenManagerImpl(), creation = org.prairieserver.prairie.network.apiv2.DownloadCreationV2Api(dummyHttpClient(dispatcher), org.prairieserver.prairie.network.TokenManagerImpl(), NoDevices, org.prairieserver.prairie.network.apiv2.DownloadRegistryV2Api(dummyHttpClient(dispatcher), org.prairieserver.prairie.network.TokenManagerImpl(), NoDevices, org.prairieserver.prairie.network.apiv2.ApiV2Gate.Unrestricted), org.prairieserver.prairie.network.apiv2.ApiV2Gate.Unrestricted), ) { override suspend fun list(scope: org.prairieserver.prairie.network.AuthScopeSnapshot?): ApiResult = - ApiResult.Success(DownloadsListResponse()) + ApiResult.Success(DownloadsListResponse(downloads = records)) } private fun dummyEbookReaderRepository(dispatcher: CoroutineDispatcher): EbookReaderRepository { diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/detail/WatchedRollbackTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/detail/WatchedRollbackTest.kt new file mode 100644 index 0000000000..5a608ac012 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/detail/WatchedRollbackTest.kt @@ -0,0 +1,28 @@ +package org.prairieserver.prairie.android.ui.screens.detail + +import org.prairieserver.prairie.model.catalog.LeafItemUserData +import kotlin.test.Test +import kotlin.test.assertEquals + +class WatchedRollbackTest { + private val started = LeafItemUserData(played = false, isInProgress = true, positionSeconds = 300.0, durationSeconds = 3000.0) + + @Test + fun anUntouchedToggleRestoresTheSnapshot() { + assertEquals(started, started.withPlayed(true).withoutPlayed(true, started)) + } + + @Test + fun progressRecordedSinceTheToggleStaysWhileWatchedIsUndone() { + val sampled = started.withPlayed(true).copy(isInProgress = true, positionSeconds = 900.0) + + assertEquals(sampled.copy(played = false), sampled.withoutPlayed(true, started)) + } + + @Test + fun aReloadThatNoLongerShowsTheToggleIsKept() { + val reloaded = LeafItemUserData(played = false, isInProgress = true, positionSeconds = 1200.0) + + assertEquals(reloaded, reloaded.withoutPlayed(true, started)) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/downloads/DownloadArtworkTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/downloads/DownloadArtworkTest.kt new file mode 100644 index 0000000000..c411416e56 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/downloads/DownloadArtworkTest.kt @@ -0,0 +1,84 @@ +package org.prairieserver.prairie.android.ui.screens.downloads + +import org.prairieserver.prairie.model.catalog.LeafItemUserData +import org.prairieserver.prairie.model.download.DownloadMediaType +import org.prairieserver.prairie.model.settings.EpisodeSpoilerPrefs +import org.prairieserver.prairie.repository.port.LocalContentState +import org.prairieserver.prairie.repository.port.LocalPlaybackProgress +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DownloadArtworkTest { + private val prefs = EpisodeSpoilerPrefs(hideImages = true) + private val episode = DownloadItem( + id = "download", contentId = "episode", title = "Episode", posterUrl = "still", + mediaType = DownloadMediaType.TvShow, posterIsEpisodeStill = true, + ) + + @Test + fun unwatchedStillsAndLegacyUnknownArtAreProtected() { + assertTrue(downloadItemHidesArtwork(episode, prefs)) + assertTrue(downloadItemHidesArtwork(episode.copy(posterIsEpisodeStill = null), prefs)) + assertFalse(downloadItemHidesArtwork(episode.copy(posterIsEpisodeStill = false), prefs)) + assertFalse(downloadItemHidesArtwork(episode, EpisodeSpoilerPrefs.NONE)) + assertFalse(downloadItemHidesArtwork(episode.copy(mediaType = DownloadMediaType.Movie), prefs)) + } + + @Test + fun watchedAndStartedEpisodeStillsRemainVisible() { + for (state in listOf( + LeafItemUserData(played = true), + LeafItemUserData(isInProgress = true), + LeafItemUserData(positionSeconds = 1.0), + )) { + assertFalse(downloadItemHidesArtwork(episode.copy(episodeUserData = state), prefs)) + } + } + + @Test + fun markingUnwatchedClearsTheSavedProgressUntilPlaybackStartsAgain() { + val saved = LeafItemUserData(played = true, isInProgress = true, positionSeconds = 30.0) + val reset = overlayDownloadEpisodeUserData(saved, LocalContentState(watched = false, favorite = null), null) + assertTrue(downloadItemHidesArtwork(episode.copy(episodeUserData = reset), prefs)) + val resumed = overlayDownloadEpisodeUserData(saved, + LocalContentState(watched = false, favorite = null), LocalPlaybackProgress(1, 5.0, 60.0)) + assertFalse(downloadItemHidesArtwork(episode.copy(episodeUserData = resumed), prefs)) + } + + @Test + fun currentServerWatchStateReplacesSavedAndAcknowledgedLocalState() { + val watched = LeafItemUserData(played = true, isInProgress = true, positionSeconds = 30.0) + val unwatched = LeafItemUserData(played = false, isInProgress = false, positionSeconds = 0.0) + val reset = resolveDownloadEpisodeUserData(watched, unwatched, + LocalContentState(watched = true, favorite = null), LocalPlaybackProgress(1, 30.0, 60.0), false) + assertTrue(downloadItemHidesArtwork(episode.copy(episodeUserData = reset), prefs)) + val remoteWatched = resolveDownloadEpisodeUserData(unwatched, watched, null, null, false) + assertFalse(downloadItemHidesArtwork(episode.copy(episodeUserData = remoteWatched), prefs)) + val localReset = resolveDownloadEpisodeUserData(watched, watched, + LocalContentState(watched = false, favorite = null), null, true) + assertTrue(downloadItemHidesArtwork(episode.copy(episodeUserData = localReset), prefs)) + } + + @Test + fun groupedHeadersProtectTheEpisodeWhosePosterTheyUse() { + val season = DownloadEntry.Season( + "series", "Series", 1, listOf(DownloadEntry.Single(episode)), "still", null, + ) + val series = DownloadEntry.Series("series", "Series", listOf(season), "still", null) + assertTrue(downloadEntryHidesArtwork(season, prefs)) + assertTrue(downloadEntryHidesArtwork(series, prefs)) + assertFalse(downloadEntryHidesArtwork(series.copy(posterUrl = "series-art"), prefs)) + val safeFirst = season.copy(episodes = listOf( + DownloadEntry.Single(episode.copy(posterIsEpisodeStill = false)), + DownloadEntry.Single(episode.copy(id = "legacy", posterIsEpisodeStill = null)), + )) + assertFalse(downloadEntryHidesArtwork(safeFirst, prefs)) + val otherSeason = season.copy( + seasonNumber = 2, + episodes = listOf(DownloadEntry.Single(episode.copy(id = "s2", posterUrl = "other-still"))), + posterSource = episode, + ) + assertTrue(downloadEntryHidesArtwork(otherSeason, prefs)) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesViewModelTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesViewModelTest.kt index 716af4ccee..27f68cedca 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesViewModelTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesViewModelTest.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModelStore import androidx.lifecycle.viewModelScope import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.MockEngineConfig import io.ktor.client.engine.mock.MockRequestHandleScope import io.ktor.client.engine.mock.respond import io.ktor.client.plugins.contentnegotiation.ContentNegotiation @@ -14,6 +15,7 @@ import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job @@ -21,6 +23,7 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest @@ -36,7 +39,7 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import androidx.test.core.app.ApplicationProvider -import org.prairieserver.prairie.android.ui.screens.browse.BrowsePrefsStore +import org.prairieserver.prairie.common.settings.BrowsePrefsStore import org.prairieserver.prairie.catalog.filter.CatalogFacet import org.prairieserver.prairie.catalog.filter.CatalogFilterState import org.prairieserver.prairie.model.server.ServerEntry @@ -52,6 +55,9 @@ import org.prairieserver.prairie.network.api.SectionApi import org.prairieserver.prairie.repository.CatalogRepository import org.prairieserver.prairie.repository.PersonalDataRepository import org.prairieserver.prairie.repository.SectionRepository +import org.prairieserver.prairie.repository.port.CatalogCachePort +import org.prairieserver.prairie.repository.port.NoOpCatalogCachePort +import org.prairieserver.prairie.model.personal.UserLibrary import kotlin.test.Test import kotlin.test.assertEquals @@ -61,6 +67,148 @@ import kotlin.test.assertEquals @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], application = android.app.Application::class) class LibrariesViewModelTest { + @Test + fun mixedLibraryBrowseUsesMovieScopeByDefault() = runTest { + val fixture = DeferredLibrariesFixture(emptySet(), firstLibraryType = "mixed") + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val viewModel = fixture.viewModel() + val store = ViewModelStore().also { it.put("libraries", viewModel) } + try { + fixture.awaitRequest("sections:1") + viewModel.uiState.first { !it.isLoadingSections } + viewModel.selectTab(LibrariesSubtab.Browse) + viewModel.uiState.first { it.catalogItems.isNotEmpty() } + assertEquals(listOf("movie"), fixture.catalogTypes) + } finally { store.clear(); Dispatchers.resetMain(); fixture.close() } + } + + @Test + fun switchingMixedScopeRejectsOldPagesAndSeparatesSavedFilters() = runTest { + val fixture = DeferredLibrariesFixture(setOf("catalog:1:added_at:desc:movie"), firstLibraryType = "mixed") + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val registry = FakeServerRegistry() + val prefs = BrowsePrefsStore(ApplicationProvider.getApplicationContext(), registry) + prefs.setPreserveEnabled(1, false, "movie") + prefs.setPreserveEnabled(1, true, "movie") + prefs.setPreserveEnabled(1, false, "series") + prefs.setPreserveEnabled(1, true, "series") + val viewModel = fixture.viewModel(prefs) + val store = ViewModelStore().also { it.put("libraries", viewModel) } + try { + fixture.awaitRequest("sections:1") + viewModel.uiState.first { !it.isLoadingSections } + viewModel.selectTab(LibrariesSubtab.Browse) + fixture.awaitRequest("catalog:1:added_at:desc:movie") + val staleRequest = viewModel.onlyActiveRequest() + viewModel.selectMediaScope("series") + viewModel.uiState.first { it.catalogItems.isNotEmpty() } + fixture.complete("catalog:1:added_at:desc:movie", catalogPageBody("stale", hasMore = true)) + staleRequest.join() + assertEquals("series", viewModel.uiState.value.mediaScope) + assertEquals(listOf("immediate"), viewModel.uiState.value.catalogItems.map { it.contentId }) + assertEquals(false, viewModel.uiState.value.catalogHasMore) + viewModel.selectBrowseSort(LibraryBrowseSort.Title) + viewModel.uiState.first { !it.isLoadingCatalog } + viewModel.selectMediaScope("movie") + assertEquals(LibraryBrowseSort.RecentlyAdded, viewModel.uiState.value.browseSort) + viewModel.selectMediaScope("series") + assertEquals(LibraryBrowseSort.Title, viewModel.uiState.value.browseSort) + } finally { store.clear(); Dispatchers.resetMain(); fixture.close() } + } + + @Test + fun switchingLibraryStopsObsoleteRecommendationPagination() = runTest { + assertObsoleteRecommendationStops(switchScope = false) + } + + @Test + fun switchingScopeStopsObsoleteRecommendationPagination() = runTest { + assertObsoleteRecommendationStops(switchScope = true) + } + + private suspend fun kotlinx.coroutines.test.TestScope.assertObsoleteRecommendationStops(switchScope: Boolean) { + val firstPage = "catalog:1:null:asc:null" + val fixture = DeferredLibrariesFixture(setOf(firstPage), firstLibraryType = "mixed", sectionResponse = """ + {"sections":[{"id":"recent","section_type":"recently_added","title":"Recent","total_count":40,"items":[]}]} + """.trimIndent()) + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val viewModel = fixture.viewModel() + val store = ViewModelStore().also { it.put("libraries", viewModel) } + try { + fixture.awaitRequest(firstPage) + val oldRequest = viewModel.onlyActiveRequest() + if (switchScope) { + viewModel.selectTab(LibrariesSubtab.Browse) + viewModel.selectMediaScope("series") + viewModel.uiState.first { !it.isLoadingCatalog } + } else { + viewModel.selectLibrary(2) + viewModel.uiState.first { !it.isLoadingSections } + } + fixture.complete(firstPage, catalogPageBody("old-film", hasMore = true)) + oldRequest.join() + // Unscoped catalog calls belong to the old shelf; the new Browse + // requests have a movie/series type. No second shelf page is allowed. + assertEquals(1, fixture.catalogQueries.count { it["type"] == null }) + assertEquals(true, oldRequest.isCancelled) + } finally { store.clear(); Dispatchers.resetMain(); fixture.close() } + } + + @Test + fun mixedRecommendationsSwitchTypesAndKeepEpisodeProgress() = runTest { + val fixture = DeferredLibrariesFixture(emptySet(), firstLibraryType = "mixed", sectionResponse = """ + {"sections":[{"id":"recent","section_type":"recently_added","title":"Recent","item_limit":20,"total_count":2,"items":[ + {"content_id":"film","type":"movie","title":"Film"}, + {"content_id":"episode","type":"episode","title":"Episode","series_id":"show","position_seconds":12}]}]} + """.trimIndent()) + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val viewModel = fixture.viewModel() + val store = ViewModelStore().also { it.put("libraries", viewModel) } + try { + viewModel.uiState.first { it.sections.isNotEmpty() } + assertEquals(listOf("film"), viewModel.uiState.value.sections.flatMap { it.items }.map { it.contentId }) + viewModel.selectMediaScope("series") + viewModel.uiState.first { it.sections.isNotEmpty() } + val episode = viewModel.uiState.value.sections.single().items.single() + assertEquals("episode", episode.contentId) + assertEquals("show", episode.seriesId) + assertEquals(12.0, episode.positionSeconds) + viewModel.selectLibrary(2) + assertEquals(null, viewModel.uiState.value.mediaScope) + } finally { store.clear(); Dispatchers.resetMain(); fixture.close() } + } + + @Test + fun mixedScopeRemainsOnAnyFilterQueriesAndTheirNextPage() = runTest { + val pageOne = "catalog:1:added_at:desc:series:filtered:0" + val pageTwo = "catalog:1:added_at:desc:series:filtered:1" + val fixture = DeferredLibrariesFixture(setOf(pageOne, pageTwo), firstLibraryType = "mixed") + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val viewModel = fixture.viewModel() + val store = ViewModelStore().also { it.put("libraries", viewModel) } + try { + viewModel.uiState.first { !it.isLoadingLibraries && !it.isLoadingSections } + viewModel.selectMediaScope("series") + viewModel.selectTab(LibrariesSubtab.Browse) + viewModel.uiState.first { it.catalogItems.isNotEmpty() } + viewModel.applyFilterState(CatalogFilterState( + selections = mapOf(CatalogFacet.Genre to setOf("Drama")), matchAll = false, + )) + fixture.awaitRequest(pageOne) + fixture.complete(pageOne, catalogPageBody("first", hasMore = true)) + viewModel.uiState.first { it.catalogHasMore && !it.isLoadingCatalog } + viewModel.loadMoreCatalog() + fixture.awaitRequest(pageTwo) + fixture.complete(pageTwo, catalogPageBody("second", hasMore = false)) + viewModel.uiState.first { !it.isLoadingMoreCatalog && it.catalogItems.size == 2 } + assertEquals(listOf("first", "second"), viewModel.uiState.value.catalogItems.map { it.contentId }) + assertEquals(listOf( + mapOf("type" to "series", "library_id" to "1", "match" to "any", "cursor" to null), + mapOf("type" to "series", "library_id" to "1", "match" to "any", "cursor" to "1"), + ), fixture.catalogQueries.takeLast(2)) + } finally { store.clear(); Dispatchers.resetMain(); fixture.close() } + } + @Test fun recommendedLatePinDoesNotPublish() = runTest { val fixture = DeferredLibrariesFixture(setOf("sections:1")) @@ -306,6 +454,250 @@ class LibrariesViewModelTest { } } + @Test + fun libraryListRecheckRecoversShortOrClearedListsAndKeepsThemOnlyOnTransientFailure() = runTest { + val fixture = DeferredLibrariesFixture( + deferredKeys = emptySet(), + engineDispatcher = StandardTestDispatcher(testScheduler), + ) + fixture.librariesBody = """ + {"items":[{"id":"1","name":"First","type":"movies","sort_order":0}],"page":{"has_more":false}} + """.trimIndent() + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val viewModel = fixture.viewModel(catalogCache = InMemoryLibraryCache()) + val store = ViewModelStore().also { it.put("libraries", viewModel) } + try { + fixture.awaitRequest("libraries") + fixture.awaitRequest("sections:1") + viewModel.uiState.first { !it.isLoadingLibraries && it.libraries.size == 1 } + + // The server now returns the full list: the re-check picks it up + // and keeps the open library selected. + fixture.librariesBody = null + viewModel.refreshLibraryList() + fixture.awaitRequest("libraries") + val recovered = viewModel.uiState.first { !it.isLoadingLibraries && it.libraries.size == 2 } + assertEquals(1, recovered.selectedLibraryId) + + // A failed re-check leaves the list on screen instead of an error. + fixture.librariesStatus = HttpStatusCode.InternalServerError + viewModel.refreshLibraryList() + fixture.awaitRequest("libraries") + val afterFailure = viewModel.uiState.first { !it.isLoadingLibraries } + assertEquals(listOf(1, 2), afterFailure.libraries.map { it.id }) + assertEquals(null, afterFailure.librariesError) + + // Revoked access is not transient: the list clears. + fixture.librariesStatus = HttpStatusCode.Forbidden + viewModel.refreshLibraryList() + fixture.awaitRequest("libraries") + val afterForbidden = viewModel.uiState.first { !it.isLoadingLibraries } + assertEquals(emptyList(), afterForbidden.libraries) + + // A transient failure next must not resurrect the pre-403 list + // from the offline cache. + fixture.librariesStatus = HttpStatusCode.ServiceUnavailable + viewModel.refreshLibraryList() + fixture.awaitRequest("libraries") + assertEquals(emptyList(), viewModel.uiState.first { !it.isLoadingLibraries }.libraries) + + // Access returns: the list recovers and the cleared rows reload, + // even though the selected library is unchanged. + fixture.librariesStatus = HttpStatusCode.OK + viewModel.refreshLibraryList() + fixture.awaitRequest("libraries") + fixture.awaitRequest("sections:1") + assertEquals(1, viewModel.uiState.first { it.libraries.size == 2 }.selectedLibraryId) + } finally { + store.clear() + Dispatchers.resetMain() + fixture.close() + } + } + + @Test + fun libraryListRecheckThatDropsTheSelectedLibraryResetsToTheReplacement() = runTest { + val fixture = DeferredLibrariesFixture(deferredKeys = emptySet()) + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val viewModel = fixture.viewModel() + val store = ViewModelStore().also { it.put("libraries", viewModel) } + try { + fixture.awaitRequest("sections:1") + viewModel.selectLibrary(2) + fixture.awaitRequest("sections:2") + viewModel.selectNamePrefix("B") + viewModel.uiState.first { it.selectedNamePrefix != null } + + // The server stops listing the open library: the re-check falls + // back to the first library with none of library 2's browse state, + // and loads the replacement's content. + fixture.librariesBody = """ + {"items":[{"id":"1","name":"First","type":"movies","sort_order":0}],"page":{"has_more":false}} + """.trimIndent() + viewModel.refreshLibraryList() + fixture.awaitRequest("sections:1") + val state = viewModel.uiState.first { !it.isLoadingLibraries && it.libraries.size == 1 } + assertEquals(1, state.selectedLibraryId) + assertEquals(null, state.selectedNamePrefix) + } finally { + store.clear() + Dispatchers.resetMain() + fixture.close() + } + } + + @Test + fun libraryListRecheckConfirmsAShrinkBeforePublishingIt() = runTest { + val fixture = DeferredLibrariesFixture( + deferredKeys = emptySet(), + engineDispatcher = StandardTestDispatcher(testScheduler), + ) + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val cache = InMemoryLibraryCache() + val viewModel = fixture.viewModel(catalogCache = cache) + val store = ViewModelStore().also { it.put("libraries", viewModel) } + try { + fixture.awaitRequest("libraries") + viewModel.uiState.first { !it.isLoadingLibraries && it.libraries.size == 2 } + + // One transient short response: the confirming read returns the + // full list again, so the switcher keeps both libraries. + fixture.librariesBodyQueue += """ + {"items":[{"id":"1","name":"First","type":"movies","sort_order":0}],"page":{"has_more":false}} + """.trimIndent() + viewModel.refreshLibraryList() + fixture.awaitRequest("libraries") + fixture.awaitRequest("libraries") + val state = viewModel.uiState.first { !it.isLoadingLibraries } + assertEquals(listOf(1, 2), state.libraries.map { it.id }) + + // A short response, then a failed confirming read: neither the + // screen nor the offline cache may take the unconfirmed short list. + fixture.librariesBodyQueue += """ + {"items":[{"id":"1","name":"First","type":"movies","sort_order":0}],"page":{"has_more":false}} + """.trimIndent() + fixture.librariesStatusQueue += listOf(HttpStatusCode.OK, HttpStatusCode.ServiceUnavailable) + viewModel.refreshLibraryList() + fixture.awaitRequest("libraries") + fixture.awaitRequest("libraries") + val afterFailedConfirm = viewModel.uiState.first { !it.isLoadingLibraries } + assertEquals(listOf(1, 2), afterFailedConfirm.libraries.map { it.id }) + assertEquals(listOf(1, 2), cache.libraries?.map { it.id }) + + // A full refresh (the Show Audiobooks toggle) confirms a shrink too. + fixture.librariesBodyQueue += """ + {"items":[{"id":"1","name":"First","type":"movies","sort_order":0}],"page":{"has_more":false}} + """.trimIndent() + viewModel.refresh() + fixture.awaitRequest("libraries") + fixture.awaitRequest("libraries") + val afterRefresh = viewModel.uiState.first { !it.isLoadingLibraries } + assertEquals(listOf(1, 2), afterRefresh.libraries.map { it.id }) + assertEquals(listOf(1, 2), cache.libraries?.map { it.id }) + } finally { + store.clear() + Dispatchers.resetMain() + fixture.close() + } + } + + @Test + fun libraryListRecheckConfirmsAShrinkOfHiddenAudiobookLibraries() = runTest { + val fixture = DeferredLibrariesFixture( + deferredKeys = emptySet(), + engineDispatcher = StandardTestDispatcher(testScheduler), + ) + fixture.librariesBody = """ + {"items":[ + {"id":"1","name":"First","type":"movies","sort_order":0}, + {"id":"2","name":"Second","type":"movies","sort_order":1}, + {"id":"3","name":"Books","type":"audiobooks","sort_order":2} + ],"page":{"has_more":false}} + """.trimIndent() + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val cache = InMemoryLibraryCache() + val viewModel = fixture.viewModel(catalogCache = cache) + val store = ViewModelStore().also { it.put("libraries", viewModel) } + try { + fixture.awaitRequest("libraries") + // Audiobooks are hidden by default, so only 1 and 2 are on screen. + viewModel.uiState.first { !it.isLoadingLibraries && it.libraries.size == 2 } + + // A short response that keeps every visible library but drops the + // hidden audiobook library still needs confirming before it is + // cached; the confirming read returns all three. + fixture.librariesBodyQueue += """ + {"items":[ + {"id":"1","name":"First","type":"movies","sort_order":0}, + {"id":"2","name":"Second","type":"movies","sort_order":1} + ],"page":{"has_more":false}} + """.trimIndent() + viewModel.refreshLibraryList() + fixture.awaitRequest("libraries") + fixture.awaitRequest("libraries") + viewModel.uiState.first { !it.isLoadingLibraries } + assertEquals(listOf(1, 2, 3), cache.libraries?.map { it.id }) + + // Same with nothing visible to protect: an audiobook-only server + // keeps its baseline through a transient failure, so a later empty + // response is still confirmed before it reaches the cache. + fixture.librariesBody = """ + {"items":[{"id":"3","name":"Books","type":"audiobooks","sort_order":0}],"page":{"has_more":false}} + """.trimIndent() + viewModel.refresh() + fixture.awaitRequest("libraries") + fixture.awaitRequest("libraries") + viewModel.uiState.first { !it.isLoadingLibraries && it.libraries.isEmpty() } + fixture.librariesStatusQueue += HttpStatusCode.ServiceUnavailable + viewModel.refreshLibraryList() + fixture.awaitRequest("libraries") + viewModel.uiState.first { !it.isLoadingLibraries } + fixture.librariesBodyQueue += """{"items":[],"page":{"has_more":false}}""" + viewModel.refreshLibraryList() + fixture.awaitRequest("libraries") + fixture.awaitRequest("libraries") + viewModel.uiState.first { !it.isLoadingLibraries } + assertEquals(listOf(3), cache.libraries?.map { it.id }) + } finally { + store.clear() + Dispatchers.resetMain() + fixture.close() + } + } + + @Test + fun accessChangeReloadsSubtabsThatWereAlreadyLoaded() = runTest { + val fixture = DeferredLibrariesFixture(deferredKeys = emptySet()) + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val viewModel = fixture.viewModel() + val store = ViewModelStore().also { it.put("libraries", viewModel) } + try { + fixture.awaitRequest("libraries") + fixture.awaitRequest("sections:1") + viewModel.uiState.first { !it.isLoadingSections } + viewModel.selectTab(LibrariesSubtab.Browse) + fixture.awaitRequest("filters") + fixture.awaitRequest("catalog:1:added_at:desc") + viewModel.uiState.first { it.catalogItems.isNotEmpty() && it.availableFilters != null } + viewModel.selectTab(LibrariesSubtab.Recommended) + + viewModel.refreshAfterAccessChange() + fixture.awaitRequest("libraries") + fixture.awaitRequest("sections:1") + + // Browse was loaded under the old policy: opening it again must + // refetch its titles and facets instead of returning early (the + // request never arrives without the fix, and runTest times out). + viewModel.selectTab(LibrariesSubtab.Browse) + fixture.awaitRequest("filters") + fixture.awaitRequest("catalog:1:added_at:desc") + } finally { + store.clear() + Dispatchers.resetMain() + fixture.close() + } + } + private suspend fun LibrariesViewModel.onlyActiveRequest(): Job = withTimeout(5_000) { while (true) { val activeRequests = viewModelScope.coroutineContext[Job] @@ -322,6 +714,13 @@ class LibrariesViewModelTest { error("Unreachable") } + /** A real library cache, so a failed read can fall back to the list it holds. */ + private class InMemoryLibraryCache : CatalogCachePort { + @Volatile var libraries: List? = null + override suspend fun cacheLibraries(libraries: List) { this.libraries = libraries } + override suspend fun getCachedLibraries() = libraries + } + /** BrowsePrefsStore persists nothing without an active server + profile. */ private class FakeServerRegistry : ServerRegistry { private val entry = ServerEntry( @@ -344,57 +743,82 @@ class LibrariesViewModelTest { private class DeferredLibrariesFixture( private val deferredKeys: Set, + private val firstLibraryType: String = "movies", + private val sectionResponse: String = """{"sections":[]}""", + engineDispatcher: CoroutineDispatcher = Dispatchers.IO, ) { var owner = AuthScopeSnapshot("s", "p", "https://example.invalid", "pin", identityGeneration = 1) + /** Overrides the immediate two-library list; [librariesStatus] fails it instead. */ + @Volatile var librariesBody: String? = null + /** One-shot bodies served, in order, ahead of [librariesBody]. */ + val librariesBodyQueue = java.util.concurrent.ConcurrentLinkedQueue() + /** One-shot statuses served, in order, ahead of [librariesStatus]. */ + val librariesStatusQueue = java.util.concurrent.ConcurrentLinkedQueue() + @Volatile var librariesStatus: HttpStatusCode = HttpStatusCode.OK private val tokens = object : TokenManager by TokenManagerImpl() { override suspend fun snapshotCurrentScope() = owner } + val catalogTypes = mutableListOf() + val catalogQueries = mutableListOf>() private val requests = Channel(Channel.UNLIMITED) private val pendingRequests = mutableListOf() private val responses = deferredKeys.associateWith { CompletableDeferred() } private val client = HttpClient( - MockEngine { request -> - val key = when (request.url.encodedPath) { - "/api/v2/user/libraries" -> "libraries" - "/api/v2/catalog/filters" -> "filters" - // Unfiltered browses are GET /catalog with `sort=-field`; facet - // filters switch to POST /catalog/query with the query in the body. - "/api/v2/catalog", "/api/v2/catalog/query" -> { - val body = (request.body as? io.ktor.http.content.TextContent) - ?.let { PrairieJson.parseToJsonElement(it.text).jsonObject } - fun field(name: String): String? = body?.get(name)?.let { (it as? JsonPrimitive)?.contentOrNull } - ?: request.url.parameters[name] - val rawSort = field("sort") - val sort = rawSort?.removePrefix("-") - val order = field("order") ?: if (rawSort?.startsWith("-") == true) "desc" else "asc" - val baseKey = "catalog:${field("library_id")}:$sort:$order" - val filterSuffix = if ((body?.get("groups") as? JsonArray)?.isNotEmpty() == true) { - ":filtered" - } else { - "" + MockEngine(MockEngineConfig().apply { + dispatcher = engineDispatcher + addHandler { request -> + val key = when (request.url.encodedPath) { + "/api/v2/user/libraries" -> "libraries" + "/api/v2/catalog/filters" -> "filters" + // Unfiltered browses are GET /catalog with `sort=-field`; facet + // filters switch to POST /catalog/query with the query in the body. + "/api/v2/catalog", "/api/v2/catalog/query" -> { + val body = (request.body as? io.ktor.http.content.TextContent) + ?.let { PrairieJson.parseToJsonElement(it.text).jsonObject } + fun field(name: String): String? = body?.get(name)?.let { (it as? JsonPrimitive)?.contentOrNull } + ?: request.url.parameters[name] + catalogTypes += field("type") + catalogQueries += listOf("type", "library_id", "match", "cursor").associateWith { field(it) } + val rawSort = field("sort") + val sort = rawSort?.removePrefix("-") + val order = field("order") ?: if (rawSort?.startsWith("-") == true) "desc" else "asc" + val baseKey = "catalog:${field("library_id")}:$sort:$order" + if (firstLibraryType == "mixed") ":${field("type")}" else "" + val filterSuffix = if ((body?.get("groups") as? JsonArray)?.isNotEmpty() == true) { + ":filtered" + } else { + "" + } + val offsetSuffix = ":${field("cursor") ?: "0"}" + listOf( + baseKey + filterSuffix + offsetSuffix, + baseKey + filterSuffix, + baseKey + offsetSuffix, + baseKey, + ).firstOrNull(responses::containsKey) ?: (baseKey + filterSuffix) + } + else -> { + val segments = request.url.encodedPath.split('/') + val family = segments.last() + "$family:${segments[4]}" } - val offsetSuffix = ":${field("cursor") ?: "0"}" - listOf( - baseKey + filterSuffix + offsetSuffix, - baseKey + filterSuffix, - baseKey + offsetSuffix, - baseKey, - ).firstOrNull(responses::containsKey) ?: (baseKey + filterSuffix) } - else -> { - val segments = request.url.encodedPath.split('/') - val family = segments.last() - "$family:${segments[4]}" + requests.send(key) + val body = responses[key]?.await() ?: immediateBody(key) + val status = if (key == "libraries") librariesStatusQueue.poll() ?: librariesStatus else HttpStatusCode.OK + if (status != HttpStatusCode.OK) { + respond(content = "", status = status) + } else { + respondJson(body) } } - requests.send(key) - val body = responses[key]?.await() ?: immediateBody(key) - respondJson(body) - }, + }), ) { install(ContentNegotiation) { json(PrairieJson) } } - fun viewModel(browsePrefs: BrowsePrefsStore? = null) = LibrariesViewModel( - personalDataRepository = PersonalDataRepository(PersonalDataApi(client)), + fun viewModel( + browsePrefs: BrowsePrefsStore? = null, + catalogCache: CatalogCachePort = NoOpCatalogCachePort, + ) = LibrariesViewModel( + personalDataRepository = PersonalDataRepository(PersonalDataApi(client), catalogCache = catalogCache), sectionRepository = SectionRepository(SectionApi(client, sectionItems = LibrarySectionItemsV2Api(client, tokens, ApiV2Gate.Unrestricted))), catalogRepository = CatalogRepository(CatalogApi(client)), browsePrefs = browsePrefs, @@ -422,14 +846,14 @@ class LibrariesViewModelTest { } private fun immediateBody(key: String): String = when { - key == "libraries" -> """ + key == "libraries" -> librariesBodyQueue.poll() ?: librariesBody ?: """ {"items":[ - {"id":"1","name":"First","type":"movies","sort_order":0}, + {"id":"1","name":"First","type":"$firstLibraryType","sort_order":0}, {"id":"2","name":"Second","type":"movies","sort_order":1} ],"page":{"has_more":false}} """.trimIndent() key == "filters" -> filtersBody(null) - key.startsWith("sections:") -> """{"sections":[]}""" + key.startsWith("sections:") -> sectionResponse key.startsWith("collections:") -> """{"library_id":"1","collections":[],"groups":[]}""" key.startsWith("catalog:") -> catalogBody("immediate") else -> error("Unexpected request key $key") diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibraryChromeInsetSourceTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibraryChromeInsetSourceTest.kt index 9de7466269..9e2c626746 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibraryChromeInsetSourceTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibraryChromeInsetSourceTest.kt @@ -24,6 +24,12 @@ class LibraryChromeInsetSourceTest { * top and reads the viewport as its blur source) and every tab clears the * chrome's *measured* height rather than a hard-coded runway. */ + /** A shuffle request carries no media type, so a Movies/Series chip view must not offer it. */ + @Test + fun scopedMixedLibraryOffersNoLibraryShuffle() { + assertTrue(Regex("val onShuffle = selectedLibrary[\\s\\S]*?\\?\\.takeIf \\{ state\\.mediaScope == null \\}[\\s\\S]*?ShuffleScopeKind\\.LIBRARY").containsMatchIn(libraries)) + } + @Test fun sharedChromeOwnsReservedSpaceBeforeEveryLibraryTab() { val viewport = libraries.indexOf("LibraryContentViewport(") diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileQualityMenuTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileQualityMenuTest.kt new file mode 100644 index 0000000000..a474f6351c --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileQualityMenuTest.kt @@ -0,0 +1,69 @@ +package org.prairieserver.prairie.android.ui.screens.player + +import org.prairieserver.prairie.model.playback.PlaybackAvailableQualityV3 +import org.prairieserver.prairie.model.playback.PlaybackDelivery +import org.prairieserver.prairie.model.playback.PlaybackEffectiveRecipeV3 +import org.prairieserver.prairie.model.playback.PlaybackExecutionPlan +import org.prairieserver.prairie.model.playback.PlaybackRouteFamily +import org.prairieserver.prairie.model.playback.playbackQualityMenu +import org.prairieserver.prairie.watchtogether.WatchPartyPlaybackContext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class MobileQualityMenuTest { + private val ladder = listOf( + PlaybackAvailableQualityV3("original", 1080, 8_000, preservesSource = true), + PlaybackAvailableQualityV3("1080p-medium", 1080, 6_000), + PlaybackAvailableQualityV3("720p-medium", 720, 4_000), + ) + + private fun state(recipe: PlaybackEffectiveRecipeV3?) = PlayerViewModel.PlayerUiState( + playbackPlan = PlaybackExecutionPlan( + planId = "plan", + delivery = PlaybackDelivery.SERVER_TRANSCODE_HLS, + routeFamily = PlaybackRouteFamily.SERVER_ADAPTIVE, + availableQualities = ladder, + effectiveRecipe = recipe, + ), + committedQualityPreference = "1080p", + ) + + @Test + fun storedResolutionPreferenceMarksTheEntryTheCappedPlanDelivers() { + val menu = playbackQualityMenu(ladder) + + val capped = state(PlaybackEffectiveRecipeV3(width = 1280, height = 720, bitrateKbps = 4_000)) + assertEquals("720p-medium", capped.activeQualityId(menu)) + + val uncapped = state(PlaybackEffectiveRecipeV3(width = 1920, height = 1080, bitrateKbps = 8_000)) + assertEquals("original", uncapped.activeQualityId(menu)) + } + + @Test + fun pickedQualityCarriesIntoSameTitleReloadsOnly() { + assertTrue(keepsPickedQuality("movie", "movie", routeNamesQuality = false, room = null, currentRoom = null)) + assertFalse(keepsPickedQuality("other", "movie", routeNamesQuality = false, room = null, currentRoom = null)) + assertFalse(keepsPickedQuality("movie", null, routeNamesQuality = false, room = null, currentRoom = null)) + // A route that names its own quality wins over an earlier pick. + assertFalse(keepsPickedQuality("movie", "movie", routeNamesQuality = true, room = null, currentRoom = null)) + } + + @Test + fun pickedQualityCarriesIntoRestartsOfTheSameRoomEpochOnly() { + val room = WatchPartyPlaybackContext( + roomId = "room", + selectionRevision = 3, + contentId = "movie", + fileId = 41, + libraryId = null, + positionSeconds = 0.0, + paused = true, + ) + + assertTrue(keepsPickedQuality("movie", "movie", false, room = room.copy(positionSeconds = 90.0), currentRoom = room)) + assertFalse(keepsPickedQuality("movie", "movie", false, room = room.copy(selectionRevision = 4), currentRoom = room)) + assertFalse(keepsPickedQuality("movie", "movie", false, room = room, currentRoom = null)) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleAutoSelectionTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleAutoSelectionTest.kt index 0cb55a9dee..1ee98908a8 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleAutoSelectionTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleAutoSelectionTest.kt @@ -518,6 +518,71 @@ class MobileSubtitleAutoSelectionTest { ) } + @Test + fun autoSubtitlePreferenceTakesTheEmbeddedTrackOverAnExternalOneInTheSameLanguage() { + // The session lists externals first; the file's own track still wins + // the tie, because external sidecars are the ones that drift. + val subtitles = listOf( + subtitle(index = 0, label = "English", language = "en").copy(source = "external", catalogSource = "external"), + subtitle(index = 3, label = "English", language = "en", codec = "subrip") + .copy(source = "embedded", catalogSource = "embedded"), + ) + + assertEquals( + MobileSubtitleAutoSelection.Select(1), + resolveMobileAutoSubtitleSelection( + audioTracks = listOf(audio(language = "ja")), + selectedAudioIndex = 0, + subtitles = subtitles, + preferredLanguage = "en", + subtitleMode = "auto", + showForcedSubtitles = false, + ), + ) + } + + @Test + fun autoSubtitlePreferenceTakesARenderableEmbeddedPgsTrackOverAnExternalSrt() { + val subtitles = listOf( + subtitle(index = 0, label = "English", language = "en").copy(source = "external", catalogSource = "external"), + subtitle(index = 3, label = "English", language = "en", codec = "hdmv_pgs_subtitle") + .copy(source = "embedded", catalogSource = "embedded", url = ""), + ) + + assertEquals( + MobileSubtitleAutoSelection.Select(1), + resolveMobileAutoSubtitleSelection( + audioTracks = listOf(audio(language = "ja")), + selectedAudioIndex = 0, + subtitles = subtitles, + preferredLanguage = "en", + subtitleMode = "always", + showForcedSubtitles = false, + ), + ) + } + + @Test + fun autoSubtitlePreferenceAvoidsAnEmbeddedTrackThatNeedsABurnIn() { + val subtitles = listOf( + subtitle(index = 0, label = "English", language = "en").copy(source = "external", catalogSource = "external"), + subtitle(index = 3, label = "English", language = "en", codec = "dvd_subtitle") + .copy(source = "embedded", catalogSource = "embedded", url = ""), + ) + + assertEquals( + MobileSubtitleAutoSelection.Select(0), + resolveMobileAutoSubtitleSelection( + audioTracks = listOf(audio(language = "ja")), + selectedAudioIndex = 0, + subtitles = subtitles, + preferredLanguage = "en", + subtitleMode = "always", + showForcedSubtitles = false, + ), + ) + } + @Test fun autoSubtitleResolverDisablesWhenAudioAlreadyMatchesPreferredLanguage() { assertEquals( diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleTransactionAdapterTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleTransactionAdapterTest.kt index f88b617b8f..96eec0d597 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleTransactionAdapterTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleTransactionAdapterTest.kt @@ -747,6 +747,27 @@ class MobileSubtitleTransactionAdapterTest { assertEquals(listOf(SubtitleIdentity.Off), harness.persistence.persisted.map { it.identity }) } + @Test + fun `failed quality change is reported apart from subtitle failures`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.selectQuality("720p-medium") + runCurrent() + harness.port.failStage(ApiResult.NetworkError(IllegalStateException("offline"))) + runCurrent() + + assertTrue(harness.adapter.snapshot.failureMessage != null) + assertTrue(harness.adapter.snapshot.qualityChangeFailed) + + harness.adapter.select(sidecar(4)) + runCurrent() + harness.port.failStage(ApiResult.NetworkError(IllegalStateException("offline"))) + runCurrent() + + assertTrue(harness.adapter.snapshot.failureMessage != null) + assertFalse(harness.adapter.snapshot.qualityChangeFailed) + } + @Test fun `missing sidecar and network failure retain committed selection and preference`() = runTest { val harness = harness(backgroundScope) diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt index ced8912522..f7c2caf37f 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt @@ -26,6 +26,7 @@ import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -73,6 +74,7 @@ import org.prairieserver.prairie.common.player.PlaybackSessionManager import org.prairieserver.prairie.common.player.SleepTimerController import org.prairieserver.prairie.common.player.StartParams import org.prairieserver.prairie.common.player.VideoSessionStartV3 +import org.prairieserver.prairie.common.player.video.PlaybackDiagnosticsCode import org.prairieserver.prairie.common.player.video.VideoPlaybackSessionCoordinator import org.prairieserver.prairie.common.player.video.VideoPlaybackStartRequest import org.prairieserver.prairie.common.player.video.VideoPlaybackStartResult @@ -116,6 +118,7 @@ import org.prairieserver.prairie.repository.port.NoOpUserItemStatePort import org.prairieserver.prairie.repository.port.UserItemStatePort import org.prairieserver.prairie.playback.audioTrackFingerprint import org.prairieserver.prairie.playback.encodeCatalogSubtitlePreference +import org.prairieserver.prairie.watchtogether.WatchPartyPlaybackContext @OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) @@ -253,6 +256,33 @@ class PlayerViewModelLoadOwnershipIntegrationTest { } } + @Test + fun sameTitleReloadsStartWithThePickedQualityAndAnotherTitleDropsIt() = runTest(dispatcher) { + val starter = DeferredNonCooperativeStarter() + val fixture = playerViewModel(starter, backgroundScope) + val store = ViewModelStore().also { it.put("player", fixture.viewModel) } + try { + val viewModel = fixture.viewModel + viewModel.loadContent(contentId = "movie", preferredFileId = 1) + starter.awaitRequestCount(1) + assertNull(starter.request(0).preferredQualityOverride) + + // What a committed Quality menu pick leaves behind. + viewModel.setPickedQuality("720p-medium") + starter.complete(0, VideoPlaybackStartResult.ServerUnreachable(contentId = "movie")) + viewModel.awaitState { it.serverUnreachable } + viewModel.playIgnoringServerReachability() + starter.awaitRequestCount(2) + assertEquals("720p-medium", starter.request(1).preferredQualityOverride) + + viewModel.loadContent(contentId = "other", preferredFileId = 2) + starter.awaitRequestCount(3) + assertNull(starter.request(2).preferredQualityOverride) + } finally { + store.clear() + } + } + @Test fun staleErrorCannotOverwriteCurrentReady() = runTest(dispatcher) { val starter = DeferredNonCooperativeStarter() @@ -443,7 +473,7 @@ class PlayerViewModelLoadOwnershipIntegrationTest { } @Test - fun pendingSessionStopCancelsNextUpWithoutStartingTheSuccessor() = runTest(dispatcher) { + fun failedSessionStopStillStartsTheSuccessor() = runTest(dispatcher) { val starter = DeferredNonCooperativeStarter() val fixture = playerViewModel(starter, backgroundScope) val store = ViewModelStore().also { it.put("player", fixture.viewModel) } @@ -466,10 +496,9 @@ class PlayerViewModelLoadOwnershipIntegrationTest { viewModel.offerNextEpisode() viewModel.playUpNextNow() fixture.manager.awaitStopped("session-a") - viewModel.awaitState { !it.isNextUpTransitioning } - assertEquals("episode-a", viewModel.uiState.value.contentId) - assertFalse(viewModel.uiState.value.showUpNext) - assertEquals(1, starter.startedRequestCount) + // The playback journal retries the stop before a later start; it never holds up this one. + starter.awaitRequestCount(2) + assertEquals(2, starter.startedRequestCount) } finally { store.clear() } @@ -511,6 +540,177 @@ class PlayerViewModelLoadOwnershipIntegrationTest { } } + @Test + fun roomStartStreamsTheRoomFilePausedAndEveryRestartKeepsTheRoom() = runTest(dispatcher) { + val starter = DeferredNonCooperativeStarter() + val fixture = playerViewModel(starter, backgroundScope) + val store = ViewModelStore().also { it.put("player", fixture.viewModel) } + try { + val viewModel = fixture.viewModel + val room = WatchPartyPlaybackContext( + roomId = "room", + selectionRevision = 3, + contentId = "movie", + fileId = 41, + libraryId = 7, + positionSeconds = 120.0, + paused = false, + ) + viewModel.startRoomPlayback(room) + starter.awaitRequestCount(1) + val request = starter.request(0) + assertEquals(room, request.room) + assertEquals("room", request.roomId) + assertEquals("movie", request.contentId) + assertEquals(41, request.preferredFileId) + assertEquals(7, request.libraryId) + + starter.complete(0, ready(request, "session-3")) + viewModel.awaitState { it.sessionId == "session-3" && !it.isLoading } + advanceUntilIdle() + // Only the room's command may start playback. + assertTrue(viewModel.uiState.value.isPaused) + assertFalse(viewModel.uiState.value.isPlaying) + assertEquals("session-3", viewModel.roomObservation.value.sessionId) + assertEquals(3L, viewModel.roomObservation.value.selectionRevision) + + // The screen collecting the same epoch again (recreation) restarts nothing. + viewModel.startRoomPlayback(room) + advanceUntilIdle() + assertEquals(1, starter.startedRequestCount) + + // A new epoch never exposes the outgoing session under its revision. + val next = room.copy(selectionRevision = 4, fileId = 42) + viewModel.startRoomPlayback(next) + starter.awaitRequestCount(2) + advanceUntilIdle() + assertNull(viewModel.roomObservation.value.sessionId) + assertEquals(4L, viewModel.roomObservation.value.selectionRevision) + assertTrue(viewModel.roomObservation.value.seekPending) + + // Retry and Try Anyway replay the room start, never a solo one. + starter.complete(1, VideoPlaybackStartResult.ServerUnreachable(contentId = "movie")) + viewModel.awaitState { it.serverUnreachable } + viewModel.playIgnoringServerReachability() + starter.awaitRequestCount(3) + val retry = starter.request(2) + assertEquals(next, retry.room) + assertEquals(42, retry.preferredFileId) + assertTrue(retry.force) + } finally { + store.clear() + } + } + + @Test + fun roomStartRefusalIsHandedToTheRoomWithoutChoosingAnotherFile() = runTest(dispatcher) { + val starter = DeferredNonCooperativeStarter() + val fixture = playerViewModel(starter, backgroundScope) + val store = ViewModelStore().also { it.put("player", fixture.viewModel) } + try { + val viewModel = fixture.viewModel + val room = WatchPartyPlaybackContext( + roomId = "room", + selectionRevision = 5, + contentId = "movie", + fileId = 41, + libraryId = null, + positionSeconds = 0.0, + paused = true, + ) + viewModel.startRoomPlayback(room) + starter.awaitRequestCount(1) + starter.complete( + 0, + VideoPlaybackStartResult.Error( + contentId = "movie", + message = "This title needs HDR tone mapping.", + diagnosticsCode = PlaybackDiagnosticsCode.serverTerminal("hdr_transcode_unsupported"), + terminalReason = "hdr_transcode_unsupported", + ), + ) + viewModel.awaitState { it.error != null } + advanceUntilIdle() + assertEquals( + PlayerViewModel.RoomRefusal(room, fileId = 41, reason = "hdr_transcode_unsupported"), + viewModel.roomRefusal.value, + ) + assertEquals(1, starter.startedRequestCount) + viewModel.roomRefusal.value?.let(viewModel::consumeRoomRefusal) + assertNull(viewModel.roomRefusal.value) + } finally { + store.clear() + } + } + + @Test + fun outsideRoomSeekStaysUnreportableUntilTheRestoreLands() = runTest(dispatcher) { + val starter = DeferredNonCooperativeStarter() + val fixture = playerViewModel(starter, backgroundScope) + val store = ViewModelStore().also { it.put("player", fixture.viewModel) } + try { + val viewModel = fixture.viewModel + viewModel.startRoomPlayback( + WatchPartyPlaybackContext("room", 3, "movie", 41, null, 20.0, false), + ) + starter.awaitRequestCount(1) + starter.complete(0, ready(starter.request(0), "session-3")) + viewModel.awaitState { it.sessionId == "session-3" && !it.isLoading } + advanceUntilIdle() + viewModel.onMediaMountApplied(viewModel.uiState.value.mediaMountGeneration) + + fun sample(positionMs: Long) { + viewModel.onRoomEngineSample( + positionMs, positionMs, playWhenReady = true, isPlaying = true, + playbackState = androidx.media3.common.Player.STATE_READY, + ) + viewModel.onPositionChanged(positionMs, 300_000L) + } + sample(20_000L) + assertFalse(viewModel.roomObservation.value.seekPending) + + // A MediaSession controller has already jumped to 80 seconds. + // PlayerScreen arms the undo before publishing that sample. + viewModel.applyRoomSeek(20.0) + sample(80_000L) + assertEquals(80.0, viewModel.roomObservation.value.sourcePositionSeconds) + assertTrue(viewModel.roomObservation.value.seekPending) + assertEquals(20.0, viewModel.immediateSeeks.first()) + + sample(20_000L) + assertEquals(20.0, viewModel.roomObservation.value.sourcePositionSeconds) + assertFalse(viewModel.roomObservation.value.seekPending) + } finally { + store.clear() + } + } + + @Test + fun roomCommandsHoldALocalSuspensionUntilTheViewerResumes() = runTest(dispatcher) { + val starter = DeferredNonCooperativeStarter() + val fixture = playerViewModel(starter, backgroundScope) + val store = ViewModelStore().also { it.put("player", fixture.viewModel) } + try { + val viewModel = fixture.viewModel + viewModel.setInWatchTogetherRoom(true) + viewModel.applyRoomPlaying(true) + assertFalse(viewModel.uiState.value.isPaused) + + viewModel.beginRoomSuspension(PlayerViewModel.RoomSuspensionReason.SleepTimer) + assertTrue(viewModel.uiState.value.isPaused) + assertTrue(viewModel.roomSuspended.value) + // The room plays on; this device stays held. + viewModel.applyRoomPlaying(true) + assertTrue(viewModel.uiState.value.isPaused) + + viewModel.endRoomSuspensions(resumeLocally = true) + assertFalse(viewModel.uiState.value.isPaused) + assertFalse(viewModel.roomSuspended.value) + } finally { + store.clear() + } + } + @Suppress("UNCHECKED_CAST") private fun PlayerViewModel.mutableUiState(): MutableStateFlow = PlayerViewModel::class.java.getDeclaredField("_uiState").let { @@ -518,6 +718,13 @@ class PlayerViewModelLoadOwnershipIntegrationTest { it.get(this) as MutableStateFlow } + private fun PlayerViewModel.setPickedQuality(quality: String) { + PlayerViewModel::class.java.getDeclaredField("sessionQualityOverride").let { + it.isAccessible = true + it.set(this, quality) + } + } + private fun PlayerViewModel.offerNextEpisode() { mutableUiState().update { it.copy(nextEpisode = PlayerViewModel.NextEpisodeInfo( contentId = "episode-b", seasonNumber = 1, episodeNumber = 2, @@ -528,6 +735,9 @@ class PlayerViewModelLoadOwnershipIntegrationTest { private fun playerViewModel( starter: DeferredNonCooperativeStarter, scope: CoroutineScope, + shufflesApi: org.prairieserver.prairie.network.apiv2.ShufflesV2Api? = null, + shuffleStore: org.prairieserver.prairie.model.feature.ShuffleFeatureStore? = + shufflesApi?.let { org.prairieserver.prairie.model.feature.ShuffleFeatureStore(it) }, ): PlayerFixture { val client = noOpClient() val tokenManager = FakeTokenManager() @@ -577,12 +787,308 @@ class PlayerViewModelLoadOwnershipIntegrationTest { scopeProvider = { null }, write = {}, ), + shuffleFeatureStore = shuffleStore, ), manager = manager, lifecycle = lifecycle, ) } + /** A shuffle server whose answers each test scripts; it records every request. */ + private class FakeShuffleServer(testDispatcher: CoroutineDispatcher? = null) { + val requests: MutableList = java.util.Collections.synchronizedList(mutableListOf()) + var read: String = shuffle(current = EPISODE_A, next = MOVIE_B) + var advance: String = shuffle(current = MOVIE_B, next = EPISODE_C) + var skip: String = shuffle(current = EPISODE_A, next = EPISODE_C) + var readStatus: HttpStatusCode = HttpStatusCode.OK + /** Holds the skip response until completed, to model a slow Pick Another. */ + var skipGate: kotlinx.coroutines.CompletableDeferred? = null + + val api = org.prairieserver.prairie.network.apiv2.ShufflesV2Api( + HttpClient(MockEngine.create { + dispatcher = testDispatcher + addHandler { request -> + val body = (request.body as? io.ktor.http.content.TextContent)?.text.orEmpty() + requests += "${request.method.value} ${request.url.encodedPath} $body".trim() + val path = request.url.encodedPath + val (status, json) = when { + request.method.value == "DELETE" -> HttpStatusCode.NoContent to "" + path == "/api/v2/shuffles" -> HttpStatusCode.Created to + shuffle(current = EPISODE_A, next = MOVIE_B) + path.endsWith("/advance") -> HttpStatusCode.OK to advance + path.endsWith("/skip") -> { + skipGate?.await() + HttpStatusCode.OK to skip + } + readStatus.value >= 400 -> readStatus to + """{"type":"https://prairieserver.org/docs/api/v2/problems/conflict","title":"Conflict","status":${readStatus.value}}""" + else -> HttpStatusCode.OK to read + } + respond(json, status, headersOf(HttpHeaders.ContentType, "application/json")) + } + }) { install(ContentNegotiation) { json(PrairieJson) } }, + FakeTokenManager(), + ApiV2Gate.Unrestricted, + ) + + companion object { + const val EPISODE_A = + """{"content_id":"episode-a","type":"episode","title":"Pilot","series_title":"Show","season_number":1,"episode_number":1}""" + const val MOVIE_B = """{"content_id":"movie-b","type":"movie","title":"Film","runtime":90}""" + const val EPISODE_C = + """{"content_id":"episode-c","type":"episode","title":"Third","series_title":"Show","season_number":0,"episode_number":3}""" + + fun shuffle(current: String, next: String) = + """{"id":"sh1","scope":{"kind":"season","id":"show-S1","title":"Season 1","parent_title":"Show"},"current":$current,"next":$next,"created_at":"2026-10-05T10:00:00.000Z","updated_at":"2026-10-05T10:00:00.000Z"}""" + } + } + + private suspend fun TestScope.startShuffledEpisode( + starter: DeferredNonCooperativeStarter, + viewModel: PlayerViewModel, + ) { + viewModel.attachShuffle("sh1") + viewModel.loadContent("episode-a", preferredFileId = 1, resumePositionOverride = 0.0, suppressResumeRewind = true) + starter.awaitRequestCount(1) + starter.complete(0, ready(starter.request(0), "session-a")) + viewModel.awaitState { it.sessionId == "session-a" && !it.isLoading } + runCurrent() + } + + @Test + fun shuffleAnnouncesTheServerPickAndAdvancesToItFromTheBeginning() = runTest(dispatcher) { + val server = FakeShuffleServer() + val starter = DeferredNonCooperativeStarter() + val fixture = playerViewModel(starter, backgroundScope, server.api) + val store = ViewModelStore().also { it.put("player", fixture.viewModel) } + try { + val viewModel = fixture.viewModel + startShuffledEpisode(starter, viewModel) + viewModel.awaitState { it.nextEpisode != null } + val state = viewModel.uiState.value + assertEquals("Show · Season 1", state.shuffle?.scopeLabel) + val next = requireNotNull(state.nextEpisode) + assertEquals("movie-b", next.contentId) + // A movie heads the card with its own title, without an S:E line. + assertFalse(next.isEpisode) + assertEquals("Film", next.label) + + viewModel.onApproachingEnd(videoEnded = true) + assertTrue(viewModel.uiState.value.showUpNext) + assertEquals(PlayerViewModel.UP_NEXT_COUNTDOWN_SECONDS, viewModel.uiState.value.upNextCountdownSeconds) + + viewModel.playUpNextNow() + starter.awaitRequestCount(2) + // A second press while the advance is in flight plays the same pick once. + viewModel.playUpNextNow() + runCurrent() + val successor = starter.request(1) + assertEquals("movie-b", successor.contentId) + assertEquals(0.0, successor.resumePositionOverride) + assertTrue(successor.suppressResumeRewind) + assertEquals(2, starter.startedRequestCount) + assertEquals( + 1, + server.requests.count { it == """POST /api/v2/shuffles/sh1/advance {"from_content_id":"episode-a"}""" }, + ) + + starter.complete(1, ready(successor, "session-b")) + viewModel.awaitState { it.sessionId == "session-b" && !it.isLoading } + viewModel.onFirstVideoFrameRendered(viewModel.uiState.value.mediaMountGeneration) + runCurrent() + // The advance already named the following pick; no sequential episode replaces it. + assertEquals("episode-c", viewModel.uiState.value.nextEpisode?.contentId) + assertTrue(viewModel.uiState.value.nextEpisode?.isEpisode == true) + } finally { + store.clear() + } + } + + @Test + fun playNowDuringASlowPickAnotherPlaysTheNewPick() = runTest(dispatcher) { + val server = FakeShuffleServer().apply { skipGate = kotlinx.coroutines.CompletableDeferred() } + server.advance = FakeShuffleServer.shuffle(FakeShuffleServer.EPISODE_C, FakeShuffleServer.MOVIE_B) + val starter = DeferredNonCooperativeStarter() + val fixture = playerViewModel(starter, backgroundScope, server.api) + val store = ViewModelStore().also { it.put("player", fixture.viewModel) } + try { + val viewModel = fixture.viewModel + startShuffledEpisode(starter, viewModel) + viewModel.awaitState { it.nextEpisode != null } + viewModel.onApproachingEnd(videoEnded = true) + + viewModel.pickAnotherShuffle() + runCurrent() + assertTrue(viewModel.uiState.value.shuffle?.pickingAnother == true) + // Play Now while Pick Another is still waiting for the server. + viewModel.playUpNextNow() + runCurrent() + assertTrue(server.requests.none { it.contains("/advance") }) + + requireNotNull(server.skipGate).complete(Unit) + starter.awaitRequestCount(2) + assertEquals("episode-c", starter.request(1).contentId) + val skip = server.requests.indexOfFirst { it.contains("/skip") } + val advance = server.requests.indexOfFirst { it.contains("/advance") } + assertTrue(skip in 0 until advance) + } finally { + store.clear() + } + } + + @Test + fun theFirstPickKnowsItsNextFromTheStartEvenWhenReadsFail() = runTest(dispatcher) { + val server = FakeShuffleServer().apply { readStatus = HttpStatusCode.ServiceUnavailable } + val starter = DeferredNonCooperativeStarter() + val shuffleStore = org.prairieserver.prairie.model.feature.ShuffleFeatureStore(server.api) + assertTrue( + shuffleStore.start(org.prairieserver.prairie.model.shuffle.ShuffleScopeKind.LIBRARY, "1") is + org.prairieserver.prairie.model.feature.ShuffleStart.Started, + ) + val fixture = playerViewModel(starter, backgroundScope, server.api, shuffleStore) + val store = ViewModelStore().also { it.put("player", fixture.viewModel) } + try { + val viewModel = fixture.viewModel + startShuffledEpisode(starter, viewModel) + viewModel.onApproachingEnd(videoEnded = true) + // The card's re-read fails like every read here. + withContext(Dispatchers.Default) { + withTimeout(5_000) { + while (server.requests.toList().none { it == "GET /api/v2/shuffles/sh1" }) delay(10) + } + } + runCurrent() + // The pick the start returned still plays next. + assertEquals("movie-b", viewModel.uiState.value.nextEpisode?.contentId) + assertTrue(viewModel.uiState.value.showUpNext) + } finally { + store.clear() + } + } + + @Test + fun aShuffleWithOnePlayableItemShowsFinishedAndPlayNowDoesNothing() = runTest(dispatcher) { + val server = FakeShuffleServer().apply { + read = FakeShuffleServer.shuffle(FakeShuffleServer.EPISODE_A, FakeShuffleServer.EPISODE_A) + } + val starter = DeferredNonCooperativeStarter() + val fixture = playerViewModel(starter, backgroundScope, server.api) + val store = ViewModelStore().also { it.put("player", fixture.viewModel) } + try { + val viewModel = fixture.viewModel + startShuffledEpisode(starter, viewModel) + viewModel.onApproachingEnd(videoEnded = true) + runCurrent() + assertTrue(viewModel.uiState.value.showUpNext) + assertNull(viewModel.uiState.value.nextEpisode) + assertNull(viewModel.uiState.value.upNextCountdownSeconds) + viewModel.playUpNextNow() + testScheduler.advanceTimeBy(30_000) + runCurrent() + assertEquals(1, starter.startedRequestCount) + assertTrue(server.requests.none { it.contains("/advance") }) + } finally { + store.clear() + } + } + + @Test + fun aShuffleThatCanNoLongerPlayAnythingShowsFinishedWhenTheCardOpens() = runTest(dispatcher) { + val server = FakeShuffleServer() + val starter = DeferredNonCooperativeStarter() + val fixture = playerViewModel(starter, backgroundScope, server.api) + val store = ViewModelStore().also { it.put("player", fixture.viewModel) } + try { + val viewModel = fixture.viewModel + startShuffledEpisode(starter, viewModel) + viewModel.awaitState { it.nextEpisode != null } + server.readStatus = HttpStatusCode.Conflict + viewModel.onApproachingEnd(videoEnded = true) + viewModel.awaitState { it.nextEpisode == null } + assertTrue(viewModel.uiState.value.showUpNext) + assertNull(viewModel.uiState.value.upNextCountdownSeconds) + } finally { + store.clear() + } + } + + @Test + fun pickAnotherReplacesThePickWithAFullCountdownAndStopEndsTheShuffle() = runTest(dispatcher) { + val server = FakeShuffleServer(dispatcher) + val starter = DeferredNonCooperativeStarter() + val fixture = playerViewModel(starter, backgroundScope, server.api) + val store = ViewModelStore().also { it.put("player", fixture.viewModel) } + try { + val viewModel = fixture.viewModel + startShuffledEpisode(starter, viewModel) + viewModel.awaitState { it.nextEpisode != null } + viewModel.onApproachingEnd(videoEnded = true) + testScheduler.advanceTimeBy(3_500) + runCurrent() + assertEquals(PlayerViewModel.UP_NEXT_COUNTDOWN_SECONDS - 3, viewModel.uiState.value.upNextCountdownSeconds) + + viewModel.pickAnotherShuffle() + // Keep the response and this waiter on the countdown's scheduler. + // Returning through Dispatchers.Default lets virtual time tick + // between publication of the full countdown and this assertion. + withTimeout(30_000) { viewModel.uiState.first { it.nextEpisode?.contentId == "episode-c" } } + assertTrue(server.requests.contains("""POST /api/v2/shuffles/sh1/skip {"next_content_id":"movie-b"}""")) + assertEquals(PlayerViewModel.UP_NEXT_COUNTDOWN_SECONDS, viewModel.uiState.value.upNextCountdownSeconds) + + viewModel.stopShuffling() + assertNull(viewModel.uiState.value.shuffle) + assertNull(viewModel.uiState.value.nextEpisode) + testScheduler.advanceTimeBy(30_000) + runCurrent() + // Stopping cancels the countdown: nothing else starts. + assertEquals(1, starter.startedRequestCount) + } finally { + store.clear() + } + } + + @Test + fun aReplacementLoadDuringThePartChangeStopKeepsThePartChangeFromLoading() = runTest(dispatcher) { + val server = FakeShuffleServer() + val starter = DeferredNonCooperativeStarter() + val fixture = playerViewModel(starter, backgroundScope, server.api) + val store = ViewModelStore().also { it.put("player", fixture.viewModel) } + try { + val viewModel = fixture.viewModel + startShuffledEpisode(starter, viewModel) + // Part 1 of a two-part pick is playing; part 2 is queued. + PlayerViewModel::class.java.getDeclaredField("shuffleNextPartFileId").let { + it.isAccessible = true + it.set(viewModel, 2) + } + fixture.lifecycle.adoptActiveSession( + params = StartParams( + contentId = "episode-a", fileId = 1, capabilities = ClientCodecCapabilities(), + clientPlaybackContext = ClientPlaybackContext(formFactor = "mobile", appVersion = "test"), + ), + session = allocatedReady("session-a").session, + manageProgress = false, + ) + fixture.manager.sequenced = true + val stopGate = kotlinx.coroutines.CompletableDeferred() + fixture.manager.stopGate = stopGate + + // Part 1 ends: the part change stops the session and is held there. + viewModel.onApproachingEnd(videoEnded = true) + fixture.manager.awaitStopped("session-a") + // A replacement load of the same item starts while that stop is pending. + viewModel.loadContent("episode-a", preferredFileId = 1, preserveRouteIntent = true) + stopGate.complete(Unit) + starter.awaitRequestCount(2) + runCurrent() + + assertEquals(2, starter.startedRequestCount) + assertEquals(1, starter.request(1).preferredFileId) + } finally { + store.clear() + } + } + private fun ready( request: VideoPlaybackStartRequest, sessionId: String, @@ -1089,6 +1595,8 @@ private class RecordingPlaybackSessionManager( ) { var sequenced = false var stopResult: ApiResult = ApiResult.Success(Unit) + /** Holds every stop until completed, to model a slow stop request. */ + var stopGate: kotlinx.coroutines.CompletableDeferred? = null override fun isSequenced(sessionId: String): Boolean = sequenced private val stopped = mutableListOf() @@ -1108,6 +1616,7 @@ private class RecordingPlaybackSessionManager( stopActiveContexts += contextActive } stoppedSignal.update { it + sessionId } + stopGate?.await() return stopResult } @@ -1231,7 +1740,7 @@ private class FakePlayerSettingsStore( override suspend fun setSubtitleMatchesDevice(enabled: Boolean) = Unit override suspend fun setShowAudiobooks(enabled: Boolean) = Unit override suspend fun resetDeviceSetting(key: String) = Unit - override suspend fun resetAllDeviceSettings() = Unit + override suspend fun resetAllDeviceSettings() = true override suspend fun flushPendingDeviceSettings() = Unit } diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/RoomDeliveryKeySourceTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/RoomDeliveryKeySourceTest.kt deleted file mode 100644 index 3a4bcb469b..0000000000 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/RoomDeliveryKeySourceTest.kt +++ /dev/null @@ -1,47 +0,0 @@ -package org.prairieserver.prairie.android.ui.screens.player - -import java.io.File -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertFailsWith -import kotlin.test.assertTrue - -class RoomDeliveryKeySourceTest { - private val reportingStartAnchor = "// Drift reporting loop" - private val reportingEndAnchor = "// ready / buffering during the waiting barrier." - - private val controllerSource = File( - "src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/RoomSyncController.kt", - ).readText() - - @Test - fun stateReportRequiresAnExplicitNonNullDeliveryKey() { - val reportingBlock = reportingBlock(controllerSource) - val guardIndex = reportingBlock.indexOf("deliveryKey != null") - val stateReportIndex = reportingBlock.indexOf("repository.stateReport(") - - assertFalse(reportingBlock.contains("deliveryKey!!")) - assertTrue(guardIndex >= 0, "Drift reporting block must guard deliveryKey explicitly.") - assertTrue(stateReportIndex >= 0, "Drift reporting block must report state.") - assertTrue(guardIndex < stateReportIndex) - } - - @Test - fun reportingBlockFailsClosedWhenEitherAnchorIsMissing() { - assertFailsWith { - reportingBlock(controllerSource.replace(reportingStartAnchor, "")) - } - assertFailsWith { - reportingBlock(controllerSource.replace(reportingEndAnchor, "")) - } - } - - private fun reportingBlock(source: String): String { - val startAnchorIndex = source.indexOf(reportingStartAnchor) - assertTrue(startAnchorIndex >= 0, "Missing drift-reporting start anchor.") - val blockStart = startAnchorIndex + reportingStartAnchor.length - val endAnchorIndex = source.indexOf(reportingEndAnchor, startIndex = blockStart) - assertTrue(endAnchorIndex >= blockStart, "Missing drift-reporting end anchor.") - return source.substring(blockStart, endAnchorIndex) - } -} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/RoomSyncStateReportGateTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/RoomSyncStateReportGateTest.kt deleted file mode 100644 index 8baf2dd450..0000000000 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/RoomSyncStateReportGateTest.kt +++ /dev/null @@ -1,57 +0,0 @@ -package org.prairieserver.prairie.android.ui.screens.player - -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class RoomSyncStateReportGateTest { - - private val cadenceMs = 1_500L - private val suppressWindowMs = 250L - - @Test fun reports_when_cadence_elapsed_and_no_pending_command() { - assertTrue( - shouldEmitStateReport( - nowMs = 2_000L, lastReportMs = 0L, cadenceMs = cadenceMs, - pendingExecuteAtMs = null, suppressWindowMs = suppressWindowMs, - ), - ) - } - - @Test fun does_not_report_before_cadence_elapsed() { - assertFalse( - shouldEmitStateReport( - nowMs = 1_000L, lastReportMs = 0L, cadenceMs = cadenceMs, - pendingExecuteAtMs = null, suppressWindowMs = suppressWindowMs, - ), - ) - } - - @Test fun suppresses_within_window_of_pending_execute() { - // cadence elapsed, but a command executes at 2100 and now=2000 → within 250ms - assertFalse( - shouldEmitStateReport( - nowMs = 2_000L, lastReportMs = 0L, cadenceMs = cadenceMs, - pendingExecuteAtMs = 2_100L, suppressWindowMs = suppressWindowMs, - ), - ) - } - - @Test fun reports_when_pending_execute_is_far_away() { - assertTrue( - shouldEmitStateReport( - nowMs = 2_000L, lastReportMs = 0L, cadenceMs = cadenceMs, - pendingExecuteAtMs = 5_000L, suppressWindowMs = suppressWindowMs, - ), - ) - } - - @Test fun suppresses_just_after_execute_too() { - assertFalse( - shouldEmitStateReport( - nowMs = 2_000L, lastReportMs = 0L, cadenceMs = cadenceMs, - pendingExecuteAtMs = 1_900L, suppressWindowMs = suppressWindowMs, - ), - ) - } -} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/WatchPartyExternalSeekTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/WatchPartyExternalSeekTest.kt new file mode 100644 index 0000000000..62dcb20083 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/WatchPartyExternalSeekTest.kt @@ -0,0 +1,131 @@ +package org.prairieserver.prairie.android.ui.screens.player + +import org.prairieserver.prairie.common.player.watchparty.IssuedSeekTracker +import android.app.Application +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowSystemClock +import org.prairieserver.prairie.common.player.watchparty.WatchPartyPlayback +import org.prairieserver.prairie.model.watchtogether.JoinRoomRequest +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.api.DefaultWatchTogetherApi +import org.prairieserver.prairie.network.apiv2.ApiV2Gate +import org.prairieserver.prairie.repository.WatchTogetherRepository +import org.prairieserver.prairie.watchtogether.RoomPlayerObservation +import org.prairieserver.prairie.watchtogether.RoomPlayerPort +import org.prairieserver.prairie.watchtogether.RoomSession +import org.prairieserver.prairie.watchtogether.RoomTransportResult +import org.prairieserver.prairie.watchtogether.WatchPartyAvailabilityRepository +import java.time.Duration +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class WatchPartyExternalSeekTest { + @Test + fun everyRapidDeniedSeekReturnsToThePreviousPosition() = runTest { + withGuestPlayback { playback -> + var position = 20.0 + val restored = mutableListOf() + for (target in listOf(80.0, 100.0, 120.0, 140.0, 160.0)) { + ShadowSystemClock.advanceBy(Duration.ofMillis(300)) + val previous = position + position = target + val result = playback.onExternalSeek(previous, target) { + restored += it + position = it + } + assertEquals(RoomTransportResult.Denied, result) + assertEquals(20.0, position, "The guest's seek to $target was left applied") + } + assertEquals(List(5) { 20.0 }, restored) + } + } + + @Test + fun duplicateControllerAndSessionCallbacksDoNotUndoRoomSeeksOrRestores() = runTest { + withGuestPlayback { playback -> + val issued = IssuedSeekTracker { 0L } + var outsideSeeks = 0 + val restores = mutableListOf() + fun discontinuity(fromMs: Long, toMs: Long) { + if (!issued.isExternal(toMs)) return + check(++outsideSeeks < 10) { "Restoration callbacks started a seek loop" } + playback.onExternalSeek(fromMs / 1_000.0, toMs / 1_000.0) { restore -> + restores += restore + val restoreMs = (restore * 1_000).toLong() + // PlayerScreen records before seekTo; MediaController + // emits immediately, then the session reports it again. + issued.note(restoreMs) + repeat(2) { discontinuity(toMs, restoreMs) } + } + } + + issued.note(60_000L) + repeat(2) { discontinuity(20_000L, 60_000L) } + assertEquals(0, outsideSeeks) + + discontinuity(60_000L, 120_000L) + assertEquals(1, outsideSeeks) + assertEquals(listOf(60.0), restores) + } + } + + private suspend fun TestScope.withGuestPlayback(block: (WatchPartyPlayback) -> Unit) { + val client = HttpClient(MockEngine { + respond( + """{"room":{"room_id":"room","phase":"playing","self_role":"guest"},"room_access_token":"proof"}""", + HttpStatusCode.OK, + headersOf(HttpHeaders.ContentType, "application/json"), + ) + }) { install(ContentNegotiation) { json() } } + try { + val authority = AuthScopeSnapshot( + serverId = "server", profileId = "profile", serverUrl = "https://prairie.example", + profileToken = null, identityGeneration = 1, + ) + val repository = WatchTogetherRepository( + api = DefaultWatchTogetherApi(client, ApiV2Gate.Unrestricted), + authScopeProvider = { authority }, + ) + assertIs>(repository.joinRoom(JoinRoomRequest(code = "K7PQ2M4X"))) + val session = RoomSession(repository, backgroundScope, DefaultIdentityTransitionBarrier()) + val availability = WatchPartyAvailabilityRepository( + roomCapabilities = { error("This test does not probe capabilities") }, + playbackCapabilities = { error("This test does not probe capabilities") }, + authScopeProvider = { authority }, + ) + val player = object : RoomPlayerPort { + override val observations = MutableStateFlow(RoomPlayerObservation()) + override fun seekTo(sourceSeconds: Double) = Unit + override fun setPlaying(playing: Boolean) = Unit + override fun setCorrectionRate(rate: Double?) = Unit + override fun isBuffered(sourceSeconds: Double) = true + } + val playback = WatchPartyPlayback("room", repository, session, availability, player, backgroundScope) + try { + block(playback) + } finally { + playback.dispose() + } + } finally { + client.close() + } + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/WatchPartyPlayerSupportTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/WatchPartyPlayerSupportTest.kt new file mode 100644 index 0000000000..52608bb7fe --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/WatchPartyPlayerSupportTest.kt @@ -0,0 +1,53 @@ +package org.prairieserver.prairie.android.ui.screens.player + +import org.prairieserver.prairie.model.playback.PlaybackAvailableQualityV3 +import org.prairieserver.prairie.model.playback.PlaybackTimeline +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class WatchPartyPlayerSupportTest { + // A transcode whose player timeline starts at source 600 s. + private val offsetTimeline = PlaybackTimeline(timelineOffsetSeconds = 600.0) + + @Test + fun bufferedTargetMapsSourceTimeOntoThePlayerTimeline() { + // Player at 10 s (source 610), buffered to player 40 s (source 640). + assertTrue(roomTargetBufferedLocally(offsetTimeline, 630.0, playerPositionSeconds = 10.0, bufferedPlayerSeconds = 40.0)) + assertTrue(roomTargetBufferedLocally(offsetTimeline, 609.5, playerPositionSeconds = 10.0, bufferedPlayerSeconds = 40.0)) + // Past the buffer, or more than a second behind the playhead, needs new media. + assertFalse(roomTargetBufferedLocally(offsetTimeline, 641.0, playerPositionSeconds = 10.0, bufferedPlayerSeconds = 40.0)) + assertFalse(roomTargetBufferedLocally(offsetTimeline, 608.0, playerPositionSeconds = 10.0, bufferedPlayerSeconds = 40.0)) + // Before this stream's origin: only a re-anchor reaches it. + assertFalse(roomTargetBufferedLocally(offsetTimeline, 30.0, playerPositionSeconds = 10.0, bufferedPlayerSeconds = 40.0)) + } + + @Test + fun bufferedTargetIsFalseWhenAReanchorWouldBeNeededOrStateIsUnknown() { + val bounded = PlaybackTimeline(seekWindowStartSeconds = 0.0, seekWindowEndSeconds = 50.0) + assertFalse(roomTargetBufferedLocally(bounded, 60.0, playerPositionSeconds = 40.0, bufferedPlayerSeconds = 90.0)) + val unknownRestoration = PlaybackTimeline(seekRestoration = "") + assertFalse(roomTargetBufferedLocally(unknownRestoration, 20.0, playerPositionSeconds = 10.0, bufferedPlayerSeconds = 40.0)) + assertFalse(roomTargetBufferedLocally(null, 20.0, playerPositionSeconds = 10.0, bufferedPlayerSeconds = 40.0)) + assertFalse(roomTargetBufferedLocally(PlaybackTimeline(), Double.NaN, playerPositionSeconds = 10.0, bufferedPlayerSeconds = 40.0)) + // A buffered position behind the playhead is not evidence of anything. + assertFalse(roomTargetBufferedLocally(PlaybackTimeline(), 10.0, playerPositionSeconds = 10.0, bufferedPlayerSeconds = 9.0)) + } + + @Test + fun lowerQualityIsOneRungBelowTheCurrentOneOnTheSameLadder() { + val ladder = listOf( + PlaybackAvailableQualityV3(label = "original", height = 2160, preservesSource = true), + PlaybackAvailableQualityV3(label = "1080p", height = 1080), + PlaybackAvailableQualityV3(label = "720p", height = 720), + PlaybackAvailableQualityV3(label = "480p", height = 480), + ) + assertEquals("1080p", lowerQualityRung(ladder, selectedLabel = null)?.label) + assertEquals("1080p", lowerQualityRung(ladder, selectedLabel = "auto")?.label) + assertEquals("720p", lowerQualityRung(ladder, selectedLabel = "1080p")?.label) + assertNull(lowerQualityRung(ladder, selectedLabel = "480p")) + assertNull(lowerQualityRung(emptyList(), selectedLabel = null)) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/ProfilePickerLayoutTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/ProfilePickerLayoutTest.kt new file mode 100644 index 0000000000..66fc8e34d2 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/ProfilePickerLayoutTest.kt @@ -0,0 +1,64 @@ +package org.prairieserver.prairie.android.ui.screens.profiles + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * "Who's watching?" must lay out 1–20 profiles (plus "Add profile") on every + * phone size: avatars that fit their columns and the screen, and that only + * get smaller as the household grows. + */ +class ProfilePickerLayoutTest { + /** Grid room (width, height between title and footer) on representative screens, in dp. */ + private val screens = listOf( + Triple("small phone", 312f, 340f), + Triple("Pixel 9", 364f, 520f), + Triple("large phone", 392f, 600f), + Triple("tablet", 440f, 900f), + ) + + @Test + fun everyHouseholdSizeFitsEveryScreen() { + for ((name, width, height) in screens) { + var previous: ProfilePickerLayout? = null + for (profiles in 1..20) { + val tiles = profiles + 1 + val layout = ProfilePickerLayout.of(tiles, width, height) + val context = "$name, $profiles profiles" + + assertTrue(layout.avatarSize >= ProfilePickerLayout.MINIMUM_AVATAR, context) + assertTrue(layout.perRow * layout.columnWidth <= width, context) + assertTrue(layout.avatarSize < layout.columnWidth, "$context: avatar wider than its column") + assertTrue(layout.addSize <= layout.avatarSize, context) + + if (!layout.scrolls) { + val rows = ((tiles + layout.perRow - 1) / layout.perRow).toFloat() + val used = rows * (layout.avatarSize + ProfilePickerLayout.LABEL_HEIGHT) + (rows - 1) * layout.rowSpacing + assertTrue(used <= height, "$context: rows overflow without scrolling") + } + previous?.let { + assertTrue(layout.avatarSize <= it.avatarSize, "$context: avatars grew") + assertTrue(layout.perRow >= it.perRow, "$context: fewer per row") + assertTrue(layout.scrolls || !it.scrolls, "$context: stopped scrolling") + } + previous = layout + } + } + } + + @Test + fun smallHouseholdGetsTwoLargeAvatarsPerRow() { + val layout = ProfilePickerLayout.of(tileCount = 3, width = 364f, height = 520f) + assertEquals(2, layout.perRow) + assertEquals(140f, layout.avatarSize) + assertTrue(layout.addSize < layout.avatarSize, "a lone Add profile tile is drawn smaller") + } + + @Test + fun largeHouseholdScrollsFourPerRow() { + val layout = ProfilePickerLayout.of(tileCount = 21, width = 364f, height = 520f) + assertEquals(4, layout.perRow) + assertTrue(layout.scrolls) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsViewModelOfflinePreferencesTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsViewModelOfflinePreferencesTest.kt index 2d27037179..98e7abd13b 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsViewModelOfflinePreferencesTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsViewModelOfflinePreferencesTest.kt @@ -26,8 +26,18 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config +import org.prairieserver.prairie.android.auth.InMemoryAccountChoiceStore +import org.prairieserver.prairie.android.auth.InMemoryPendingNativeSignInStore +import org.prairieserver.prairie.android.auth.NativeSignInCompleter +import org.prairieserver.prairie.android.auth.NativeSignInCoordinator +import org.prairieserver.prairie.android.auth.SignOutTeardown import org.prairieserver.prairie.common.player.AudiobookSettingsStore +import org.prairieserver.prairie.model.feature.MetadataAiFeatureStore +import org.prairieserver.prairie.model.feature.RequestsFeatureStore +import org.prairieserver.prairie.repository.MetadataAiRepository +import org.prairieserver.prairie.repository.RequestsRepository import org.prairieserver.prairie.common.settings.CardPresentationUiState +import org.prairieserver.prairie.common.settings.PlayerSettingsStore import org.prairieserver.prairie.domain.settings.ProfileSettingsController import org.prairieserver.prairie.model.profile.ActiveProfileStore import org.prairieserver.prairie.model.profile.Profile @@ -53,6 +63,7 @@ import org.prairieserver.prairie.repository.ProfileRepository import org.prairieserver.prairie.repository.SettingsRepository import java.lang.reflect.Proxy import kotlin.test.assertEquals +import kotlin.test.assertNull @OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) @@ -118,7 +129,37 @@ class SettingsViewModelOfflinePreferencesTest { assertEquals("always", profiles.activeProfile.value?.subtitleMode) } + @Test + fun useProfileSettingsReportsWhetherTheClearsLanded() { + var landed = true + val cleared = mutableListOf() + val store = object : PlayerSettingsStore by idleStore() { + override suspend fun resetAllDeviceSettings() = landed + override suspend fun resetDeviceSetting(key: String) { + cleared += key + } + } + scenario(playerSettingsStore = store) { vm, _, _ -> + vm.resetPlaybackOverrides() + runCurrent() + assertEquals(true, vm.uiState.value.playbackOverridesReset) + vm.onPlaybackOverridesResetShown() + assertNull(vm.uiState.value.playbackOverridesReset) + + // Unconfirmed (offline, refused): the notice must not claim success. + landed = false + vm.resetPlaybackOverrides() + runCurrent() + assertEquals(false, vm.uiState.value.playbackOverridesReset) + + vm.useProfileSetting(SettingKeys.PLAYBACK_AUTO_SKIP_CREDITS) + runCurrent() + assertEquals(listOf(SettingKeys.PLAYBACK_AUTO_SKIP_CREDITS), cleared) + } + } + private fun scenario( + playerSettingsStore: PlayerSettingsStore = idleStore(), block: suspend TestScope.(SettingsViewModel, PendingSettingsApi, ActiveProfileStore) -> Unit, ) = runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) @@ -130,11 +171,10 @@ class SettingsViewModelOfflinePreferencesTest { override suspend fun listProfiles() = ApiResult.Success(listOf(Profile(id = "p1", name = "Test", subtitleMode = "auto"))) }) profiles.refresh() + val authRepository = AuthRepository(AuthApi(client, ApiV2Gate.Unrestricted), tokens) val vm = SettingsViewModel( - authRepository = AuthRepository(AuthApi(client, ApiV2Gate.Unrestricted), tokens), - playerSettingsStore = idleStore(), - libraryPlaybackPrefsStore = idleStore(), - overlayPrefsStore = idleStore(), + authRepository = authRepository, + playerSettingsStore = playerSettingsStore, activeProfileStore = profiles, notificationsRepository = NotificationsRepository(NotificationsV2Api(client, tokens, ApiV2Gate.Unrestricted)), profileSettings = ProfileSettingsController(SettingsRepository(api)), @@ -143,6 +183,30 @@ class SettingsViewModelOfflinePreferencesTest { "getState" to MutableStateFlow(SeekIntervalState()), "getLastError" to MutableStateFlow(null), )), + episodeSpoilerStore = idleStore(mapOf( + "getState" to MutableStateFlow(org.prairieserver.prairie.common.settings.EpisodeSpoilerState()), + "getSaveError" to MutableStateFlow(null), + )), + signOutTeardown = SignOutTeardown( + authRepository = authRepository, + playerSettingsStore = idleStore(), + libraryPlaybackPrefsStore = idleStore(), + overlayPrefsStore = idleStore(), + activeProfileStore = profiles, + cardPresentationStore = idleStore(), + seekIntervalStore = idleStore(), + titleArtStore = idleStore(), + episodeSpoilerStore = idleStore(), + requestsFeatureStore = RequestsFeatureStore(RequestsRepository(idleStore())), + metadataAiFeatureStore = MetadataAiFeatureStore(MetadataAiRepository(idleStore())), + serverRegistry = idleStore(mapOf("getActiveServerId" to MutableStateFlow(null))), + nativeSignIn = NativeSignInCoordinator( + InMemoryPendingNativeSignInStore(), + idleStore(), + backgroundScope, + InMemoryAccountChoiceStore(), + ), + ), audiobookSettingsStore = AudiobookSettingsStore(RuntimeEnvironment.getApplication(), { null }), ) try { diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SignInSettingsTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SignInSettingsTest.kt new file mode 100644 index 0000000000..2d17b44d4e --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SignInSettingsTest.kt @@ -0,0 +1,500 @@ +package org.prairieserver.prairie.android.ui.screens.settings + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.prairieserver.prairie.android.auth.InMemoryAccountChoiceStore +import org.prairieserver.prairie.android.auth.InMemoryPendingNativeSignInStore +import org.prairieserver.prairie.android.auth.NativeSignInCompleter +import org.prairieserver.prairie.android.auth.NativeSignInCoordinator +import org.prairieserver.prairie.android.auth.PendingNativeSignIn +import org.prairieserver.prairie.android.ui.screens.auth.passwordLoginMessage +import org.prairieserver.prairie.android.ui.screens.auth.networkSignInMessage +import org.prairieserver.prairie.model.auth.AccountIdentities +import org.prairieserver.prairie.model.auth.AccountIdentity +import org.prairieserver.prairie.model.auth.AccountIdentityLinkTicket +import org.prairieserver.prairie.model.auth.ExternalSignInCapabilities +import org.prairieserver.prairie.model.auth.LoginResponse +import org.prairieserver.prairie.model.auth.NetworkIdentity +import org.prairieserver.prairie.model.auth.OAuthHandshakeCapabilities +import org.prairieserver.prairie.model.auth.SignInProvider +import org.prairieserver.prairie.model.auth.SignInProviders +import org.prairieserver.prairie.model.auth.User +import org.prairieserver.prairie.model.server.ServerEntry +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.ServerRegistry +import org.prairieserver.prairie.network.TokenManager +import org.prairieserver.prairie.network.TokenManagerImpl +import org.prairieserver.prairie.network.api.DefaultExternalSignInApi +import org.prairieserver.prairie.network.api.ExternalSignInApi +import org.prairieserver.prairie.network.api.ServerConnections +import org.prairieserver.prairie.network.api.ServerIdentityApi +import org.prairieserver.prairie.network.api.ServerIdentityProbe +import org.prairieserver.prairie.repository.ExternalSignInRepository +import org.prairieserver.prairie.repository.ServerIdentityRepository +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SignInSettingsTest { + private val local = SignInProvider("local", "Prairie account", SignInProvider.Mode.Credentials, true, null, null, null) + private val ldap = SignInProvider("plugin:6:ldap", "Directory", SignInProvider.Mode.Credentials, false, null, "6", null) + private val oidc = SignInProvider( + "plugin:5:oidc", "Keycloak", SignInProvider.Mode.OAuth, false, null, "5", + "/api/v2/auth/oauth/5/native/start", + ) + private val identity = AccountIdentity("4", "5", "plugin:5:oidc", "Keycloak", "alice", "", "", "2026-01-02T03:04:05Z", null) + private val all = OAuthHandshakeCapabilities(available = true, native = true, linking = true) + private val tailscale = SignInProvider( + "plugin:7:tailscale", "Tailscale", SignInProvider.Mode.Network, false, null, "7", null, + networkSignInPath = "/api/v2/auth/network/7/sign-in", + networkIdentity = NetworkIdentity("Alice Example", "alice@example.test"), + ) + + /** Offered only where the server serves network sign-in and listed the provider (over its network); no OAuth handshake needed. */ + @Test + fun aNetworkProviderIsConnectableOnlyWhereNetworkSignInIsServed() { + val offered = SignInSettingsViewModel.sectionOf( + true, emptyList(), listOf(local, tailscale), OAuthHandshakeCapabilities.None, credentialsLinking = false, networkSignIn = true, + ) + assertEquals(tailscale, offered.network) + assertNull(offered.directory, "a network provider is not a directory") + assertTrue(offered.connectable.isEmpty(), "nor a browser sign-in") + assertNull(SignInSettingsViewModel.sectionOf(true, emptyList(), listOf(tailscale), all, credentialsLinking = true).network) + val linked = identity.copy(installationId = "7", providerName = "Tailscale") + assertNull( + SignInSettingsViewModel.sectionOf(true, listOf(linked), listOf(tailscale), all, credentialsLinking = true, networkSignIn = true).network, + "already connected", + ) + assertNull(SignInSettingsViewModel.sectionOf(false, emptyList(), listOf(tailscale), all, credentialsLinking = true, networkSignIn = true).network) + assertTrue(SignInSettingsUiState(loading = false, network = tailscale).visible) + } + + @Test + fun networkLinkRefusalsHaveTheirOwnText() { + val codes = listOf( + 422 to DefaultExternalSignInApi.WRONG_PASSWORD, + 403 to "network_identity_required", + 409 to "local_password_required", + 403 to "not_permitted", + 403 to "permission_denied", + 409 to "identity_linked_elsewhere", + 409 to "already_linked", + 404 to "not_found", + 503 to "provider_unavailable", + 429 to "rate_limited", + ) + val texts = codes.map { (status, code) -> + SignInSettingsViewModel.networkLinkMessage(ApiResult.Error(status, code, "server detail"), "Tailscale") + } + assertEquals(texts.size, texts.toSet().size, texts.joinToString("\n")) + texts.forEach { assertFalse(it.contains("server detail"), "the server's detail is never shown") } + assertEquals("Open this server at its Tailscale address to connect Tailscale.", texts[1]) + assertEquals("Tailscale doesn't allow this device on this server.", texts[3]) + // The network refusals read as they do on the sign-in screen. + assertEquals(networkSignInMessage(ApiResult.Error(403, "not_permitted", ""), "Tailscale"), texts[3]) + assertEquals(networkSignInMessage(ApiResult.Error(404, "not_found", ""), "Tailscale"), texts[7]) + // Shared refusals read as in the other links. + assertEquals(SignInSettingsViewModel.directoryLinkMessage(ApiResult.Error(409, "identity_linked_elsewhere", ""), "Tailscale"), texts[5]) + } + + @Test + fun theNetworkPromptNamesWhoIsConnected() { + assertTrue(SignInSettingsViewModel.connectNetworkPrompt("Tailscale", "Alice Example", false).contains("connect Alice Example on Tailscale")) + assertTrue(SignInSettingsViewModel.connectNetworkPrompt("Tailscale", null, false).contains("the Tailscale account this device uses")) + } + + /** + * Only a server that reports `network_link_keeps_password` keeps the + * password on a network link; on older servers the copy still says the + * provider replaces it. + */ + @Test + fun theNetworkCopyKeepsThePasswordOnlyWhereTheServerSaysSo() { + assertTrue(SignInSettingsViewModel.connectNetworkPrompt("Tailscale", null, keepsPassword = false).contains("instead of your password")) + assertFalse(SignInSettingsViewModel.connectNetworkPrompt("Tailscale", null, keepsPassword = true).contains("instead of your password")) + assertEquals( + SignInSettingsViewModel.connectDescription("Tailscale"), + SignInSettingsViewModel.connectNetworkDescription("Tailscale", keepsPassword = false), + ) + assertNotEquals( + SignInSettingsViewModel.connectDescription("Tailscale"), + SignInSettingsViewModel.connectNetworkDescription("Tailscale", keepsPassword = true), + ) + fun keeps(served: Boolean) = SignInSettingsViewModel.sectionOf( + true, emptyList(), listOf(local, tailscale), OAuthHandshakeCapabilities.None, + credentialsLinking = false, networkSignIn = true, networkLinkKeepsPassword = served, + ).networkLinkKeepsPassword + assertTrue(keeps(true)) + assertFalse(keeps(false)) + } + + @Test + fun aServerWithoutIdentitiesShowsNoSection() { + val section = SignInSettingsViewModel.sectionOf(false, listOf(identity), listOf(local, oidc, ldap), all, credentialsLinking = true) + assertTrue(section.identities.isEmpty() && section.connectable.isEmpty() && section.directory == null) + } + + @Test + fun oidcIsConnectableThroughTheBrowserUntilLinked() { + val unlinked = SignInSettingsViewModel.sectionOf(true, emptyList(), listOf(local, oidc), all, credentialsLinking = true) + assertEquals(listOf(oidc), unlinked.connectable) + val linked = SignInSettingsViewModel.sectionOf(true, listOf(identity), listOf(local, oidc), all, credentialsLinking = true) + assertTrue(linked.connectable.isEmpty()) + assertEquals(listOf(identity), linked.identities) + val noAppLinking = SignInSettingsViewModel.sectionOf(true, emptyList(), listOf(oidc), all.copy(linking = false), credentialsLinking = true) + assertTrue(noAppLinking.connectable.isEmpty()) + } + + @Test + fun ldapIsConnectableOnlyWithCredentialsLinking() { + assertEquals(ldap, SignInSettingsViewModel.sectionOf(true, emptyList(), listOf(local, ldap), all, credentialsLinking = true).directory) + assertNull(SignInSettingsViewModel.sectionOf(true, emptyList(), listOf(local, ldap), all, credentialsLinking = false).directory) + assertNull(SignInSettingsViewModel.sectionOf(true, emptyList(), listOf(local), all, credentialsLinking = true).directory, "the local provider is not a directory") + val ldapIdentity = identity.copy(installationId = "6") + assertNull(SignInSettingsViewModel.sectionOf(true, listOf(ldapIdentity), listOf(ldap), all, credentialsLinking = true).directory) + } + + /** `credentials_linking` comes from the external sign-in document, not the OAuth handshake. */ + @Test + fun directoryLinkingNeedsNoOAuthHandshake() { + assertEquals( + ldap, + SignInSettingsViewModel.sectionOf(true, emptyList(), listOf(local, ldap), OAuthHandshakeCapabilities.None, credentialsLinking = true) + .directory, + ) + } + + @Test + fun canUnlinkDecidesDisconnect() { + val only = SignInSettingsViewModel.sectionOf(true, listOf(identity), listOf(oidc), all, credentialsLinking = true, canUnlink = false) + assertEquals(false, only.canUnlink) + val free = SignInSettingsViewModel.sectionOf(true, listOf(identity), listOf(oidc), all, credentialsLinking = true, canUnlink = true) + assertEquals(true, free.canUnlink) + assertNull(SignInSettingsViewModel.sectionOf(true, emptyList(), listOf(oidc), all, credentialsLinking = true, canUnlink = false).canUnlink) + // With can_unlink the confirmation no longer hedges. + assertFalse(SignInSettingsViewModel.disconnectConfirmBody(identity, canUnlink = true).contains("another way to sign in")) + assertTrue(SignInSettingsViewModel.ONLY_SIGN_IN_METHOD.contains("only way to sign in")) + } + + @Test + fun directoryRefusalsHaveTheirOwnText() { + val codes = listOf( + 422 to DefaultExternalSignInApi.WRONG_PASSWORD, + 422 to DefaultExternalSignInApi.DIRECTORY_REFUSED, + 409 to "local_password_required", + 403 to "not_permitted", + 403 to "account_disabled", + 403 to "password_expired", + 403 to "permission_denied", + 409 to "identity_linked_elsewhere", + 409 to "already_linked", + 404 to "not_found", + 503 to "provider_unavailable", + 429 to "rate_limited", + ) + val texts = codes.map { (status, code) -> + SignInSettingsViewModel.directoryLinkMessage(ApiResult.Error(status, code, "server detail"), "Directory") + } + assertEquals(texts.size, texts.toSet().size, texts.joinToString("\n")) + texts.forEach { assertFalse(it.contains("server detail"), "the server's detail is never shown") } + assertTrue(texts[1].contains("Directory")) + // Typing mistakes and throttling keep the dialog open; the rest close it. + assertTrue(SignInSettingsViewModel.keepsPromptOpen(ApiResult.Error(422, DefaultExternalSignInApi.WRONG_PASSWORD, ""))) + assertTrue(SignInSettingsViewModel.keepsPromptOpen(ApiResult.Error(429, "rate_limited", ""))) + assertFalse(SignInSettingsViewModel.keepsPromptOpen(ApiResult.Error(409, "identity_linked_elsewhere", ""))) + } + + @Test + fun linkTicketAndDisconnectRefusals() { + assertEquals( + "That password is incorrect.", + SignInSettingsViewModel.linkTicketMessage(ApiResult.Error(422, DefaultExternalSignInApi.WRONG_PASSWORD, ""), "Keycloak"), + ) + assertTrue(SignInSettingsViewModel.linkTicketMessage(ApiResult.Error(409, "conflict", ""), "Keycloak").contains("no password")) + assertTrue(SignInSettingsViewModel.linkTicketMessage(ApiResult.Error(403, "permission_denied", ""), "Keycloak").contains("session")) + assertTrue( + SignInSettingsViewModel.disconnectMessage(ApiResult.Error(409, "last_sign_in_method", ""), identity) + .contains("only way to sign in"), + ) + assertTrue(SignInSettingsViewModel.disconnectMessage(ApiResult.Error(404, "not_found", ""), identity).contains("already")) + } + + @Test + fun connectSaysThePasswordStopsWorkingAndDisconnectDoesNotPromiseIt() { + assertTrue(SignInSettingsViewModel.connectDescription("Keycloak").contains("instead of your password")) + assertTrue(SignInSettingsViewModel.connectPrompt("Keycloak").contains("instead of your password")) + assertTrue(SignInSettingsViewModel.connectDirectoryDescription("Directory").contains("Directory username and password instead")) + assertTrue(SignInSettingsViewModel.connectDirectoryPrompt("Directory").contains("username and password instead")) + val body = SignInSettingsViewModel.disconnectConfirmBody(identity) + assertFalse(body.contains("such as your password"), body) + assertTrue(body.contains("another way to sign in"), body) + } + + @Test + fun sharedRefusalsReadTheSameInEveryOperation() { + val tooMany = ApiResult.Error(429, "rate_limited", "") + assertEquals( + SignInSettingsViewModel.linkTicketMessage(tooMany, "Keycloak"), + SignInSettingsViewModel.disconnectMessage(tooMany, identity), + ) + val denied = ApiResult.Error(403, "permission_denied", "") + assertEquals( + SignInSettingsViewModel.linkTicketMessage(denied, "Keycloak"), + SignInSettingsViewModel.directoryLinkMessage(denied, "Keycloak"), + ) + } + + @Test + fun passwordLoginNamesLocalLoginDisabled() { + assertEquals( + "Password sign-in is turned off on this server. Sign in with Keycloak instead.", + passwordLoginMessage(ApiResult.Error(403, "local_login_disabled", ""), "Keycloak"), + ) + assertEquals( + "Password sign-in is turned off on this server.", + passwordLoginMessage(ApiResult.Error(403, "local_login_disabled", "")), + ) + assertEquals("Invalid username or password", passwordLoginMessage(ApiResult.Error(401, "invalid_credentials", ""))) + assertTrue(passwordLoginMessage(ApiResult.Error(403, "password_expired", "")).contains("expired")) + assertTrue(passwordLoginMessage(ApiResult.Error(503, "provider_unavailable", "")).contains("can't be reached")) + } +} + +/** "Connect " in the account's Sign-in settings: the password-only link. */ +@OptIn(ExperimentalCoroutinesApi::class) +class SignInSettingsNetworkLinkTest { + private val dispatcher = StandardTestDispatcher() + + @BeforeTest fun setUp() = Dispatchers.setMain(dispatcher) + + @AfterTest fun tearDown() = Dispatchers.resetMain() + + private val scope = AuthScopeSnapshot("a", "p1", "https://prairie.tailnet.ts.net", null, identityGeneration = 1) + private val tailscale = SignInProvider( + "plugin:7:tailscale", "Tailscale", SignInProvider.Mode.Network, false, null, "7", null, + networkSignInPath = "/api/v2/auth/network/7/sign-in", + networkIdentity = NetworkIdentity("Alice Example", "alice@example.test"), + ) + private val linkedIdentity = AccountIdentity( + "8", "7", "plugin:7:tailscale", "Tailscale", "alice@example.test", "alice@example.test", "Alice Example", + "2026-01-02T03:04:05.000Z", null, + ) + + private class Api(private val provider: SignInProvider, private val linked: AccountIdentity) : ExternalSignInApi { + var identities = emptyList() + var linkAnswer: ApiResult = ApiResult.Success(linked) + val links = mutableListOf>() + + /** When set, the link answers only once this completes. */ + var linkGate: CompletableDeferred? = null + + override suspend fun listProviders(serverUrl: String): ApiResult = + ApiResult.Success(SignInProviders(listOf(provider), passwordLogin = true)) + override suspend fun oauthCapabilities(serverUrl: String): ApiResult = + ApiResult.Error(404, "not_found", "") + override suspend fun externalSignInCapabilities(scope: AuthScopeSnapshot): ApiResult = + ApiResult.Success(ExternalSignInCapabilities(identities = true, networkSignIn = true)) + /** Each read takes the next gate, if any, and answers with the identities as they were when asked. */ + val identityGates = ArrayDeque>() + override suspend fun listIdentities(scope: AuthScopeSnapshot): ApiResult { + val asked = identities + identityGates.removeFirstOrNull()?.await() + return ApiResult.Success(AccountIdentities(asked, canUnlink = null)) + } + override suspend fun linkWithNetwork(scope: AuthScopeSnapshot, installationId: String, password: String): ApiResult { + links += Triple(scope, installationId, password) + linkGate?.await() + return linkAnswer.also { if (it is ApiResult.Success) identities = listOf(it.data) } + } + override suspend fun completeOAuthLogin(serverUrl: String, code: String, codeVerifier: String): ApiResult = TODO() + override suspend fun signInWithNetworkIdentity(serverUrl: String, signInPath: String): ApiResult = TODO() + override suspend fun deleteIdentity(scope: AuthScopeSnapshot, identityId: String): ApiResult = TODO() + override suspend fun createLinkTicket(scope: AuthScopeSnapshot, installationId: String, password: String): + ApiResult = TODO() + override suspend fun completeLink(scope: AuthScopeSnapshot, code: String, codeVerifier: String): ApiResult = TODO() + override suspend fun linkWithCredentials( + scope: AuthScopeSnapshot, + installationId: String, + password: String, + username: String, + directoryPassword: String, + ): ApiResult = TODO() + } + + /** Signed in on saved server "a". */ + private inner class SignedIn : TokenManager by TokenManagerImpl() { + override suspend fun snapshotCurrentScope(): AuthScopeSnapshot = scope + override suspend fun loginSessionId(serverId: String): String = "login-1" + } + + private object NoBrowser : NativeSignInCompleter { + override suspend fun signIn(pending: PendingNativeSignIn, code: String): ApiResult = error("no browser flow here") + override suspend fun link(pending: PendingNativeSignIn, code: String): ApiResult = error("no browser flow here") + } + + private class OneServer(private val entry: ServerEntry) : ServerRegistry { + override val entries: StateFlow> = MutableStateFlow(listOf(entry)) + override val activeServerId = MutableStateFlow(entry.id) + override val activeEntry: StateFlow = MutableStateFlow(entry) + override suspend fun addOrUpdate(url: String, fetchedName: String?): String = entry.id + override suspend fun rename(serverId: String, userOverrideName: String?) = Unit + override suspend fun setFetchedName(serverId: String, fetchedName: String?) = Unit + override suspend fun setProfileId(serverId: String, profileId: String?) = Unit + override suspend fun remove(serverId: String) = Unit + override suspend fun signOut(serverId: String) = Unit + override suspend fun switchTo(serverId: String) = Unit + override suspend fun touchActive() = Unit + } + + @Test + fun connectingAsksForThePasswordOnlyAndShowsTheLinkedIdentity() = runTest(dispatcher) { + val api = Api(tailscale, linkedIdentity) + val registry = OneServer(ServerEntry(id = "a", url = "https://prairie.tailnet.ts.net")) + val identityApi = object : ServerIdentityApi { + override suspend fun probeIdentity(serverUrl: String) = ServerIdentityProbe.Identity("srv-1") + override suspend fun connections(scope: AuthScopeSnapshot): ServerConnections? = null + } + val vm = SignInSettingsViewModel( + externalSignIn = ExternalSignInRepository(api), + tokenManager = SignedIn(), + serverRegistry = registry, + serverIdentities = ServerIdentityRepository(registry, identityApi), + nativeSignIn = NativeSignInCoordinator(InMemoryPendingNativeSignInStore(), NoBrowser, backgroundScope, InMemoryAccountChoiceStore()), + ) + advanceUntilIdle() + assertEquals(tailscale, vm.uiState.value.network) + assertTrue(vm.uiState.value.visible) + + vm.onConnectNetwork(tailscale) + assertEquals(tailscale, vm.uiState.value.networkPrompt) + vm.onConfirmNetwork("") + assertEquals("Enter your password.", vm.uiState.value.passwordError) + assertTrue(api.links.isEmpty()) + + // A wrong password keeps the prompt open to try again. + api.linkAnswer = ApiResult.Error(422, DefaultExternalSignInApi.WRONG_PASSWORD, "") + vm.onConfirmNetwork("wrong") + advanceUntilIdle() + assertEquals("That password is incorrect.", vm.uiState.value.passwordError) + assertEquals(tailscale, vm.uiState.value.networkPrompt) + assertEquals(false, vm.uiState.value.busy) + + api.linkAnswer = ApiResult.Success(linkedIdentity) + vm.onConfirmNetwork("local-pw") + advanceUntilIdle() + assertEquals(Triple(scope, "7", "local-pw"), api.links.last()) + assertNull(vm.uiState.value.networkPrompt) + assertEquals("Connected Tailscale.", vm.uiState.value.message) + assertEquals(listOf(linkedIdentity), vm.uiState.value.identities) + assertNull(vm.uiState.value.network, "connected: not offered again") + vm.viewModelScope.cancel() + } + + @Test + fun aRefusalThatTypingWontFixClosesThePrompt() = runTest(dispatcher) { + val api = Api(tailscale, linkedIdentity) + val registry = OneServer(ServerEntry(id = "a", url = "https://prairie.tailnet.ts.net")) + val identityApi = object : ServerIdentityApi { + override suspend fun probeIdentity(serverUrl: String) = ServerIdentityProbe.Identity("srv-1") + override suspend fun connections(scope: AuthScopeSnapshot): ServerConnections? = null + } + val vm = SignInSettingsViewModel( + ExternalSignInRepository(api), SignedIn(), registry, ServerIdentityRepository(registry, identityApi), + NativeSignInCoordinator(InMemoryPendingNativeSignInStore(), NoBrowser, backgroundScope, InMemoryAccountChoiceStore()), + ) + advanceUntilIdle() + vm.onConnectNetwork(tailscale) + api.linkAnswer = ApiResult.Error(403, "network_identity_required", "") + vm.onConfirmNetwork("local-pw") + advanceUntilIdle() + assertNull(vm.uiState.value.networkPrompt) + assertEquals("Open this server at its Tailscale address to connect Tailscale.", vm.uiState.value.error) + assertTrue(vm.uiState.value.identities.isEmpty()) + vm.viewModelScope.cancel() + } + + /** Cancel while "Checking…" stops the link: nothing reports back, and the section shows what the server has. */ + @Test + fun cancellingTheNetworkPromptStopsTheLink() = runTest(dispatcher) { + val api = Api(tailscale, linkedIdentity) + val gate = CompletableDeferred() + api.linkGate = gate + val registry = OneServer(ServerEntry(id = "a", url = "https://prairie.tailnet.ts.net")) + val identityApi = object : ServerIdentityApi { + override suspend fun probeIdentity(serverUrl: String) = ServerIdentityProbe.Identity("srv-1") + override suspend fun connections(scope: AuthScopeSnapshot): ServerConnections? = null + } + val vm = SignInSettingsViewModel( + ExternalSignInRepository(api), SignedIn(), registry, ServerIdentityRepository(registry, identityApi), + NativeSignInCoordinator(InMemoryPendingNativeSignInStore(), NoBrowser, backgroundScope, InMemoryAccountChoiceStore()), + ) + advanceUntilIdle() + vm.onConnectNetwork(tailscale) + vm.onConfirmNetwork("local-pw") + advanceUntilIdle() + assertTrue(vm.uiState.value.busy) + assertEquals(1, api.links.size) + + vm.onDismissNetworkPrompt() + assertNull(vm.uiState.value.networkPrompt) + assertFalse(vm.uiState.value.busy, "the section can be used again") + gate.complete(Unit) + advanceUntilIdle() + assertNull(vm.uiState.value.message, "a cancelled link doesn't report \"Connected\"") + assertNull(vm.uiState.value.error) + assertEquals(tailscale, vm.uiState.value.network, "the server made no link: still offered") + vm.viewModelScope.cancel() + } + + /** The reload after a cancelled link answers late: it doesn't undo the link made after it. */ + @Test + fun aLateReloadDoesNotOverwriteANewerOne() = runTest(dispatcher) { + val api = Api(tailscale, linkedIdentity) + val registry = OneServer(ServerEntry(id = "a", url = "https://prairie.tailnet.ts.net")) + val identityApi = object : ServerIdentityApi { + override suspend fun probeIdentity(serverUrl: String) = ServerIdentityProbe.Identity("srv-1") + override suspend fun connections(scope: AuthScopeSnapshot): ServerConnections? = null + } + val vm = SignInSettingsViewModel( + ExternalSignInRepository(api), SignedIn(), registry, ServerIdentityRepository(registry, identityApi), + NativeSignInCoordinator(InMemoryPendingNativeSignInStore(), NoBrowser, backgroundScope, InMemoryAccountChoiceStore()), + ) + advanceUntilIdle() + api.linkGate = CompletableDeferred() + vm.onConnectNetwork(tailscale) + vm.onConfirmNetwork("local-pw") + advanceUntilIdle() + val lateReload = CompletableDeferred() + api.identityGates += lateReload + vm.onDismissNetworkPrompt() + advanceUntilIdle() + + api.linkGate = null + vm.onConnectNetwork(tailscale) + vm.onConfirmNetwork("local-pw") + advanceUntilIdle() + assertEquals(listOf(linkedIdentity), vm.uiState.value.identities) + + lateReload.complete(Unit) + advanceUntilIdle() + assertEquals(listOf(linkedIdentity), vm.uiState.value.identities, "the older read is dropped") + assertNull(vm.uiState.value.network, "connected: not offered again") + vm.viewModelScope.cancel() + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartyInviteRouteTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartyInviteRouteTest.kt new file mode 100644 index 0000000000..cd8c774440 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartyInviteRouteTest.kt @@ -0,0 +1,34 @@ +package org.prairieserver.prairie.android.ui.screens.watchparty + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.prairieserver.prairie.android.ui.navigation.Route + +// Route strings call android.net.Uri.encode, which needs Robolectric's runtime. +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = android.app.Application::class) +class WatchPartyInviteRouteTest { + + @Test + fun `an app link becomes a hub route that carries only an opaque id`() { + val link = watchPartyAppLinkOrNull( + "prairie://watch-party?server=https%3A%2F%2Fmedia.example.com&token=secret-join-token", + )!! + val route = watchPartyInviteRoute(link) + + assertEquals("watch_party?invite=${watchPartyInviteHandoffId(link)}", route) + assertFalse(route.contains("secret")) + assertFalse(route.contains("media.example.com")) + } + + @Test + fun `hub and lobby routes`() { + assertEquals("watch_party", Route.WatchPartyHub().route) + assertEquals("watch_party?host=abc", Route.WatchPartyHub(host = "abc").route) + assertEquals("watch_party/lobby/room-1", Route.WatchPartyLobby("room-1").route) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartySupportTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartySupportTest.kt new file mode 100644 index 0000000000..353cfcf681 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchparty/WatchPartySupportTest.kt @@ -0,0 +1,156 @@ +package org.prairieserver.prairie.android.ui.screens.watchparty + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import org.prairieserver.prairie.model.watchtogether.RoomMember +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.RoomPlaybackState +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.viewmodel.WatchPartyItem +import org.prairieserver.prairie.watchtogether.WatchPartyInvite + +class WatchPartySupportTest { + + // ---- Invitation links --------------------------------------------------- + + @Test + fun `a prairie watch-party link parses into its server and token`() { + val link = watchPartyAppLinkOrNull( + "prairie://watch-party?server=https%3A%2F%2Fmedia.example.com%2Fprairie&token=secret-token", + ) + assertEquals(WatchPartyInvite.Link("https://media.example.com/prairie", "secret-token"), link) + } + + @Test + fun `account invitations, web links, codes, and malformed links are not party app links`() { + assertNull(watchPartyAppLinkOrNull("prairie://invite?server=https%3A%2F%2Fmedia.example.com&token=t")) + assertNull(watchPartyAppLinkOrNull("https://media.example.com/rooms/join?token=t")) + assertNull(watchPartyAppLinkOrNull("ABCD2345")) + assertNull(watchPartyAppLinkOrNull("prairie://watch-party?server=https%3A%2F%2Fmedia.example.com")) + assertNull(watchPartyAppLinkOrNull("prairie://watch-party?server=https%3A%2F%2Fmedia.example.com&token=a&token=b")) + assertNull(watchPartyAppLinkOrNull(null)) + assertNull(watchPartyAppLinkOrNull("")) + } + + @Test + fun `the handoff id is stable per link and never contains the token`() { + val link = WatchPartyInvite.Link("https://media.example.com", "secret-token") + val id = watchPartyInviteHandoffId(link) + assertEquals(id, watchPartyInviteHandoffId(link.copy())) + assertFalse(id.contains("secret")) + assertNotEquals(id, watchPartyInviteHandoffId(link.copy(token = "other-token"))) + assertNotEquals(id, watchPartyInviteHandoffId(link.copy(serverUrl = "https://other.example.com"))) + } + + @Test + fun `an invitation is taken once, and a newer one replaces an older one`() { + val handoff = WatchPartyHandoff() + val first = WatchPartyInvite.Link("https://media.example.com", "first") + val second = WatchPartyInvite.Link("https://media.example.com", "second") + val firstId = handoff.offerInvite(first) + val secondId = handoff.offerInvite(second) + + assertNull(handoff.takeInvite(firstId)) + assertEquals(second, handoff.takeInvite(secondId)) + assertNull(handoff.takeInvite(secondId)) + assertNull(handoff.takeInvite(null)) + } + + @Test + fun `a host item is taken once and leaves a title preview behind`() { + val handoff = WatchPartyHandoff() + val item = WatchPartyItem(contentId = "movie:1", contentType = "movie", title = "Heat") + val id = handoff.offerHost(item) + + assertEquals(item, handoff.takeHost(id)) + assertNull(handoff.takeHost(id)) + assertEquals(item, handoff.preview("movie:1")) + } + + // ---- In-playback status ------------------------------------------------- + + @Test + fun `members the room went on without are named once it plays again`() { + val previous = room( + RoomPlaybackState.Waiting, + member("sam", "Sam", syncing = true), + member("kim", "Kim", syncing = true), + member("me", "Me", syncing = true, self = true), + ) + val next = room( + RoomPlaybackState.Playing, + member("sam", "Sam", ready = false), + member("kim", "Kim", ready = true), + member("me", "Me", ready = false, self = true), + ) + assertEquals(listOf("Sam"), watchPartyLeftBehindNames(previous, next)) + } + + @Test + fun `nobody is left behind across a new selection, a pause, or a departure`() { + val waiting = room(RoomPlaybackState.Waiting, member("sam", "Sam", syncing = true)) + val playingNewRevision = room(RoomPlaybackState.Playing, member("sam", "Sam"), revision = 2) + val paused = room(RoomPlaybackState.Paused, member("sam", "Sam")) + val samLeft = room(RoomPlaybackState.Playing) + val stillWaiting = room(RoomPlaybackState.Waiting, member("sam", "Sam", syncing = true)) + + assertEquals(emptyList(), watchPartyLeftBehindNames(waiting, playingNewRevision)) + assertEquals(emptyList(), watchPartyLeftBehindNames(waiting, paused)) + assertEquals(emptyList(), watchPartyLeftBehindNames(waiting, samLeft)) + assertEquals(emptyList(), watchPartyLeftBehindNames(waiting, stillWaiting)) + assertEquals(emptyList(), watchPartyLeftBehindNames(null, waiting)) + } + + @Test + fun `the waiting status names the other members still syncing`() { + val waiting = room( + RoomPlaybackState.Waiting, + member("sam", "Sam", syncing = true), + member("kim", "", syncing = true), + member("lee", "Lee", ready = true), + member("me", "Me", syncing = true, self = true), + ) + assertEquals(listOf("Sam", "Someone"), watchPartyWaitingNames(waiting)) + assertEquals(emptyList(), watchPartyWaitingNames(room(RoomPlaybackState.Playing, member("sam", "Sam", syncing = true)))) + } + + @Test + fun `names read naturally`() { + assertEquals("", formatWatchPartyNames(emptyList())) + assertEquals("Sam", formatWatchPartyNames(listOf("Sam"))) + assertEquals("Sam and Kim", formatWatchPartyNames(listOf("Sam", "Kim"))) + assertEquals("Sam, Kim, and Lee", formatWatchPartyNames(listOf("Sam", "Kim", "Lee"))) + assertEquals("Sam, Kim, and 3 others", formatWatchPartyNames(listOf("Sam", "Kim", "Lee", "Ann", "Bo"))) + } + + private fun room( + state: RoomPlaybackState, + vararg members: RoomMember, + revision: Long = 1L, + ) = RoomSnapshot( + roomId = "room-1", + phase = RoomPhase.Playing, + playbackState = state, + selectionRevision = revision, + members = members.toList(), + ) + + private fun member( + id: String, + name: String, + syncing: Boolean = false, + ready: Boolean = false, + self: Boolean = false, + ) = RoomMember( + userId = "user-$id", + profileId = "profile-$id", + displayName = name, + isSelf = self, + connected = true, + isReady = ready, + isSyncing = syncing, + ) +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/LobbyAutoNavigateTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/LobbyAutoNavigateTest.kt deleted file mode 100644 index 1734a0215f..0000000000 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/LobbyAutoNavigateTest.kt +++ /dev/null @@ -1,57 +0,0 @@ -package org.prairieserver.prairie.android.ui.screens.watchtogether - -import org.prairieserver.prairie.android.ui.navigation.Route -import org.prairieserver.prairie.model.watchtogether.RoomPhase -import org.prairieserver.prairie.model.watchtogether.RoomPlaybackState -import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode -import org.prairieserver.prairie.model.watchtogether.RoomSnapshot -import org.prairieserver.prairie.model.watchtogether.MemberRole -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -// Asserts on real Route.route strings, which call android.net.Uri.encode — -// Robolectric provides the real Android impl under plain JVM unit tests. -// Pinned to SDK 34 (matches WatchTogetherEntryDestinationTest). -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = android.app.Application::class) -class LobbyAutoNavigateTest { - - private fun room(selectedContentId: String?, phase: RoomPhase) = RoomSnapshot( - roomId = "r1", - phase = phase, - playbackState = RoomPlaybackState.Idle, - selectionMode = RoomSelectionMode.HostPick, - selectionRevision = 0L, - selectedContentId = selectedContentId, - selectedFileId = 3, - ) - - @Test fun playing_with_selection_navigates_to_player() { - val dest = lobbyPlayerDestinationOrNull(room("c9", phase = RoomPhase.Playing)) - assertEquals(Route.Player(contentId = "c9", fileId = 3, roomId = "r1").route, dest) - } - - @Test fun lobby_phase_does_not_navigate() { - assertNull(lobbyPlayerDestinationOrNull(room("c9", phase = RoomPhase.Lobby))) - } - - @Test fun playing_without_selection_does_not_navigate() { - assertNull(lobbyPlayerDestinationOrNull(room(null, phase = RoomPhase.Playing))) - } - - @Test fun host_alone_stays_in_lobby_until_a_guest_arrives() { - val host = room("c9", RoomPhase.Playing).copy( - selfRole = MemberRole.Host, - memberCount = 1, - ) - assertNull(lobbyPlayerDestinationOrNull(host)) - assertEquals( - Route.Player(contentId = "c9", fileId = 3, roomId = "r1").route, - lobbyPlayerDestinationOrNull(host.copy(memberCount = 2)), - ) - } -} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntryDestinationTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntryDestinationTest.kt deleted file mode 100644 index 47841dd1bf..0000000000 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntryDestinationTest.kt +++ /dev/null @@ -1,65 +0,0 @@ -package org.prairieserver.prairie.android.ui.screens.watchtogether - -import org.prairieserver.prairie.android.ui.navigation.Route -import org.prairieserver.prairie.model.watchtogether.RoomPhase -import org.prairieserver.prairie.model.watchtogether.RoomPlaybackState -import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode -import org.prairieserver.prairie.model.watchtogether.RoomSnapshot -import org.prairieserver.prairie.model.watchtogether.MemberRole -import kotlin.test.Test -import kotlin.test.assertEquals -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -// Asserts on real Route.route strings, which call android.net.Uri.encode — -// Robolectric provides the real Android impl under plain JVM unit tests. -// Pinned to SDK 34 (the project targetSdk 36 is newer than this Robolectric -// release ships an emulated runtime for). -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = android.app.Application::class) -class WatchTogetherEntryDestinationTest { - - private fun snapshot( - roomId: String = "room-1", - selectedContentId: String? = null, - selectedFileId: Int? = null, - ) = RoomSnapshot( - roomId = roomId, - phase = RoomPhase.Lobby, - playbackState = RoomPlaybackState.Idle, - selectionMode = RoomSelectionMode.HostPick, - selectionRevision = 0L, - selectedContentId = selectedContentId, - selectedFileId = selectedFileId, - ) - - @Test - fun host_with_selection_goes_to_player_with_roomId() { - val dest = watchTogetherDestination(snapshot(selectedContentId = "c1", selectedFileId = 7)) - assertEquals(Route.Player(contentId = "c1", fileId = 7, roomId = "room-1").route, dest) - } - - @Test - fun no_selection_goes_to_lobby() { - val dest = watchTogetherDestination(snapshot(selectedContentId = null)) - assertEquals(Route.WatchTogetherLobby(roomId = "room-1").route, dest) - } - - @Test - fun selection_set_but_no_fileId_still_routes_to_player() { - val dest = watchTogetherDestination(snapshot(selectedContentId = "c2", selectedFileId = null)) - assertEquals(Route.Player(contentId = "c2", fileId = null, roomId = "room-1").route, dest) - } - - @Test - fun host_alone_with_selection_stays_in_lobby_to_share_invite() { - val dest = watchTogetherDestination( - snapshot(selectedContentId = "c1").copy( - selfRole = MemberRole.Host, - memberCount = 1, - ), - ) - assertEquals(Route.WatchTogetherLobby(roomId = "room-1").route, dest) - } -} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntryViewModelTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntryViewModelTest.kt deleted file mode 100644 index 36e5c9b1eb..0000000000 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntryViewModelTest.kt +++ /dev/null @@ -1,145 +0,0 @@ -package org.prairieserver.prairie.android.ui.screens.watchtogether - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.prairieserver.prairie.model.watchtogether.CreateRoomRequest -import org.prairieserver.prairie.model.watchtogether.JoinRoomRequest -import org.prairieserver.prairie.model.watchtogether.RoomPhase -import org.prairieserver.prairie.model.watchtogether.RoomResponse -import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode -import org.prairieserver.prairie.model.watchtogether.RoomSnapshot -import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest -import org.prairieserver.prairie.network.ApiResult -import org.prairieserver.prairie.watchtogether.WatchTogetherEntryGateway -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import kotlin.test.AfterTest -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull - -@OptIn(ExperimentalCoroutinesApi::class) -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = android.app.Application::class) -class WatchTogetherEntryViewModelTest { - private val dispatcher = UnconfinedTestDispatcher() - - @BeforeTest - fun setUp() { - Dispatchers.setMain(dispatcher) - } - - @AfterTest - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - fun emptyHostCreatesVoteRoomWithoutSelection() = runTest(dispatcher) { - val gateway = FakeGateway() - val viewModel = WatchTogetherEntryViewModel(gateway) - - viewModel.hostEmptyVoteRoom() - - assertEquals(listOf(CreateRoomRequest(RoomSelectionMode.Vote.wire)), gateway.createRequests) - assertEquals(emptyList(), gateway.selectionRequests) - assertEquals("watch_together/room-1", viewModel.uiState.value.destination) - } - - @Test - fun resumeUsesCurrentRoomWithoutCreateOrJoin() = runTest(dispatcher) { - val room = RoomSnapshot(roomId = "room-1", phase = RoomPhase.Lobby) - val gateway = FakeGateway(room) - val viewModel = WatchTogetherEntryViewModel(gateway) - - viewModel.resumeCurrentRoom() - - assertEquals("watch_together/room-1", viewModel.uiState.value.destination) - assertEquals(emptyList(), gateway.createRequests) - assertEquals(emptyList(), gateway.joinRequests) - } - - @Test - fun identityClearRemovesResumeState() = runTest(dispatcher) { - val gateway = FakeGateway(RoomSnapshot(roomId = "room-1", phase = RoomPhase.Lobby)) - val viewModel = WatchTogetherEntryViewModel(gateway) - - gateway.roomSnapshot.value = null - - assertNull(viewModel.currentRoom.value) - } - - @Test - fun titleHostStillSetsTheSelectedTitle() = runTest(dispatcher) { - val gateway = FakeGateway() - val viewModel = WatchTogetherEntryViewModel(gateway) - - viewModel.host(contentId = "movie-1", fileId = 7) - - assertEquals( - listOf(SetSelectionRequest(contentId = "movie-1", fileId = 7)), - gateway.selectionRequests, - ) - } - - @Test - fun joinByCodeTrimsAndUsesExistingErrorMapping() = runTest(dispatcher) { - val gateway = FakeGateway() - val viewModel = WatchTogetherEntryViewModel(gateway) - - viewModel.joinByCode(" ABCD1234 ") - assertEquals(listOf(JoinRoomRequest(code = "ABCD1234")), gateway.joinRequests) - - viewModel.consumeDestination() - gateway.joinResult = ApiResult.NetworkError(IllegalStateException("offline")) - viewModel.joinByCode("EFGH5678") - assertEquals("Network error. Check your connection.", viewModel.uiState.value.error) - } - - private class FakeGateway( - room: RoomSnapshot? = null, - ) : WatchTogetherEntryGateway { - override val roomSnapshot = MutableStateFlow(room) - val createRequests = mutableListOf() - val joinRequests = mutableListOf() - val selectionRequests = mutableListOf() - var joinResult: ApiResult? = null - var nextRoom = RoomSnapshot( - roomId = "room-1", - phase = RoomPhase.Lobby, - selectionMode = RoomSelectionMode.Vote, - ) - - override suspend fun createRoom(request: CreateRoomRequest): ApiResult { - createRequests += request - roomSnapshot.value = nextRoom - return ApiResult.Success(RoomResponse(nextRoom, "test-room-token")) - } - - override suspend fun joinRoom(request: JoinRoomRequest): ApiResult { - joinRequests += request - joinResult?.let { return it } - roomSnapshot.value = nextRoom - return ApiResult.Success(RoomResponse(nextRoom, "test-room-token")) - } - - override suspend fun setSelection( - request: SetSelectionRequest, - ): ApiResult { - selectionRequests += request - nextRoom = nextRoom.copy( - selectedContentId = request.contentId, - selectedFileId = request.fileId, - ) - roomSnapshot.value = nextRoom - return ApiResult.Success(RoomResponse(nextRoom, "test-room-token")) - } - } -} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyErrorTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyErrorTest.kt deleted file mode 100644 index 69f78ce0d4..0000000000 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyErrorTest.kt +++ /dev/null @@ -1,177 +0,0 @@ -package org.prairieserver.prairie.android.ui.screens.watchtogether - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.prairieserver.prairie.model.watchtogether.AddSuggestionRequest -import org.prairieserver.prairie.model.watchtogether.CreateRoomRequest -import org.prairieserver.prairie.model.watchtogether.JoinRoomRequest -import org.prairieserver.prairie.model.watchtogether.PromoteSuggestionRequest -import org.prairieserver.prairie.model.watchtogether.RoomResponse -import org.prairieserver.prairie.model.watchtogether.RoomSnapshot -import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest -import org.prairieserver.prairie.model.watchtogether.SuggestionsResponse -import org.prairieserver.prairie.model.watchtogether.UpdatePolicyRequest -import org.prairieserver.prairie.network.ApiResult -import org.prairieserver.prairie.network.AuthScopeSnapshot -import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier -import org.prairieserver.prairie.network.api.WatchTogetherApi -import org.prairieserver.prairie.repository.WatchTogetherRepository -import org.prairieserver.prairie.watchtogether.RoomSession -import kotlin.test.AfterTest -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals - -@OptIn(ExperimentalCoroutinesApi::class) -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = android.app.Application::class) -class WatchTogetherLobbyErrorTest { - private val dispatcher = UnconfinedTestDispatcher() - - @BeforeTest - fun setUp() { - Dispatchers.setMain(dispatcher) - } - - @AfterTest - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - fun `rejected lobby operations use the transient repository message path`() = runTest(dispatcher) { - val api = FailingLobbyApi() - val repository = WatchTogetherRepository( - api = api, - authScopeProvider = { AUTH_SCOPE }, - ) - repository.createRoom(CreateRoomRequest()) - val roomSession = RoomSession(repository, backgroundScope, DefaultIdentityTransitionBarrier()) - val viewModel = WatchTogetherLobbyViewModel("room-1", repository, roomSession) - val messages = mutableListOf() - val collector = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.errors.toList(messages) - } - - viewModel.vote("suggestion-1") - viewModel.promote("suggestion-1") - repository.reportDeliveryFailure("Socket request rejected") - runCurrent() - - assertEquals( - listOf( - "Voting is disabled", - "Only the host can promote", - "Socket request rejected", - ), - messages, - ) - collector.cancel() - } - - @Test - fun `suggest rejection remains a visible one shot detail message`() = runTest(dispatcher) { - val repository = WatchTogetherRepository( - api = FailingLobbyApi(), - authScopeProvider = { AUTH_SCOPE }, - ) - repository.createRoom(CreateRoomRequest()) - val viewModel = SuggestToRoomViewModel(repository) - - viewModel.suggest("movie-1", "movie", "Movie One", null, null) - runCurrent() - - assertEquals("Suggestions are locked", viewModel.uiState.value.error) - viewModel.clearError() - assertEquals(null, viewModel.uiState.value.error) - } - - private class FailingLobbyApi : WatchTogetherApi { - private val roomResponse = - ApiResult.Success(RoomResponse(RoomSnapshot(roomId = "room-1"), "room-token")) - - override suspend fun createRoom(request: CreateRoomRequest, scope: AuthScopeSnapshot) = roomResponse - override suspend fun joinRoom(request: JoinRoomRequest, scope: AuthScopeSnapshot) = roomResponse - override suspend fun getRoom(roomId: String, roomToken: String, scope: AuthScopeSnapshot) = roomResponse - override suspend fun setSelection( - roomId: String, - roomToken: String, - request: SetSelectionRequest, - scope: AuthScopeSnapshot, - ) = roomResponse - - override suspend fun updatePolicy( - roomId: String, - roomToken: String, - request: UpdatePolicyRequest, - scope: AuthScopeSnapshot, - ) = roomResponse - - override suspend fun closeRoom(roomId: String, roomToken: String, scope: AuthScopeSnapshot) = - ApiResult.Success(Unit) - - override suspend fun listSuggestions( - roomId: String, - roomToken: String, - scope: AuthScopeSnapshot, - ) = ApiResult.Success(SuggestionsResponse()) - - override suspend fun addSuggestion( - roomId: String, - roomToken: String, - request: AddSuggestionRequest, - scope: AuthScopeSnapshot, - ): ApiResult = - ApiResult.Error(409, "suggestions_locked", "Suggestions are locked") - - override suspend fun deleteSuggestion( - roomId: String, - roomToken: String, - suggestionId: String, - scope: AuthScopeSnapshot, - ) = ApiResult.Success(SuggestionsResponse()) - - override suspend fun vote( - roomId: String, - roomToken: String, - suggestionId: String, - scope: AuthScopeSnapshot, - ): ApiResult = - ApiResult.Error(409, "voting_disabled", "Voting is disabled") - - override suspend fun unvote( - roomId: String, - roomToken: String, - suggestionId: String, - scope: AuthScopeSnapshot, - ) = ApiResult.Success(SuggestionsResponse()) - - override suspend fun promoteSuggestion( - roomId: String, - roomToken: String, - request: PromoteSuggestionRequest, - scope: AuthScopeSnapshot, - ): ApiResult = - ApiResult.Error(403, "host_required", "Only the host can promote") - } - - private companion object { - val AUTH_SCOPE = AuthScopeSnapshot( - serverId = "server-1", - profileId = "profile-1", - serverUrl = "https://example.test", - profileToken = "profile-token", - identityGeneration = 1L, - ) - } -} diff --git a/androidTvApp/src/androidMain/AndroidManifest.xml b/androidTvApp/src/androidMain/AndroidManifest.xml index dddb8ad483..ddd6526772 100644 --- a/androidTvApp/src/androidMain/AndroidManifest.xml +++ b/androidTvApp/src/androidMain/AndroidManifest.xml @@ -76,6 +76,18 @@ + + + + + + + + = Build.VERSION_CODES.TIRAMISU) { + lifecycleScope.launch { + get(ProfileLaunchPreferences::class.java).state + .map { it.behavior == ProfileLaunchBehavior.Automatic } + .distinctUntilChanged() + .collect { automatic -> setRecentsScreenshotEnabled(automatic) } + } + } + + // Run the PrairieCast receiver while the app is started AND signed in, + // following sign-in, profile selection and sign-out without needing a + // background/foreground cycle. start()/stop() run here on the main + // thread, and repeatOnLifecycle cancels this block at ON_STOP, so no + // start() can land after onStop()'s stop(). + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + val receiver = get(TvPrairieCastReceiver::class.java) + val identityTransitions = + get(IdentityTransitionBarrier::class.java) + var running = false + castAuthentication(identityTransitions).collect { state -> + // A read that crossed the IO hop after a newer identity + // change is stale. Every generation bump ends in a + // DID_CHANGE that triggers a fresh read, so wait for that + // one; acting on this could stop a handoff that just began. + if (state.generation != identityTransitions.generation.value) return@collect + if (state.authenticated == running) return@collect + running = state.authenticated + if (running) receiver.start() else receiver.stop() + } + } + } + setContent { var startRoute by remember { mutableStateOf(null) } var splashPlaybackComplete by remember { mutableStateOf(hasShownColdSplash) } @@ -111,6 +176,12 @@ class MainTvActivity : ComponentActivity() { launchAuthenticatedStartupWarmup(route) } + // Profile Selection: from the moment Prairie returns and the setting + // asks, cover the previous profile's screens until Who's Watching + // has replaced them (TvAppNavigation lifts the request). + val profileAwayTracker = remember { get(ProfileAwayTracker::class.java) } + val profileCoverVisible by profileAwayTracker.selectionRequired.collectAsState() + PrairieTvTheme { val resolvedRoute = startRoute val splashVisible = resolvedRoute == null || !splashPlaybackComplete @@ -121,11 +192,12 @@ class MainTvActivity : ComponentActivity() { // Consume ALL input at the root while the splash // overlay is up: no input-dispatch-timeout ANR, and // no keys leak into the app pre-rendering below — - // even after its content grabs focus. + // even after its content grabs focus. The profile + // cover gates input the same way. if (splashVisible) { TvFocusLog.d { "key swallowed by splash gate" } } - splashVisible + splashVisible || profileCoverVisible }, ) { if (resolvedRoute != null) { @@ -140,6 +212,9 @@ class MainTvActivity : ComponentActivity() { modifier = Modifier.fillMaxSize(), ) } + if (profileCoverVisible && !splashVisible) { + Box(modifier = Modifier.fillMaxSize().background(Color.Black)) + } if (splashVisible) { val splashFocus = remember { FocusRequester() } // PURE black — the splash video's encoded black is #000000, and any @@ -193,31 +268,11 @@ class MainTvActivity : ComponentActivity() { override fun onStart() { super.onStart() DiagnosticsLifecycleLogger.state("foreground") + get(ProfileAwayTracker::class.java).onForeground() val refresher = get(ServerDrivenConfigRefresher::class.java) val monitor = get(ServerReachabilityMonitor::class.java) monitor.startForeground() lifecycleScope.launch(Dispatchers.IO) { refresher.refreshIfStale() } - lifecycleScope.launch(Dispatchers.IO) { - // The auth check suspends; a quick background could run onStop's - // stop() first (a no-op — nothing started) and THEN this start(), - // leaving the receiver advertising while backgrounded. Re-check - // the lifecycle after the suspension. - if (isAuthenticatedForCast() && - lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.STARTED) - ) { - val receiver = get(TvPrairieCastReceiver::class.java) - receiver.start() - // The lifecycle check above is a TOCTOU: the activity can stop - // between the check and start(), so onStop()'s stop() lands - // BEFORE this start() and the receiver keeps advertising while - // backgrounded. Compensate after the fact — start()/stop() are - // @Synchronized and stop() is idempotent, so every interleaving - // terminates with the receiver stopped when backgrounded. - if (!lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.STARTED)) { - receiver.stop() - } - } - } } /** @@ -282,6 +337,10 @@ class MainTvActivity : ComponentActivity() { override fun onStop() { DiagnosticsLifecycleLogger.state("background") super.onStop() + // A configuration-change recreation is not leaving Prairie. + if (!isChangingConfigurations) { + get(ProfileAwayTracker::class.java).onBackground() + } val monitor = get(ServerReachabilityMonitor::class.java) monitor.stopForeground() get(TvPrairieCastReceiver::class.java).stop() @@ -299,6 +358,20 @@ class MainTvActivity : ComponentActivity() { private suspend fun resolveStartDestination(): String { val registry = get(ServerRegistry::class.java) val tokenManager = get(TokenManager::class.java) + val processStart = !hasResolvedLaunchProfile + // An unreadable registry hides which servers remember a profile, so + // settle on a later launch rather than mistake this TV for a new one. + if (processStart && !registry.stateLoadFailed) { + // The setting is device-wide, so a profile saved for any server + // makes this an existing TV, not just one on the active server. + val hasRememberedProfile = registry.entries.value.any { !it.profileId.isNullOrBlank() } || + !tokenManager.getProfileId().isNullOrBlank() + get(ProfileLaunchPreferences::class.java) + .settleFirstLaunch(hasRememberedProfile = hasRememberedProfile) + } + // Only once settled: an Activity recreated while this was suspended + // must still settle. + hasResolvedLaunchProfile = true val activeEntry = registry.activeEntry.value // Prairie: first run lands on the server list (LAN discovery; @@ -334,6 +407,30 @@ class MainTvActivity : ComponentActivity() { val profileId = tokenManager.getProfileId() if (profileId.isNullOrBlank()) return TvRoute.ProfileSelection.route + // A remote-playback overlay (only possible when the Activity is + // recreated in a live process) owns identity until it ends; the + // navigation graph applies the return rule once it has. + if (tokenManager.hasTemporaryScope()) return TvRoute.Main.route + + // Profile Selection (prairie-apple `ProfileLaunchState.resolution`). A + // process start applies the launch rule: Every Time, or a timed choice + // whose away interval ran out while Prairie was closed. A later Activity + // in this process (Back left Prairie, then it was reopened) follows the + // return rule the away tracker evaluated in onStart. Either way the + // profile is cleared here, before the picker loads, and losing its PIN + // proof makes a protected profile ask for its PIN again. + val launchPreferences = get(ProfileLaunchPreferences::class.java) + val requiresSelection = if (processStart) { + launchPreferences.requiresSelectionAtLaunch() + } else { + get(ProfileAwayTracker::class.java).selectionRequired.value + } + if (requiresSelection) { + get(TvActiveProfileReset::class.java).clearActiveProfile() + return TvRoute.ProfileSelection.route + } + if (processStart) launchPreferences.clearBackgroundedAt() + return TvRoute.Main.route } @@ -353,21 +450,6 @@ class MainTvActivity : ComponentActivity() { } if (startRoute != TvRoute.Main.route) return lifecycleScope.launch(Dispatchers.IO) { - // Re-check the lifecycle before starting the cast receiver: a - // cold-start followed by an immediate Home can dispatch this after - // onStop()'s stop() already ran, leaving NSD advertising + the cast - // socket up while backgrounded. Mirrors the onStart() guard. - if (lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.STARTED)) { - val receiver = get(TvPrairieCastReceiver::class.java) - receiver.start() - // Same TOCTOU compensation as onStart(): if the activity - // stopped between the check and start(), undo the start — - // start()/stop() are @Synchronized and stop() is idempotent, - // so every interleaving ends stopped when backgrounded. - if (!lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.STARTED)) { - receiver.stop() - } - } warmAuthenticatedStartup( context = applicationContext, authRepository = get(AuthRepository::class.java), @@ -380,13 +462,54 @@ class MainTvActivity : ComponentActivity() { ), serverUrl = get(ServerRegistry::class.java).activeEntry.value?.url, artworkPlan = StartupArtworkPlan.tv(), + // What Home will draw right now (cached answer, else logos on); + // never waits on the title art request. + showTitleArt = { get(TitleArtStore::class.java).state.value.showTitleArt }, ) } } + /** Whether the TV may receive casts, read under identity [generation]. */ + private data class CastAuthState(val authenticated: Boolean, val generation: Long) + + /** + * Emits whether the TV is signed in far enough to receive casts, re-read + * after every committed identity change, so signing in, picking a profile + * or signing out while the app stays open starts or stops the receiver. + */ + @OptIn(ExperimentalCoroutinesApi::class) + private fun castAuthentication(identityTransitions: IdentityTransitionBarrier): Flow { + val registry = get(ServerRegistry::class.java) + // Widened to nullable so a null marker can stand for "subscribed". + val transitions: SharedFlow = identityTransitions.transitions + return merge( + // The marker triggers the first read only once the subscription is + // live, so a transition finishing at that moment can't be missed. + // DID_CHANGE, not WILL_CHANGE: the new identity is only readable + // after the transition's block has run. + transitions + .onSubscription { emit(null) } + .filter { it == null || it.phase == IdentityTransitionPhase.DID_CHANGE }, + registry.activeEntry, + ) + .mapLatest { + // Captured before the read, so a transition landing mid-read + // marks the result stale. + val generation = identityTransitions.generation.value + CastAuthState(isAuthenticatedForCast(), generation) + } + // Keeps a repeated answer under a new generation: the collector may + // have dropped the earlier one as stale. + .distinctUntilChanged() + .flowOn(Dispatchers.IO) + } + private suspend fun isAuthenticatedForCast(): Boolean { val registry = get(ServerRegistry::class.java) val tokenManager = get(TokenManager::class.java) + // A remote-playback overlay counts as signed in: stopping the receiver + // would end the handoff it belongs to. + if (tokenManager.hasTemporaryScope()) return true return registry.activeEntry.value != null && !tokenManager.getAccessToken().isNullOrBlank() && !tokenManager.getProfileId().isNullOrBlank() diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/PrairieTvApplication.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/PrairieTvApplication.kt index c70d8a6c03..39e82b88dd 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/PrairieTvApplication.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/PrairieTvApplication.kt @@ -7,6 +7,8 @@ import coil3.ImageLoader import coil3.PlatformContext import coil3.SingletonImageLoader import org.prairieserver.prairie.common.di.playerInfraModule +import org.prairieserver.prairie.common.ui.marquee.marqueeModule +import org.prairieserver.prairie.common.di.watchPartyModule import org.prairieserver.prairie.common.di.playerModule import org.prairieserver.prairie.common.diagnostics.DiagnosticsCoordinator import org.prairieserver.prairie.common.diagnostics.DiagnosticsStartup @@ -37,18 +39,28 @@ class PrairieTvApplication : Application(), Configuration.Provider, SingletonIma DiagnosticsStartup.installCrashCapture(this) val koinApp = startKoin { androidContext(this@PrairieTvApplication) - modules(sharedModules() + playerModule + playerInfraModule + androidTvModule + diagnosticsModule) + modules(sharedModules() + playerModule + playerInfraModule + marqueeModule + watchPartyModule + androidTvModule + diagnosticsModule) } DiagnosticsStartup.startCoordinator { koinApp.koin.get() } koinApp.koin.get().start( kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.IO), ) + // Profile-verification recovery: clears a profile whose PIN token the + // server stopped accepting (after an access change), including when + // background work hits it first. Guarded — never load-bearing. + runCatching { + koinApp.koin.get() + }.onFailure { + // Class name only: the exception chain can carry auth or server data. + android.util.Log.w("PrairieTvApplication", "Profile verification recovery init failed: ${it::class.simpleName}") + } // Live-home socket (Apple realtime-updates spec). Guarded — a dead // socket just means Home refreshes on open only. runCatching { org.prairieserver.prairie.tv.home.HomeRealtimeForegroundStarter( coordinator = koinApp.koin.get(), profileRepository = koinApp.koin.get(), + accessCheck = koinApp.koin.getOrNull(), ).register() }.onFailure { android.util.Log.w("PrairieTvApplication", "Home realtime starter init failed", it) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/cast/RemotePlaybackIdentityManager.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/cast/RemotePlaybackIdentityManager.kt index 6c46f2ff45..df3d6688b7 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/cast/RemotePlaybackIdentityManager.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/cast/RemotePlaybackIdentityManager.kt @@ -5,6 +5,7 @@ import java.util.UUID import kotlinx.coroutines.delay import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull import org.prairieserver.prairie.cast.PrairieCastHandoffChallenge import org.prairieserver.prairie.cast.PrairieCastHandoffOffer import org.prairieserver.prairie.cast.PrairieCastHandoffReady @@ -37,6 +38,8 @@ class RemotePlaybackIdentityManager( val controllerDeviceId: String, val controllerDeviceName: String?, val expiresAtEpochMs: Long, + /** The receiver run (see [TvPrairieCastReceiver]) that last installed or reused it. */ + val receiverRun: Long, ) private val mutex = Mutex() @@ -52,19 +55,36 @@ class RemotePlaybackIdentityManager( active.controllerDeviceId == controllerDeviceId } + /** + * Installs the phone's temporary identity once the server approves it. + * [beforeActivation] runs after approval and before anything changes, while + * the outgoing identity (the TV's own or an earlier phone's) still owns the + * credentials, so whatever is playing can close its server session under the + * identity it started with. An offer that fails or is denied leaves the + * current identity and playback alone. + */ suspend fun prepare( offer: PrairieCastHandoffOffer, controllerDeviceId: String, controllerDeviceName: String?, + receiverRun: Long, + /** Called once the offer needs the full server exchange rather than a reuse. */ + onFullHandoff: () -> Unit = {}, + beforeActivation: suspend () -> Unit = {}, onChallenge: suspend (PrairieCastHandoffChallenge) -> Unit, ): PrairieCastHandoffReady = mutex.withLock { validateOffer(offer) + // An end in flight has already unpublished its identity (see + // [endLocked]) and holds this lock, so an identity being ended is + // never reused: this waits for it and takes the full path. activeIdentity?.takeIf { matches(offer, controllerDeviceId) }?.let { active -> - return@withLock active.toReady(offer.requestId, reused = true) + val reclaimed = active.copy(receiverRun = receiverRun) + activeIdentity = reclaimed + return@withLock reclaimed.toReady(offer.requestId, reused = true) } - endLocked() + onFullHandoff() val capability = deviceLoginApi.remotePlaybackCapabilityAt(offer.serverURL).successOrThrow() require(capability.remotePlaybackHandoff && PrairieCastProtocol.version in capability.protocolVersions) { @@ -106,6 +126,8 @@ class RemotePlaybackIdentityManager( } val expiresAtMs = poll.sessionExpiresAt?.let(::parseInstantMillis) ?: (System.currentTimeMillis() + DEFAULT_SESSION_MS) + beforeActivation() + endLocked() val generationId = UUID.randomUUID().toString() tokenManager.beginTemporaryScope( TemporaryAuthScope( @@ -139,6 +161,7 @@ class RemotePlaybackIdentityManager( controllerDeviceId = controllerDeviceId, controllerDeviceName = controllerDeviceName, expiresAtEpochMs = expiresAtMs, + receiverRun = receiverRun, ) activeIdentity = active return@withLock active.toReady(offer.requestId, reused = false) @@ -161,25 +184,56 @@ class RemotePlaybackIdentityManager( suspend fun end() = mutex.withLock { endLocked() } + /** + * Ends the identity only if [generationId] is still the active one. The + * check runs under the same lock as [prepare], so a delayed cleanup can + * never end a replacement identity installed after it was scheduled. + */ + suspend fun end(generationId: String): Boolean = mutex.withLock { + if (activeIdentity?.generationId != generationId) return@withLock false + endLocked() + true + } + + /** + * Ends the active identity unless a receiver run started after [run] has + * installed or reused it. A same-phone handoff keeps the generation, so a + * generation check would let a delayed stop cleanup revoke the identity + * that handoff just claimed. Claims made by [run] itself (or earlier) do + * not protect it: that run is stopped, so nothing else will end it. + */ + suspend fun endIfNotClaimedSince(run: Long): Boolean = mutex.withLock { + val active = activeIdentity ?: return@withLock false + if (active.receiverRun > run) return@withLock false + endLocked() + true + } + private suspend fun endLocked() { val active = activeIdentity + // Unpublish before the logout below: while it is in flight, readers + // outside the lock must not launch a title under this identity. + activeIdentity = null try { if (active != null && tokenManager.hasTemporaryScope()) { - deviceLoginApi.endRemotePlayback( - AuthScopeSnapshot( - serverId = active.serverId, - serverUrl = active.serverUrl, - profileId = active.profileId, - profileToken = null, - credentialGenerationId = active.generationId, - ), - ) + // Bounded: a handoff waiting on this lock must not sit behind + // an unreachable server's request timeout. + withTimeoutOrNull(LOGOUT_TIMEOUT_MS) { + deviceLoginApi.endRemotePlayback( + AuthScopeSnapshot( + serverId = active.serverId, + serverUrl = active.serverUrl, + profileId = active.profileId, + profileToken = null, + credentialGenerationId = active.generationId, + ), + ) + } } } finally { // Local credential teardown is mandatory even if the best-effort // server logout throws or the transport disappears mid-request. tokenManager.endTemporaryScope() - activeIdentity = null } } @@ -226,5 +280,6 @@ class RemotePlaybackIdentityManager( private companion object { const val DEFAULT_SESSION_MS = 24 * 60 * 60 * 1_000L + const val LOGOUT_TIMEOUT_MS = 5_000L } } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/cast/RemotePlaybackOwnIdentityPolicy.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/cast/RemotePlaybackOwnIdentityPolicy.kt new file mode 100644 index 0000000000..a8aee69ef4 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/cast/RemotePlaybackOwnIdentityPolicy.kt @@ -0,0 +1,31 @@ +package org.prairieserver.prairie.tv.cast + +import org.prairieserver.prairie.network.AndroidServerRegistry + +/** + * Decides when a phone's handoff offer can skip the server-mediated profile + * exchange because the TV is already signed in as the offered profile on the + * same server. Mirrors prairie-apple's `RemotePlaybackOwnIdentityPolicy`. + * + * The fast path never grants authorization: [authorizedAtHello] must already + * hold, so a cross-server controller always takes the full handoff. + */ +internal object RemotePlaybackOwnIdentityPolicy { + fun accepts( + /** The controller's hello matched this TV's own server. */ + authorizedAtHello: Boolean, + /** A phone's temporary identity is installed, or still being ended. */ + holdsTemporaryIdentity: Boolean, + signedIn: Boolean, + ownServerId: String?, + ownProfileId: String?, + offerServerId: String, + offerProfileId: String, + ): Boolean = + authorizedAtHello && + !holdsTemporaryIdentity && + signedIn && + AndroidServerRegistry.serverIdsMatch(ownServerId, offerServerId) && + !ownProfileId.isNullOrBlank() && + ownProfileId == offerProfileId +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/cast/TvPrairieCastPlayerAdapter.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/cast/TvPrairieCastPlayerAdapter.kt index ee6a61d2b8..a250670935 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/cast/TvPrairieCastPlayerAdapter.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/cast/TvPrairieCastPlayerAdapter.kt @@ -13,12 +13,17 @@ class TvPrairieCastPlayerAdapter( private val setPlaybackSpeed: (Double) -> Unit, private val setQuality: (String) -> Unit, private val setVideoGravity: (String) -> Unit, - private val setHdrEnabled: (Boolean) -> Unit, private val setSubtitleSyncMs: (Int) -> Unit, private val setSubtitlePosition: (String) -> Unit, private val setVolume: (Double) -> Unit, private val setMuted: (Boolean) -> Unit, private val playNext: () -> Unit, + /** + * Non-null when a phone's launch must not replace this player (a Watch + * Party is playing): the reason, sent back to the phone as an error. + * Called off the main thread. + */ + val launchRefusal: () -> String? = { null }, ) { fun handle(command: PrairieCastControlCommand) { // Field mapping follows Apple's PrairieControlCommand exactly: seek uses @@ -35,7 +40,6 @@ class TvPrairieCastPlayerAdapter( PrairieCastControlCommand.SetQuality -> command.value?.let(setQuality) PrairieCastControlCommand.SetPlaybackSpeed -> command.speed?.let(setPlaybackSpeed) PrairieCastControlCommand.SetVideoGravity -> command.value?.let(setVideoGravity) - PrairieCastControlCommand.SetHdrEnabled -> command.enabled?.let(setHdrEnabled) PrairieCastControlCommand.SetSubtitleSyncMs -> command.milliseconds?.let(setSubtitleSyncMs) PrairieCastControlCommand.SetSubtitlePosition -> command.value?.let(setSubtitlePosition) PrairieCastControlCommand.SetVolume -> command.volume?.let(setVolume) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/cast/TvPrairieCastReceiver.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/cast/TvPrairieCastReceiver.kt index 41e94b3851..955e61dd68 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/cast/TvPrairieCastReceiver.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/cast/TvPrairieCastReceiver.kt @@ -4,16 +4,21 @@ import android.util.Log import java.io.Closeable import java.net.ServerSocket import java.net.Socket +import java.time.Instant +import java.util.UUID import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.receiveAsFlow @@ -24,10 +29,14 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.json.Json +import org.prairieserver.prairie.cast.PrairieCastControlCommand import org.prairieserver.prairie.cast.PrairieCastError import org.prairieserver.prairie.cast.PrairieCastHello import org.prairieserver.prairie.cast.PrairieCastHandoffCancel +import org.prairieserver.prairie.cast.PrairieCastHandoffOffer +import org.prairieserver.prairie.cast.PrairieCastHandoffReady import org.prairieserver.prairie.cast.PrairieCastLaunchRequest import org.prairieserver.prairie.cast.PrairieCastMessage import org.prairieserver.prairie.cast.PrairieCastPeerRole @@ -41,6 +50,9 @@ import org.prairieserver.prairie.common.lan.PrairieCastTls import org.prairieserver.prairie.common.lan.PrairieCastTlsSession import org.prairieserver.prairie.network.AndroidServerRegistry import org.prairieserver.prairie.network.ServerRegistry +import org.prairieserver.prairie.network.IdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionPhase +import org.prairieserver.prairie.network.TokenManager /** * TV-side PrairieCast receiver for the cross-platform v2 protocol shared with @@ -61,19 +73,35 @@ import org.prairieserver.prairie.network.ServerRegistry * right after every accepted control command. * - A deliberate teardown sends a `close` frame ahead of the FIN so the * controller can tell "disconnected on purpose" from a dropped link. + * - A same-server phone offering the profile this TV is already signed in + * with gets `handoff_ready` at once and plays on the TV's own identity. + * A borrowed profile stays installed while its phone is connected, so the + * next title reuses it, and is handed back when that phone leaves, another + * controller takes over, or after [IDENTITY_IDLE_MS] with nothing playing. */ class TvPrairieCastReceiver( private val advertiser: PrairieCastNsdAdvertiser, private val serverRegistry: ServerRegistry, + private val tokenManager: TokenManager, + /** Reports sign-in, profile, server and borrowed-identity changes. */ + private val identityTransitions: IdentityTransitionBarrier, private val identityManager: RemotePlaybackIdentityManager, private val deviceNameProvider: () -> String, private val deviceIdProvider: () -> String, + /** Suspends until the player's queued session teardown has finished. */ + private val awaitPlaybackTeardown: suspend () -> Unit, + /** True while this TV is in a Watch Party, whose membership an identity swap would end. */ + private val inWatchParty: () -> Boolean = { false }, ) { data class StandbyState( val controllerName: String?, val serverName: String?, + /** Set while a full profile handoff prepares a title the phone is about to play. */ + val preparing: Preparing? = null, ) { val controllerLabel: String get() = controllerName?.takeIf { it.isNotBlank() } ?: "phone" + + data class Preparing(val title: String?) } private val json = Json { @@ -94,30 +122,65 @@ class TvPrairieCastReceiver( private var sessionEpoch: Long = 0 private var activePlayer: ActivePlayer? = null private val volumeTracker = PrairieCastVolumeTracker() - private val launchRequestChannel = Channel(capacity = 1) - val launchRequests: Flow = launchRequestChannel.receiveAsFlow() - private var pendingPlayerIdentityGeneration: String? = null + private val launchRequestChannel = Channel(capacity = 1) + val launchRequests: Flow = launchRequestChannel.receiveAsFlow() + // The admitted launch on its way to the player, until its player registers + // or the launch expires. Non-null holds off other launches and handoffs; + // cleanup clears it only for its own launch. + private var pendingLaunch: AdmittedLaunch? = null + // The launch whose player registered last. Its player registers again when + // its screen is recreated (an activity restart), which is allowed only + // while the identity it was admitted on is unchanged. + private var registeredLaunch: AdmittedLaunch? = null + // Counts sign-in, profile, server and borrowed-identity changes that affect + // the identity in use. A launch stays current only while it is unchanged; + // removing some other saved server does not count. + private var identityRevision: Long = 0 + + init { + identityTransitions.installGate { transition -> + if (transition.phase == IdentityTransitionPhase.WILL_CHANGE && transition.affectsCurrentIdentity) { + synchronized(this@TvPrairieCastReceiver) { identityRevision += 1 } + } + } + } private var identityEndJob: Job? = null + // A scheduled end that has committed to ending this generation. It and + // launch admission decide under the receiver lock, so a launch is never + // admitted onto an identity whose end has started. + private var endingGenerationId: String? = null // stop() cancels the receiver scope, so cleanup for a ready-but-unconsumed // temporary identity must have an owner that survives that cancellation. private val identityCleanupScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + // Bumped by every start(). Handoffs tag the identity with the run that + // claimed it, so stop()'s deferred cleanup can tell a returning phone's + // claim from one made by the session it is tearing down. + @Volatile + private var receiverRun: Long = 0 @Synchronized fun start() { if (scope != null) return + receiverRun += 1 DiagnosticsCastLogger.event("TV cast receiver started") val newScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) scope = newScope newScope.launch { - val socket = ServerSocket(0).also { serverSocket = it } - val server = serverRegistry.activeEntry.value - val remote = identityManager.activeIdentity - advertiser.start( - port = socket.localPort, - serverId = remote?.serverId ?: server?.id, - serverName = remote?.serverName ?: server?.displayName, - playing = activePlayer != null, - ) + val socket = ServerSocket(0) + // stop() can run while the socket is being created, and scope + // cancellation can't interrupt that. Publish and advertise under the + // lock only if this scope is still current; otherwise the stopped + // receiver would keep listening and stay listed in Bonjour. + val published = synchronized(this@TvPrairieCastReceiver) { + if (scope !== newScope) return@synchronized false + serverSocket = socket + refreshAdvertisement() + true + } + if (!published) { + runCatching { socket.close() } + return@launch + } Log.i(TAG, "PrairieCast listening on ${socket.localPort} for ${PrairieCastProtocol.serviceType}") acceptLoop(socket) } @@ -125,21 +188,21 @@ class TvPrairieCastReceiver( // receiver runs (it stays up across server switches until onStop), and // drop the live controller session — its hello was authorized against // the previous server, so keeping it would let an old-server remote - // drive playback on the new one. + // drive playback on the new one. Only a different server or advertised + // name counts: profile and last-used updates rewrite the entry too, and + // re-registering on a profile clear raced the stop() that follows it, + // losing the Bonjour goodbye so the TV stayed listed after sign-out. newScope.launch { - serverRegistry.activeEntry.drop(1).collect { entry -> - closePreviousController() - identityManager.end() - val port = synchronized(this@TvPrairieCastReceiver) { serverSocket?.localPort } - if (port != null) { - advertiser.start( - port = port, - serverId = entry?.id, - serverName = entry?.displayName, - playing = activePlayer != null, - ) + serverRegistry.activeEntry + .distinctUntilChangedBy { it?.id to it?.displayName } + .drop(1) + .collect { + closePreviousController() + identityManager.end() + // Re-advertises under the lock, and is a no-op once stop() + // has cleared the socket. + refreshAdvertisement() } - } } } @@ -147,8 +210,11 @@ class TvPrairieCastReceiver( fun stop() { DiagnosticsCastLogger.event("TV cast receiver stopped") advertiser.stop() - val identityGeneration = identityManager.activeIdentity?.generationId - pendingPlayerIdentityGeneration = null + val stoppedRun = receiverRun + pendingLaunch = null + // A launch navigation has not taken yet belongs to the session being + // torn down; it must not open when the TV app comes back. + launchRequestChannel.tryReceive() identityEndJob = null // Close the session directly (not via closePreviousController, which // launches the goodbye on `scope` — the scope we cancel a line later, @@ -163,34 +229,62 @@ class TvPrairieCastReceiver( serverSocket = null scope?.cancel() scope = null - if (identityGeneration != null) { - identityCleanupScope.launch { - // Exact-generation guard: a rapid stop/start/new handoff must - // not let the old shutdown clean up the replacement identity. - if (identityManager.activeIdentity?.generationId == identityGeneration) { - identityManager.end() - } - } + // Always scheduled, even with no identity yet: a handoff from the + // session being torn down can still install or reuse one after this. + identityCleanupScope.launch { + // The player's ON_STOP observer runs before this (Activity + // onStop) and has already queued its final progress report and + // stopSession, which ride on this temporary identity. Ending it + // first revokes the session under them (401). The player's + // unregister path cannot own this: its composition is not + // disposed until the activity starts again. Wait for the queued + // teardown (bounded, so the identity is always ended), then end it. + withTimeoutOrNull(PLAYBACK_TEARDOWN_TIMEOUT_MS) { awaitPlaybackTeardown() } + // Run guard: only a handoff from a later start() protects the + // identity, whether it reused this generation or replaced it. + // Claims from the stopped run's own sessions do not, since + // nothing else is left to end them. + identityManager.endIfNotClaimedSince(stoppedRun) } } + /** + * Registers the player on screen. [launchId] is the [Launch.id] the player + * was opened for, if a phone launched it. Navigation already refuses a stale + * launch; if one goes stale in the moment before its player registers + * (expired, or its identity changed), nothing is registered and + * [onStaleLaunch] runs so the screen closes instead of playing on. + */ @Synchronized fun registerPlayer( adapter: TvPrairieCastPlayerAdapter, + launchId: String? = null, + onStaleLaunch: () -> Unit = {}, stateProvider: () -> PrairieCastPlaybackState, ): Closeable { + if (launchId != null && !confirmLaunchLocked(launchId)) { + DiagnosticsCastLogger.warning("TV cast launch expired before its player opened") + onStaleLaunch() + return Closeable {} + } DiagnosticsCastLogger.event("TV cast player registered") identityEndJob?.cancel() identityEndJob = null - val identityGeneration = pendingPlayerIdentityGeneration - ?: identityManager.activeIdentity?.generationId - pendingPlayerIdentityGeneration = null + // Only the launched player takes the launch's generation; another + // player leaves the pending launch to its own player. + val launched = pendingLaunch?.takeIf { launchId != null && it.id == launchId } + val identityGeneration = launched?.generation ?: identityManager.activeIdentity?.generationId + if (launched != null) { + pendingLaunch = null + registeredLaunch = launched + } val player = ActivePlayer( adapter = adapter, stateProvider = stateProvider, identityGeneration = identityGeneration, ) activePlayer = player + activeSession?.let { clearPreparing(it, refresh = false) } _standbyState.value = null advertiser.updatePlaying(true) return Closeable { @@ -199,12 +293,22 @@ class TvPrairieCastReceiver( DiagnosticsCastLogger.event("TV cast player unregistered") activePlayer = null advertiser.updatePlaying(false) - if (player.identityGeneration != null) { - scheduleIdentityEnd(player.identityGeneration) + val generation = player.identityGeneration + if (generation != null) { + // The phone that lent the profile is still here: keep + // it so that phone's next title starts without a new + // handoff, until it leaves or the TV sits idle. + if (isLenderConnected(generation)) { + scheduleIdentityEnd(generation, IDENTITY_IDLE_MS) + } else { + scheduleIdentityEnd(generation, keepIfLenderReturns = true) + } } refreshStandbyState() } } + // After the slot is cleared: a handoff waiting on this checks it next. + player.unregistered.complete(Unit) } } @@ -346,11 +450,6 @@ class TvPrairieCastReceiver( } catch (t: Throwable) { Log.w(TAG, "PrairieCast controller session ended", t) } finally { - val orphanedReadyIdentity = if (session.remoteLaunchReady && activePlayer == null) { - identityManager.activeIdentity?.generationId - } else { - null - } synchronized(this) { if (activeSession === session) { activeSession = null @@ -358,8 +457,15 @@ class TvPrairieCastReceiver( } } session.close() - if (orphanedReadyIdentity != null) { - scheduleIdentityEnd(orphanedReadyIdentity, READY_TIMEOUT_MS) + // The phone that lent the profile left (disconnected, dropped, was + // disconnected from the TV, or lost the slot to another phone). + // Hand the profile back unless a title is playing on it or on its + // way to the player; playback outlives a lost socket. + // A phone that reconnects within the grace keeps it. + val lent = identityManager.activeIdentity + ?.takeIf { it.controllerDeviceId == session.controllerDeviceId } + if (lent != null && isIdentityIdle()) { + scheduleIdentityEnd(lent.generationId, keepIfLenderReturns = true) } } } @@ -400,6 +506,20 @@ class TvPrairieCastReceiver( } is PrairieCastMessage.HandoffOffer -> { val offer = message.handoffOffer + // A handoff swaps in the phone's profile, and any identity + // change leaves the Watch Party this TV is in. + if (inWatchParty()) { + session.send( + PrairieCastMessage.HandoffCancel( + PrairieCastHandoffCancel( + requestId = offer.requestId, + reason = "watch_party_active", + message = "Leave the Watch Party on the TV first.", + ), + ), + ) + return true + } val controllerId = session.controllerDeviceId if (session.negotiatedVersion != PrairieCastProtocol.version || controllerId == null) { session.send( @@ -413,20 +533,71 @@ class TvPrairieCastReceiver( ) return true } + // A launch on its way to the player would land on whatever + // identity this handoff installs. The phone tries again once the + // player opens, or the pending launch expires. + if (synchronized(this) { pendingLaunch != null }) { + session.send( + PrairieCastMessage.HandoffCancel( + PrairieCastHandoffCancel( + requestId = offer.requestId, + reason = "launch_pending", + message = LAUNCH_PENDING_MESSAGE, + ), + ), + ) + return true + } session.handoffJob?.cancel() - identityEndJob?.cancel() - identityEndJob = null + // A reused identity must survive a pending cleanup. One this + // offer would replace stays until approval, so its cleanup still + // runs if the handoff fails; after a replacement it finds a newer + // generation and does nothing. + if (identityManager.matches(offer, controllerId)) { + identityEndJob?.cancel() + identityEndJob = null + } + session.remoteLaunchReady = false + session.ownIdentityProfileId = null + val run = receiverRun session.handoffJob = scope?.launch { try { + // The hello may have been judged against a phone's + // identity that is gone now; with none installed, the + // TV's own server decides. + if (identityManager.activeIdentity == null) { + session.isAuthorized = AndroidServerRegistry.serverIdsMatch( + session.controllerServerId, + serverRegistry.activeServerId.value, + ) + refreshStandbyState() + } + ownIdentityReady(session, offer)?.let { ready -> + clearPreparing(session) + session.ownIdentityProfileId = offer.profileId + DiagnosticsCastLogger.event("TV cast handoff skipped on own profile") + session.send(PrairieCastMessage.HandoffReady(ready)) + session.send(PrairieCastMessage.State(currentState())) + return@launch + } + var fullHandoff = false val ready = identityManager.prepare( offer = offer, controllerDeviceId = controllerId, controllerDeviceName = session.controllerDeviceName, + receiverRun = run, + onFullHandoff = { + fullHandoff = true + showPreparing(session, offer.title) + }, + beforeActivation = ::stopPlayerBeforeIdentitySwap, ) { challenge -> if (activeSession === session) { session.send(PrairieCastMessage.HandoffChallenge(challenge)) } } + // A reuse is instant; drop a cancelled offer's title. + if (!fullHandoff) clearPreparing(session) if (activeSession !== session) { identityManager.end() return@launch @@ -438,33 +609,25 @@ class TvPrairieCastReceiver( refreshAdvertisement() session.send(PrairieCastMessage.HandoffReady(ready)) session.send(PrairieCastMessage.State(currentState())) - session.readyTimeoutJob?.cancel() - session.readyTimeoutJob = launch { - delay(READY_TIMEOUT_MS) - // Ready-without-launch only abandons an UNUSED - // identity: with a player active (a reused-identity - // offer mid-playback), ending it would rip the - // token overlay out from under the live stream. - if (activeSession === session && session.remoteLaunchReady) { - session.remoteLaunchReady = false - if (activePlayer == null) { - identityManager.end() - refreshAdvertisement() - session.send( - PrairieCastMessage.Error( - PrairieCastError( - code = "launch_timeout", - message = "No content was launched, so the temporary profile was restored.", - ), - ), - ) - session.goodbyeAndClose() - } + // Replaces the old ready-without-launch timeout: with + // nothing playing, the profile goes back after the + // idle limit. A player cancels this when it opens. + if (activePlayer == null) { + identityManager.activeIdentity?.let { + scheduleIdentityEnd(it.generationId, IDENTITY_IDLE_MS) + } + } + if (fullHandoff) { + session.preparingClearJob?.cancel() + session.preparingClearJob = scope?.launch { + delay(PREPARING_AFTER_READY_MS) + clearPreparing(session) } } } catch (e: CancellationException) { throw e } catch (t: Throwable) { + clearPreparing(session) if (session.remoteLaunchReady && activePlayer == null) { identityManager.end() session.remoteLaunchReady = false @@ -487,20 +650,45 @@ class TvPrairieCastReceiver( is PrairieCastMessage.HandoffCancel -> { session.handoffJob?.cancel() session.handoffJob = null - // Disarm the ready-without-launch watchdog: the offer it was - // guarding is dead, and with a player active (identity in use - // by live playback) letting it fire would end the session. - session.readyTimeoutJob?.cancel() - session.readyTimeoutJob = null - if (session.remoteLaunchReady && activePlayer == null) { - val generation = identityManager.activeIdentity?.generationId - if (generation != null) scheduleIdentityEnd(generation, 0L) - } session.remoteLaunchReady = false + session.ownIdentityProfileId = null + clearPreparing(session) + // The phone is still here, so a profile it lent stays for its + // next title, as on tvOS. Make sure the idle limit covers one + // a cancelled handoff installed without arming it. + identityManager.activeIdentity + ?.takeIf { + it.controllerDeviceId == session.controllerDeviceId && + isIdentityIdle() && + identityEndJob?.isActive != true + } + ?.let { scheduleIdentityEnd(it.generationId, IDENTITY_IDLE_MS) } } is PrairieCastMessage.Launch -> { if (!requireAuthorized(session)) return true - if (!session.remoteLaunchReady || identityManager.activeIdentity == null) { + // A Watch Party player is never silently replaced by a cast. + activePlayer?.adapter?.launchRefusal?.invoke()?.let { refusal -> + session.send( + PrairieCastMessage.Error( + PrairieCastError(code = "watch_party_active", message = refusal), + ), + ) + return true + } + // In a party lobby no party player is registered yet, but a + // solo launch would still take the shared player from the room. + if (inWatchParty()) { + session.send( + PrairieCastMessage.Error( + PrairieCastError(code = "watch_party_active", message = "Leave the Watch Party on the TV first."), + ), + ) + return true + } + // A sign-in, profile or borrowed-identity change after this + // makes the launch stale (see confirmLaunch). + val identity = settledLaunchIdentity(session)?.takeIf { it.onOwnIdentity || it.borrowed != null } + if (identity == null) { session.send( PrairieCastMessage.Error( PrairieCastError( @@ -511,11 +699,9 @@ class TvPrairieCastReceiver( ) return true } - if (!AndroidServerRegistry.serverIdsMatch( - message.launch.serverId, - identityManager.activeIdentity?.serverId, - ) - ) { + val onOwnIdentity = identity.onOwnIdentity + val borrowed = identity.borrowed + if (!AndroidServerRegistry.serverIdsMatch(message.launch.serverId, identity.serverId)) { session.send( PrairieCastMessage.Error( PrairieCastError( @@ -526,10 +712,39 @@ class TvPrairieCastReceiver( ) return true } - val generation = identityManager.activeIdentity?.generationId - pendingPlayerIdentityGeneration = generation - if (generation == null || !launchRequestChannel.trySend(message.launch).isSuccess) { - pendingPlayerIdentityGeneration = null + val generation = if (onOwnIdentity) null else borrowed?.generationId + val launchOwner = UUID.randomUUID().toString() + val admitted = AdmittedLaunch(launchOwner, generation, identity.revision) + val refusal = synchronized(this) { + when { + // A phone that lost the slot while this was in flight. + activeSession !== session -> PrairieCastError( + code = "unauthorized", + message = "Another phone is controlling this TV.", + ) + pendingLaunch != null -> PrairieCastError( + code = "launch_pending", + message = LAUNCH_PENDING_MESSAGE, + ) + // The identity changed since the checks above, or its + // hand-back has committed. + !isAdmittedIdentityCurrentLocked(admitted) -> PrairieCastError( + code = "handoff_required", + message = "Prepare the phone profile before playing.", + ) + else -> { + pendingLaunch = admitted + null + } + } + } + if (refusal != null) { + if (refusal.code == "handoff_required") session.remoteLaunchReady = false + session.send(PrairieCastMessage.Error(refusal)) + return true + } + if (!launchRequestChannel.trySend(Launch(message.launch, launchOwner)).isSuccess) { + clearPendingLaunch(launchOwner) session.send( PrairieCastMessage.Error( PrairieCastError( @@ -540,13 +755,27 @@ class TvPrairieCastReceiver( ) return true } + // A launch whose player never opens must not hold off every + // later handoff. + scope?.launch { + delay(PENDING_LAUNCH_TIMEOUT_MS) + clearPendingLaunch(launchOwner, dropQueued = true) + } session.remoteLaunchReady = false + session.ownIdentityProfileId = null + // The TV is opening the player now; the preparing view is done. + clearPreparing(session, refresh = false) _standbyState.value = null - session.readyTimeoutJob?.cancel() - session.readyTimeoutJob = null } is PrairieCastMessage.Control -> { if (!requireAuthorized(session)) return true + if (message.control.name !in PrairieCastControlCommand.knownNames) { + // A command from a newer (or retired, like set_hdr_enabled) + // remote. Ignored without an error, as tvOS does: the phone + // shows errors as banners, and nothing happened anyway. + Log.i(TAG, "PrairieCast ignoring unsupported command ${message.control.name}") + return true + } val player = activePlayer if (player == null) { session.send( @@ -576,6 +805,31 @@ class TvPrairieCastReceiver( return true } + /** + * Stops the registered player and waits for its server session to close. + * An approved handoff runs this just before it installs the phone's + * identity, including for a title the TV started under its own account: + * the player's stop rides on the identity it started under, so after the + * swap it is refused and the session lingers beside the phone's. Runs under + * the identity manager's lock, which also holds off the stopped player's + * own identity cleanup until the swap is done. + */ + private suspend fun stopPlayerBeforeIdentitySwap() { + val player = activePlayer ?: return + withContext(Dispatchers.Main.immediate) { + player.adapter.handle(PrairieCastControlCommand(name = PrairieCastControlCommand.Stop)) + } + val tornDown = withTimeoutOrNull(PLAYBACK_TEARDOWN_TIMEOUT_MS) { + // The exit queues the session stop before the route unregisters. + player.unregistered.await() + awaitPlaybackTeardown() + } != null + // The player or its session stop outlived the teardown limit, or + // something started playing meanwhile; swapping the identity now would + // strand that session. + check(tornDown && activePlayer == null) { "The TV is still playing. Try again." } + } + private suspend fun requireAuthorized(session: ControllerSession): Boolean { if (session.isAuthorized) return true session.send( @@ -586,20 +840,65 @@ class TvPrairieCastReceiver( return false } + /** + * Ends [generationId] after [delayMs]. With [keepIfLenderReturns] (the + * lending phone left), a phone back in the slot by then keeps it under + * the idle limit instead: a dropped link reconnects within seconds. + */ private fun scheduleIdentityEnd( generationId: String, delayMs: Long = IDENTITY_END_GRACE_MS, + keepIfLenderReturns: Boolean = false, ) { identityEndJob?.cancel() val owner = scope ?: return identityEndJob = owner.launch { delay(delayMs) - if (identityManager.activeIdentity?.generationId != generationId) return@launch - identityManager.end() - pendingPlayerIdentityGeneration = null - refreshAdvertisement() + if (keepIfLenderReturns && isLenderConnected(generationId) && isIdentityIdle()) { + scheduleIdentityEnd(generationId, IDENTITY_IDLE_MS) + return@launch + } + // Commit under the lock launch admission uses: a launch admitted + // first keeps the identity (its player schedules the next end); + // once committed, a later launch is refused. + val commit = synchronized(this@TvPrairieCastReceiver) { + if (isIdentityIdle()) { + endingGenerationId = generationId + true + } else { + false + } + } + if (!commit) return@launch + // Cancellable only while waiting: a new offer may still keep the + // identity, but once the logout starts it runs to the end, and + // that offer's prepare() waits for it and takes the full path. + val ended = try { + withContext(NonCancellable) { + identityManager.end(generationId).also { done -> + if (done) { + // Only this generation's stale launch: a newer one + // admitted meanwhile keeps its protection. + synchronized(this@TvPrairieCastReceiver) { + if (pendingLaunch?.generation == generationId) pendingLaunch = null + } + refreshAdvertisement() + } + } + } + } finally { + synchronized(this@TvPrairieCastReceiver) { + if (endingGenerationId == generationId) endingGenerationId = null + } + } + if (!ended) return@launch + // A newer offer cancelled this mid-logout and now owns the + // session's authorization; judging it here could drop a phone + // that is still handing off. + if (!isActive) return@launch val session = activeSession if (session != null) { + session.remoteLaunchReady = false session.isAuthorized = AndroidServerRegistry.serverIdsMatch( session.controllerServerId, serverRegistry.activeServerId.value, @@ -614,13 +913,163 @@ class TvPrairieCastReceiver( } } + /** No title is playing on, or on its way to, a phone's identity. */ + private fun isIdentityIdle(): Boolean = activePlayer == null && pendingLaunch == null + + /** + * Whether launch [id] may still open its player: it is the pending launch, + * or the launch whose player registered last, and the identity it was + * admitted on is unchanged. Navigation checks this before it opens the + * player and before it creates the player screen; registration checks it + * again. A stale launch gives up its place, so the next launch is not + * refused while it waits out its expiry. + */ + @Synchronized + fun confirmLaunch(id: String): Boolean = confirmLaunchLocked(id) + + /** Caller holds the receiver lock. */ + private fun confirmLaunchLocked(id: String): Boolean { + pendingLaunch?.takeIf { it.id == id }?.let { pending -> + if (isAdmittedIdentityCurrentLocked(pending)) return true + pendingLaunch = null + return false + } + registeredLaunch?.takeIf { it.id == id }?.let { registered -> + if (isAdmittedIdentityCurrentLocked(registered)) return true + registeredLaunch = null + } + return false + } + + /** + * Reads the identity [session]'s launch would play on. The barrier holds + * identity changes off while this runs, so the checks agree with the + * revision read alongside them: a change already under way is waited out, + * never half-observed. Null if changes kept landing meanwhile. + */ + private suspend fun settledLaunchIdentity(session: ControllerSession): LaunchIdentity? { + repeat(SETTLE_ATTEMPTS) { + val observed = identityTransitions.generation.value + identityTransitions.withCurrentGeneration(observed) { + val ownProfileId = session.ownIdentityProfileId + // Launch-ready on the TV's own identity: still signed in as + // that profile with no phone's identity installed since. + val onOwnIdentity = ownProfileId != null && + identityManager.activeIdentity == null && + !tokenManager.hasTemporaryScope() && + tokenManager.getProfileId() == ownProfileId + // activeIdentity is unpublished as soon as an end starts, so a + // launch never lands on an identity being handed back. + val borrowed = identityManager.activeIdentity?.takeIf { session.remoteLaunchReady } + LaunchIdentity( + revision = synchronized(this@TvPrairieCastReceiver) { identityRevision }, + onOwnIdentity = onOwnIdentity, + borrowed = borrowed, + serverId = if (onOwnIdentity) serverRegistry.activeServerId.value else borrowed?.serverId, + ) + }?.let { return it } + } + return null + } + + /** + * Caller holds the receiver lock. No sign-in, profile, server or borrowed + * identity change since [launch] was admitted, and a borrowed identity is + * not being ended. + */ + private fun isAdmittedIdentityCurrentLocked(launch: AdmittedLaunch): Boolean { + if (identityRevision != launch.identityRevision) return false + val generation = launch.generation ?: return true + return identityManager.activeIdentity?.generationId == generation && endingGenerationId != generation + } + + /** + * Clears the pending launch only if [owner] still holds it. With + * [dropQueued], also drops its request if navigation has not taken it yet, + * so an expired launch never opens later under another identity. Admission + * holds every other launch off while one is pending, so a request still + * queued is this one. + */ + private fun clearPendingLaunch(owner: String, dropQueued: Boolean = false) { + synchronized(this) { + if (pendingLaunch?.id == owner) { + pendingLaunch = null + if (dropQueued) launchRequestChannel.tryReceive() + } + } + } + + /** Whether the phone that lent [generationId] holds the controller slot. */ + private fun isLenderConnected(generationId: String): Boolean { + val identity = identityManager.activeIdentity ?: return false + val session = activeSession ?: return false + return identity.generationId == generationId && + session.isAuthorized && + session.controllerDeviceId == identity.controllerDeviceId + } + + /** + * The `handoff_ready` for an offer the TV can play on its own identity + * (see [RemotePlaybackOwnIdentityPolicy]), or null when the offer needs + * the full handoff. + */ + private suspend fun ownIdentityReady( + session: ControllerSession, + offer: PrairieCastHandoffOffer, + ): PrairieCastHandoffReady? { + val ownServerId = serverRegistry.activeServerId.value + val ownScope = tokenManager.snapshotCurrentScope() + val accepts = RemotePlaybackOwnIdentityPolicy.accepts( + authorizedAtHello = session.isAuthorized && + AndroidServerRegistry.serverIdsMatch(session.controllerServerId, ownServerId), + holdsTemporaryIdentity = identityManager.activeIdentity != null || tokenManager.hasTemporaryScope(), + signedIn = !tokenManager.getAccessToken().isNullOrBlank(), + ownServerId = ownScope?.serverId?.takeIf { AndroidServerRegistry.serverIdsMatch(it, ownServerId) }, + ownProfileId = ownScope?.profileId, + offerServerId = offer.serverId, + offerProfileId = offer.profileId, + ) + if (!accepts) return null + return PrairieCastHandoffReady( + requestId = offer.requestId, + serverId = offer.serverId, + profileId = offer.profileId, + // Required on the wire, though neither phone reads it. The TV's + // own sign-in has no session deadline to report, so this uses + // the same one-day fallback as a handoff whose server omits one. + sessionExpiresAt = Instant.ofEpochMilli(System.currentTimeMillis() + OWN_IDENTITY_SESSION_MS).toString(), + reused = true, + ) + } + + private fun showPreparing(session: ControllerSession, title: String?) { + session.preparingClearJob?.cancel() + session.preparingClearJob = null + session.preparing = StandbyState.Preparing( + title = title?.trim()?.take(MAX_PREPARING_TITLE_LENGTH)?.ifBlank { null }, + ) + refreshStandbyState() + } + + private fun clearPreparing(session: ControllerSession, refresh: Boolean = true) { + session.preparingClearJob?.cancel() + session.preparingClearJob = null + if (session.preparing == null) return + session.preparing = null + if (refresh) refreshStandbyState() + } + private fun refreshStandbyState() { val session = activeSession - _standbyState.value = if (session != null && session.isAuthorized && activePlayer == null) { + val preparing = session?.preparing + // A cross-server phone is not authorized until its handoff finishes, + // but the TV still shows what it is preparing for that phone. + _standbyState.value = if (session != null && activePlayer == null && (session.isAuthorized || preparing != null)) { StandbyState( controllerName = session.controllerDeviceName, serverName = identityManager.activeIdentity?.serverName ?: serverRegistry.activeEntry.value?.displayName, + preparing = preparing, ) } else { null @@ -651,8 +1100,8 @@ class TvPrairieCastReceiver( if (read < 0) return val payloads = buffer.append(chunk.copyOf(read)) for (payload in payloads) { - val text = payload.decodeToString() - val message = json.decodeFromString(PrairieCastMessage.serializer(), text) + // A kind added after this build is skipped, not fatal. + val message = PrairieCastMessage.decodeOrNull(json, payload.decodeToString()) ?: continue if (!onMessage(message)) return } } @@ -733,11 +1182,18 @@ class TvPrairieCastReceiver( @Volatile var remoteLaunchReady: Boolean = false + /** Launch-ready on the TV's own identity for this profile (no handoff ran). */ + @Volatile + var ownIdentityProfileId: String? = null + + @Volatile + var preparing: StandbyState.Preparing? = null + @Volatile var missedHeartbeats: Int = 0 var job: Job? = null var handoffJob: Job? = null - var readyTimeoutJob: Job? = null + var preparingClearJob: Job? = null suspend fun send(message: PrairieCastMessage) { val payload = json.encodeToString(PrairieCastMessage.serializer(), message).encodeToByteArray() @@ -770,17 +1226,35 @@ class TvPrairieCastReceiver( fun close() { handoffJob?.cancel() - readyTimeoutJob?.cancel() + preparingClearJob?.cancel() tls.close() runCatching { socket.close() } job?.cancel() } } + /** A phone's launch on its way to the player, with the [id] it was admitted under. */ + data class Launch(val request: PrairieCastLaunchRequest, val id: String) + + /** + * A launch as admitted: the phone's borrowed identity [generation] (null on + * the TV's own identity) and the receiver's [identityRevision] then. + */ + private data class AdmittedLaunch(val id: String, val generation: String?, val identityRevision: Long) + + /** What a launch would play on: the TV's own profile or a [borrowed] one, as of [revision]. */ + private data class LaunchIdentity( + val revision: Long, + val onOwnIdentity: Boolean, + val borrowed: RemotePlaybackIdentityManager.ActiveIdentity?, + val serverId: String?, + ) + private data class ActivePlayer( val adapter: TvPrairieCastPlayerAdapter, val stateProvider: () -> PrairieCastPlaybackState, val identityGeneration: String?, + val unregistered: CompletableDeferred = CompletableDeferred(), ) private companion object { @@ -792,7 +1266,15 @@ class TvPrairieCastReceiver( const val MAX_MISSED_HEARTBEATS = 3 const val AUTH_GRACE_MS = 5_000L const val GOODBYE_TIMEOUT_MS = 1_000L - const val READY_TIMEOUT_MS = 60_000L const val IDENTITY_END_GRACE_MS = 2_000L + const val PLAYBACK_TEARDOWN_TIMEOUT_MS = 15_000L + const val PENDING_LAUNCH_TIMEOUT_MS = 30_000L + // Identity changes rarely land back to back; past this, refuse the launch. + const val SETTLE_ATTEMPTS = 3 + const val LAUNCH_PENDING_MESSAGE = "The TV is still starting the last title. Try again in a moment." + const val IDENTITY_IDLE_MS = 10 * 60_000L + const val PREPARING_AFTER_READY_MS = 20_000L + const val OWN_IDENTITY_SESSION_MS = 24 * 60 * 60 * 1_000L + const val MAX_PREPARING_TITLE_LENGTH = 120 } } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/di/AndroidTvModule.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/di/AndroidTvModule.kt index 66332f276a..4432111f00 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/di/AndroidTvModule.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/di/AndroidTvModule.kt @@ -64,6 +64,10 @@ import org.prairieserver.prairie.tv.ui.screens.player.TvVideoPlaybackStarter import org.prairieserver.prairie.tv.ui.screens.profiles.TvProfileSelectionViewModel import org.prairieserver.prairie.tv.ui.screens.search.TvSearchViewModel import org.prairieserver.prairie.tv.data.preferences.TvLibraryScopeStore +import org.prairieserver.prairie.common.profiles.ProfileLaunchBehavior +import org.prairieserver.prairie.common.profiles.ProfileLaunchPreferences +import org.prairieserver.prairie.tv.profiles.TvActiveProfileReset +import org.prairieserver.prairie.common.profiles.ProfileAwayTracker import org.prairieserver.prairie.tv.watchnext.WatchNextRepository import org.prairieserver.prairie.tv.watchnext.WatchNextSeeder import android.net.Uri @@ -159,6 +163,20 @@ val androidTvModule = module { // androidModule for why every identity reporter resolves this instead of // deriving its own answer. single { PrairieClientBuildIdentity(BuildConfig.BUILD_NUMBER, BuildConfig.RELEASE_CHANNEL) } + // Settings → Experimental → Watch Party: on by default only in debug builds. + single { + org.prairieserver.prairie.common.watchparty.WatchPartyExperiment( + prefs = androidContext().getSharedPreferences( + org.prairieserver.prairie.common.watchparty.WatchPartyExperiment.PREFS_NAME, + android.content.Context.MODE_PRIVATE, + ), + defaultEnabled = BuildConfig.DEBUG, + onDisabled = { get().depart() }, + ) + } + single { + get() + } single { AndroidDeviceMetadataProvider( androidContext(), @@ -278,6 +296,12 @@ val androidTvModule = module { org.prairieserver.prairie.tv.data.preferences.TvLibraryScopeStore(androidContext(), get()) } + // Library Browse sort + filters, per server·profile·library (the phone + // store under its own key prefix). + single { + org.prairieserver.prairie.common.settings.BrowsePrefsStore(androidContext(), get(), keyPrefix = "androidtv") + } + // tvOS-parity Home row visibility/order, local to this TV and partitioned // by active server + profile. single { TvHomeSectionPreferences(androidContext(), get()) } @@ -308,6 +332,27 @@ val androidTvModule = module { single { WatchNextRepository(androidContext()) } single { WatchNextSeeder(androidContext(), get()) } + // Profile Selection (tvOS General → PROFILE AT LAUNCH): the device-wide + // choice, the tracker MainTvActivity drives from onStop/onStart, and the + // reset every path uses to drop the active profile. + single { ProfileLaunchPreferences(androidContext(), ProfileLaunchPreferences.TV_PREFS_NAME) } + single { + val watchNextSeeder = get() + ProfileAwayTracker( + preferences = get(), + activePlayerHolder = get(), + // Every Time keeps the launcher's Watch Next row empty whenever + // Prairie is not on screen (prairie-apple Top Shelf parity). + onLeave = { state -> + if (state.behavior == ProfileLaunchBehavior.EveryTime) watchNextSeeder.clear() + }, + // The timed choices keep the Watch Next row until the interval + // runs out, then a check removes it. + onAwayIntervalStarted = watchNextSeeder::scheduleProfileExpiryCheck, + ) + } + single { TvActiveProfileReset(get(), get(), get(), get(), get(), get(), get(), get()) } + // Deep-link bridge between MainTvActivity (producer) and TvAppNavigation // (consumer). The Activity writes incoming Silo app-scheme URIs here on // cold-launch (read from launching intent in onCreate) and warm-launch @@ -321,9 +366,15 @@ val androidTvModule = module { // TLS-PSK socket lifecycle; the receiver is the transport-agnostic state // machine. A later step wires the UI to PairingReceiver.status. single { + val identityApi = get() org.prairieserver.prairie.common.pairing.PairingReceiver( authPort = org.prairieserver.prairie.common.pairing.RegistryPairingAuthPort(get(), get(), get(), get()), - deviceLogin = org.prairieserver.prairie.common.pairing.DeviceLoginRepositoryPort(get()), + // Its own device-login state machine: the sign-in screen runs its + // own device code while it advertises, and the two must not share + // one repository's state. + deviceLogin = org.prairieserver.prairie.common.pairing.DeviceLoginRepositoryPort( + org.prairieserver.prairie.repository.DeviceLoginRepository(get()), + ), identityProvider = { org.prairieserver.prairie.common.pairing.PairingDeviceIdentity( name = tvDeviceName(), @@ -331,28 +382,16 @@ val androidTvModule = module { .stable(androidContext()), ) }, - // Always `setup`: advertising only runs while the server-setup - // screen is showing, and Apple is authoritative for the wire — - // silo-apple's TVPairingAdvertiser hardcodes st=setup and its - // companion card FILTERS to state == .setup, so a registry-based - // `login` (always true after a sign-out, since the registry keeps - // entries) made the TV invisible to phones exactly when the user - // needed set-up-with-phone again. - receiverStateProvider = { org.prairieserver.prairie.pairing.PairingReceiverState.Setup }, + identityProbe = { url -> identityApi.probeIdentity(url) }, ) } + // The advertised state comes from the screen that starts it: server setup + // advertises st=setup, the sign-in screen st=login with its server's + // identity (srv) so only phones holding that server offer it. single { org.prairieserver.prairie.common.pairing.TvPairingAdvertiser( context = androidContext(), receiver = get(), - // Always `setup`: advertising only runs while the server-setup - // screen is showing, and Apple is authoritative for the wire — - // silo-apple's TVPairingAdvertiser hardcodes st=setup and its - // companion card FILTERS to state == .setup, so a registry-based - // `login` (always true after a sign-out, since the registry keeps - // entries) made the TV invisible to phones exactly when the user - // needed set-up-with-phone again. - receiverStateProvider = { org.prairieserver.prairie.pairing.PairingReceiverState.Setup }, ) } single { PrairieCastNsdAdvertiser(androidContext()) } @@ -364,23 +403,49 @@ val androidTvModule = module { ) } single { + val playbackLifecycle = get() TvPrairieCastReceiver( advertiser = get(), serverRegistry = get(), + tokenManager = get(), + identityTransitions = get(), identityManager = get(), deviceNameProvider = ::tvDeviceName, deviceIdProvider = { org.prairieserver.prairie.common.pairing.PairingDeviceId.stable(androidContext()) }, + awaitPlaybackTeardown = playbackLifecycle::awaitPendingStop, + inWatchParty = { + get().roomSnapshot.value != null + }, ) } // Auth ViewModels - viewModel { TvServerSetupViewModel(get(), get()) } + viewModel { + val tokens = get() + TvServerSetupViewModel(get(), get(), hasSession = { !tokens.getAccessToken().isNullOrBlank() }) + } viewModel { org.prairieserver.prairie.tv.ui.screens.auth.TvSetupViewModel(get()) } viewModel { org.prairieserver.prairie.tv.ui.screens.auth.TvSignupViewModel(get()) } - viewModel { TvLoginViewModel(get(), get(), get()) } - viewModel { TvProfileSelectionViewModel(get(), get()) } + viewModel { params -> + TvLoginViewModel( + authRepository = get(), + tokenManager = get(), + // Per-screen device-login state; see the PairingReceiver above. + deviceLogin = org.prairieserver.prairie.repository.DeviceLoginRepository(get()), + serverRegistry = get(), + serverIdentities = get(), + sessionExpired = params.getOrNull() ?: false, + externalSignIn = get(), + // st=login rollout gate (prairie-apple PairingProtocol.advertisesSignInTVs): + // debug builds only until both phone apps that accept login TVs ship. + advertisesSignIn = BuildConfig.DEBUG, + ) + } + viewModel { + TvProfileSelectionViewModel(get(), get(), profileVerificationRecovery = getOrNull(), launchPreferences = get()) + } viewModel { org.prairieserver.prairie.tv.ui.screens.profiles.TvCreateProfileViewModel(get()) } viewModel { params -> org.prairieserver.prairie.tv.ui.screens.profiles.TvEditProfileViewModel( @@ -388,10 +453,10 @@ val androidTvModule = module { profileId = params.get(), ) } - viewModel { TvServerListViewModel(get(), get(), get(), get()) } + viewModel { TvServerListViewModel(get(), get(), get(), get(), get(), get()) } viewModel { params -> - org.prairieserver.prairie.viewmodel.RequestDetailViewModel(get(), params.get(), params.get()) + org.prairieserver.prairie.viewmodel.RequestDetailViewModel(get(), params.get(), params.get(), featureStore = get()) } viewModel { params -> val args = params.get>() @@ -403,13 +468,19 @@ val androidTvModule = module { } // Content ViewModels - viewModel { HomeViewModel(get(), get(), get(), get(), getOrNull(), get(), get()) } + viewModel { HomeViewModel(get(), get(), get(), get(), getOrNull(), get(), get(), catalogLanguage = get()) } viewModel { org.prairieserver.prairie.tv.ui.screens.home.TvUpcomingViewModel(get()) } - viewModel { RecommendationsViewModel(get()) } - viewModel { RequestsViewModel(get()) } + viewModel { RecommendationsViewModel(get(), catalogLanguage = get()) } + // The Requests page reads the approval queue itself, so the hub doesn't + // count it; it loads its view models when it composes. + viewModel { params -> + RequestsViewModel(get(), get(), countsPendingApprovals = false, loadOnInit = params.getOrNull() ?: true) + } viewModel { RequestSearchViewModel(get()) } - viewModel { MyRequestsViewModel(get()) } - viewModel { org.prairieserver.prairie.tv.ui.screens.requests.TvRequestsViewModel(get()) } + viewModel { params -> MyRequestsViewModel(get(), loadOnInit = params.getOrNull() ?: true) } + viewModel { params -> + org.prairieserver.prairie.viewmodel.RequestApprovalsViewModel(get(), loadOnInit = params.getOrNull() ?: true) + } // Platform supplies "today" and the IANA timezone; the shared ViewModel's // week math stays deterministic in commonTest (no Clock.System default). viewModel { @@ -430,7 +501,12 @@ val androidTvModule = module { }, ) } - viewModel { TvLibrariesViewModel(get(), get(), get()) } + viewModel { + TvLibrariesViewModel( + get(), get(), get(), + reachability = get().state, + ) + } viewModel { params -> TvLibraryDetailViewModel( sectionRepository = get(), @@ -438,6 +514,9 @@ val androidTvModule = module { libraryId = params.get(), libraryTitle = params.get(), libraryType = params.get(), + mediaScope = params.values.getOrNull(3) as? String, + browsePrefs = get(), + catalogLanguage = get(), ) } viewModel { params -> @@ -447,6 +526,8 @@ val androidTvModule = module { libraryId = params.get(), collectionId = params.get(), title = params.get(), + mediaScope = params.values.getOrNull(3) as? String, + collectionSource = params.values.getOrNull(4) as? String ?: "library_collection", ) } viewModel { TvSearchViewModel(get(), get(), get()) } @@ -468,21 +549,43 @@ val androidTvModule = module { capabilityDetector = get(), ) } - // Watch Together entry (create/join orchestration) — backs the entry + - // join-code dialogs on the detail screen. - viewModel { - org.prairieserver.prairie.tv.ui.screens.watchtogether.TvWatchTogetherViewModel(get()) - } + // Watch Party: the shared hub, lobby (keyed per room; roomId is the + // positional parameter), and picker ViewModels. viewModel { - org.prairieserver.prairie.tv.ui.screens.watchtogether.TvSuggestToRoomViewModel(get()) + val serverRegistry = get() + org.prairieserver.prairie.viewmodel.WatchPartyHubViewModel( + repository = get(), + roomSession = get(), + availability = get(), + recents = get(), + isCurrentServer = { url -> + org.prairieserver.prairie.common.watchparty.watchPartyServerMatches( + linkServerUrl = url, + activeServerUrl = serverRegistry.activeEntry.value?.url, + ) + }, + ) } - // Watch Together lobby — keyed per roomId (koinViewModel key="wt-lobby-$roomId"); - // roomId is read from the positional parameter. viewModel { params -> - org.prairieserver.prairie.tv.ui.screens.watchtogether.TvWatchTogetherLobbyViewModel( + org.prairieserver.prairie.viewmodel.WatchPartyLobbyViewModel( roomId = params.get(), repository = get(), roomSession = get(), + availability = get(), + ) + } + viewModel { + val catalog = get() + org.prairieserver.prairie.viewmodel.WatchPartyPickerViewModel( + repository = get(), + availability = get(), + search = { query -> + when (val result = catalog.browse(query = query, limit = 30)) { + is ApiResult.Success -> ApiResult.Success(result.data.items) + is ApiResult.Error -> result + is ApiResult.NetworkError -> result + } + }, ) } viewModel { params -> @@ -504,6 +607,8 @@ val androidTvModule = module { serverReachabilityMonitor = get(), qualityLadderClient = get(), launchArgs = params.get(), + shuffleFeatureStore = get(), + sectionRepository = get(), ) } @@ -544,7 +649,10 @@ val androidTvModule = module { profileSettings = get(), tvLibraryScopeStore = getOrNull(), seekIntervalStore = get(), + episodeSpoilerStore = get(), audiobookSettingsStore = get(), + profileLaunchPreferences = get(), + watchNextSeeder = get(), ) } viewModel { TvDiagnosticsViewModel(get()) } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/home/HomeRealtimeForegroundStarter.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/home/HomeRealtimeForegroundStarter.kt index c66b0a7801..b3d66f19cc 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/home/HomeRealtimeForegroundStarter.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/home/HomeRealtimeForegroundStarter.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import org.prairieserver.prairie.repository.ForegroundAccessCheck import org.prairieserver.prairie.repository.HomeRealtimeCoordinator import org.prairieserver.prairie.repository.ProfileRepository @@ -18,14 +19,18 @@ import org.prairieserver.prairie.repository.ProfileRepository * (ProfileRepository.profileSwitches) * reconnects so the coordinator's profile filter tracks the new session. * REST stays the source of truth; a dead socket just means pull behavior. + * Returning from the background runs [accessCheck], because a socket that was + * closed while stopped cannot report an access change made in that time. */ class HomeRealtimeForegroundStarter( private val coordinator: HomeRealtimeCoordinator, private val profileRepository: ProfileRepository, + private val accessCheck: ForegroundAccessCheck? = null, ) : DefaultLifecycleObserver { private var realtimeScope: CoroutineScope? = null private var foregroundScope: CoroutineScope? = null + private var stoppedSinceStart = false fun register() { ProcessLifecycleOwner.get().lifecycle.addObserver(this) @@ -34,9 +39,14 @@ class HomeRealtimeForegroundStarter( override fun onStart(owner: LifecycleOwner) { startRealtime() observeProfileSwitches() + if (stoppedSinceStart) { + stoppedSinceStart = false + foregroundScope?.launch { accessCheck?.afterReturnToForeground() } + } } override fun onStop(owner: LifecycleOwner) { + stoppedSinceStart = true stopRealtime() foregroundScope?.cancel() foregroundScope = null diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/profiles/TvActiveProfileReset.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/profiles/TvActiveProfileReset.kt new file mode 100644 index 0000000000..f6119da57c --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/profiles/TvActiveProfileReset.kt @@ -0,0 +1,46 @@ +package org.prairieserver.prairie.tv.profiles + +import org.prairieserver.prairie.common.settings.CardPresentationStore +import org.prairieserver.prairie.common.settings.EpisodeSpoilerStore +import org.prairieserver.prairie.common.settings.LibraryPlaybackPrefsStore +import org.prairieserver.prairie.common.settings.OverlayPrefsStore +import org.prairieserver.prairie.common.settings.SeekIntervalStore +import org.prairieserver.prairie.common.settings.TitleArtStore +import org.prairieserver.prairie.repository.ProfileRepository +import org.prairieserver.prairie.tv.watchnext.WatchNextSeeder + +/** + * Drops the active profile and everything cached for it, so the next profile + * starts clean. Shared by Switch Profile and every Profile Selection path + * (launch, return to Prairie, server switch) so they all leave the same state. + */ +class TvActiveProfileReset( + private val profileRepository: ProfileRepository, + private val libraryPlaybackPrefsStore: LibraryPlaybackPrefsStore, + private val overlayPrefsStore: OverlayPrefsStore, + private val cardPresentationStore: CardPresentationStore, + private val seekIntervalStore: SeekIntervalStore, + private val titleArtStore: TitleArtStore, + private val episodeSpoilerStore: EpisodeSpoilerStore, + private val watchNextSeeder: WatchNextSeeder, +) { + suspend fun clearActiveProfile() { + // The profile id and its PIN proof go together, so a protected + // profile asks for its PIN again. + profileRepository.clearProfile() + // Library/overlay prefs are per-profile — drop the caches + // so the next profile's prefs don't ghost-render the + // previous user's rows. Parity with the Settings + // switch-profile path. + libraryPlaybackPrefsStore.clear() + overlayPrefsStore.clear() + cardPresentationStore.clear() + seekIntervalStore.clear() + titleArtStore.clear() + episodeSpoilerStore.clear() + // Clear the previous profile's Watch Next rows before + // landing on the picker; the new profile will re-seed + // when it is selected. + watchNextSeeder.clear() + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvAnchoredSelectorMenu.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvAnchoredSelectorMenu.kt index 98c3bf322a..856f090fa0 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvAnchoredSelectorMenu.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvAnchoredSelectorMenu.kt @@ -27,9 +27,9 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.rounded.Check import androidx.compose.material3.DropdownMenu import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -73,6 +73,7 @@ import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import org.prairieserver.prairie.tv.ui.theme.PrairieOnSurface +import org.prairieserver.prairie.tv.ui.screens.player.TvPlayerType // --------------------------------------------------------------------------- // Anchored selector popover — Compose-for-TV port of the silo-apple tvOS @@ -307,9 +308,9 @@ internal fun TvAnchoredSelectorMenu( // The Material3 DropdownMenu is kept only as the anchored popup host // (positioning under the trigger, focus capture, dismiss-on-Back); its // own surface is made transparent and the content draws the same - // Skyline glass panel, dim uppercase header, inverted-capsule rows and - // hint footer as the top-bar cascade / For You selector, so every - // dropdown in the app reads as one component. + // opaque Skyline panel as the top-bar cascade / For You selector, with + // an eyebrow header and two-line rows, so every dropdown in the app + // reads as one component. No hint footer: TV viewers know the remote. DropdownMenu( expanded = expanded, onDismissRequest = { @@ -347,9 +348,9 @@ internal fun TvAnchoredSelectorMenu( // it the handler below is never called and the d-pad falls // straight through to Compose's own focus search. modifier = Modifier - .widthIn(min = CascadeLibraryColumnWidth, max = TvCascadeSelectorMaxPanelWidth) - .tvSkylinePanelChrome() - .padding(CascadePanelPadding) + .widthIn(min = SelectorMenuMinWidth, max = SelectorMenuMaxWidth) + .tvSkylinePanelChrome(corner = 20.dp) + .padding(start = 10.dp, end = 10.dp, top = 14.dp, bottom = 10.dp) .focusGroup() .onPreviewKeyEvent { event -> if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false @@ -370,10 +371,15 @@ internal fun TvAnchoredSelectorMenu( true }, ) { - CascadePanelHeader(label.uppercase()) - // The rows scroll inside a capped list while the header and - // footer stay pinned — a long subtitle list would otherwise - // grow the panel past the bottom of the screen. + Text( + text = label.uppercase(), + style = TvPlayerType.Eyebrow, + maxLines = 1, + modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 6.dp), + ) + // The rows scroll inside a capped list while the header stays + // pinned — a long subtitle list would otherwise grow the panel + // past the bottom of the screen. Box { Column( modifier = Modifier @@ -433,7 +439,6 @@ internal fun TvAnchoredSelectorMenu( modifier = Modifier.align(Alignment.BottomCenter), ) } - CascadePanelFooter(caption = "Press selects · Back closes") } } } @@ -520,15 +525,17 @@ private fun ConnectedSelectorTrigger( } private val SelectorMenuGap = 6.dp +private val SelectorMenuMinWidth = 300.dp +private val SelectorMenuMaxWidth = 400.dp -/** Six rows of options; anything longer scrolls within the panel. */ -private val SelectorMenuMaxListHeight = 230.dp +/** Five two-line rows of options; anything longer scrolls within the panel. */ +private val SelectorMenuMaxListHeight = 270.dp private val SelectorMenuScrollEdgeHeight = 26.dp /** * Fades the rows out toward whichever edge still has more of them, by masking * the list's own pixels (DstIn) rather than painting a colour over it — a - * painted fade can never quite match the panel's translucent gradient and + * painted fade can never quite match the panel's vertical gradient and * shows up as a band. */ private fun Modifier.selectorMenuEdgeFade(fadeTop: Boolean, fadeBottom: Boolean): Modifier { @@ -577,11 +584,11 @@ private fun SelectorMenuScrollEdge(visible: Boolean, top: Boolean, modifier: Mod } /** - * One option row, drawn with the cascade's row chrome (see `CascadeRowChrome`): - * a leading check slot (kept even when unselected so titles stay aligned, the - * way the cascade's leading icon does), the title in semibold and the detail - * dimmed, inverting to a solid [PrairieOnSurface] capsule on focus. Disabled rows - * are dimmed and skipped by focus. + * One option row: a leading check slot (kept even when unselected so titles + * stay aligned), the title, and the detail on its own line so it never + * competes with the option name. Focus inverts to a Paper capsule; the + * current choice keeps a soft fill. Disabled rows are dimmed and skipped by + * focus. */ @Composable private fun SelectorMenuRow( @@ -592,11 +599,10 @@ private fun SelectorMenuRow( modifier: Modifier = Modifier, ) { val visual = tvSelectorRowVisualState(focused, option.selected, option.enabled) - val shape = RoundedCornerShape(CascadeRowCornerRadius) + val shape = RoundedCornerShape(10.dp) Row( modifier = modifier .fillMaxWidth() - .padding(vertical = 2.dp) .clip(shape) .background(visual.container) .clickable( @@ -606,34 +612,37 @@ private fun SelectorMenuRow( onClick = onClick, ) .semantics { this.selected = option.selected } - .padding(horizontal = CascadeRowPaddingHorizontal, vertical = CascadeRowPaddingVertical), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(7.dp), + .padding(horizontal = 10.dp, vertical = 9.dp), + verticalAlignment = Alignment.Top, ) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = null, - tint = if (option.selected) visual.content else Color.Transparent, - modifier = Modifier.size(CascadeRowIconSize), - ) - Text( - text = option.title, - color = visual.content, - fontWeight = FontWeight.SemiBold, - fontSize = CascadeRowTextSize, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - if (option.detail.isNotBlank()) { + Box(modifier = Modifier.width(28.dp).padding(top = 1.dp)) { + if (option.selected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = visual.content, + modifier = Modifier.size(19.dp), + ) + } + } + Column(modifier = Modifier.weight(1f)) { Text( - text = option.detail, - color = visual.content.copy(alpha = 0.6f), - fontWeight = FontWeight.Medium, - fontSize = CascadeRowTextSize, + text = option.title, + style = TvPlayerType.Row, + color = visual.content, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), ) + if (option.detail.isNotBlank()) { + Text( + text = option.detail, + style = TvPlayerType.RowDetail, + color = visual.content.copy(alpha = 0.6f), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp), + ) + } } } } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvAuroraChrome.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvAuroraChrome.kt deleted file mode 100644 index aee7a6e7c0..0000000000 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvAuroraChrome.kt +++ /dev/null @@ -1,377 +0,0 @@ -package org.prairieserver.prairie.tv.ui.components - -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -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 -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.TextUnit -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.tv.material3.Icon -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text - -/** - * The Aurora first-run chrome — eyebrow, glass panel, cream primary button and - * ghost button — ported from prairie-apple's `AuroraStyle.swift`. A warm, - * cinematic skin (gold accent, plum-night) layered over the onboarding screens. - */ - -val AuroraInk = Color(0xFFF3EFE9) -val AuroraAccent = Color(0xFFF3D3A0) -private val AuroraJourneyAccent = Color(0xFFFF7900) -private val AuroraGlassTint = Color(0xFF171019) -private val AuroraCreamTop = Color(0xFFFDF7EC) -private val AuroraCreamBottom = Color(0xFFF1E3CD) -private val AuroraCreamInk = Color(0xFF20160A) -private val AuroraNightBottom = Color(0xFF070509) - -/** Compact Server → Account → Profile progress used across TV onboarding. */ -@Composable -fun AuroraJourneyProgress( - currentStep: Int, - modifier: Modifier = Modifier, -) { - val labels = listOf("SERVER", "ACCOUNT", "PROFILE") - Column(modifier = modifier) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - labels.forEachIndexed { index, _ -> - val step = index + 1 - val reached = step <= currentStep - Box( - modifier = Modifier - .size(22.dp) - .clip(RoundedCornerShape(11.dp)) - .background(if (reached) AuroraJourneyAccent else Color.White.copy(alpha = 0.10f)) - .border( - 1.dp, - if (reached) AuroraJourneyAccent else Color.White.copy(alpha = 0.20f), - RoundedCornerShape(11.dp), - ), - contentAlignment = Alignment.Center, - ) { - Text( - text = if (step < currentStep) "✓" else step.toString(), - color = if (reached) Color.Black else Color.White.copy(alpha = 0.48f), - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Bold, - ) - } - if (index < labels.lastIndex) { - Box( - modifier = Modifier - .weight(1f) - .padding(horizontal = 7.dp) - .height(1.dp) - .background( - if (step < currentStep) AuroraJourneyAccent - else Color.White.copy(alpha = 0.16f), - ), - ) - } - } - } - Spacer(Modifier.height(5.dp)) - Row(modifier = Modifier.fillMaxWidth()) { - labels.forEachIndexed { index, label -> - Text( - text = label, - color = if (index + 1 == currentStep) Color.White else Color.White.copy(alpha = 0.42f), - style = MaterialTheme.typography.labelSmall.copy( - fontFamily = FontFamily.Monospace, - fontWeight = if (index + 1 == currentStep) FontWeight.Bold else FontWeight.Medium, - letterSpacing = 1.2.sp, - ), - textAlign = when (index) { - 0 -> TextAlign.Start - labels.lastIndex -> TextAlign.End - else -> TextAlign.Center - }, - modifier = Modifier.weight(1f), - ) - } - } - } -} - -/** - * Gold-hairline + mono-caps step label, e.g. "STEP 01 — CONNECT". - * - * The hairline is mirrored on both sides so the label sits on the row's centre - * line. Every eyebrow in the auth flow is laid out in a `CenterHorizontally` - * column under a centered title; with a leading rule only, the row centres but - * the *text* does not, and the eyebrow visibly hangs right of the title beneath - * it. Keep the pair symmetric if you restyle this. - */ -@Composable -fun AuroraEyebrow(text: String, modifier: Modifier = Modifier) { - Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { - AuroraEyebrowRule(fadesTowardText = true) - Spacer(Modifier.width(16.dp)) - Text( - text = text.uppercase(), - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.SemiBold, - fontSize = 17.sp, - letterSpacing = 3.5.sp, - color = AuroraAccent, - ) - Spacer(Modifier.width(16.dp)) - AuroraEyebrowRule(fadesTowardText = false) - } -} - -/** - * One 46dp hairline of the eyebrow. [fadesTowardText] runs the gradient solid - * at the outer edge and transparent at the inner one, so a mirrored pair reads - * as a single rule interrupted by the label. - */ -@Composable -private fun AuroraEyebrowRule(fadesTowardText: Boolean) { - val stops = if (fadesTowardText) { - listOf(AuroraAccent, AuroraAccent.copy(alpha = 0f)) - } else { - listOf(AuroraAccent.copy(alpha = 0f), AuroraAccent) - } - Box( - modifier = Modifier - .width(46.dp) - .height(1.dp) - .background(Brush.horizontalGradient(stops)), - ) -} - -/** - * Liquid-glass panel chrome (translucent plum tint + gradient hairline + top - * sheen + soft drop shadow; optional gold halo). Compose has no backdrop blur, - * so the tint is kept translucent enough for the aurora to glow through. - */ -fun Modifier.auroraGlass(cornerRadius: Dp = 28.dp, emphasized: Boolean = false): Modifier { - val shape = RoundedCornerShape(cornerRadius) - return this - .shadow(elevation = 60.dp, shape = shape, clip = false) - .clip(shape) - .background(AuroraGlassTint.copy(alpha = 0.62f)) - .background( - Brush.verticalGradient( - listOf(Color.White.copy(alpha = 0.05f), Color.Transparent), - ), - ) - .border( - width = 1.dp, - brush = if (emphasized) { - Brush.verticalGradient( - listOf(AuroraAccent.copy(alpha = 0.6f), AuroraAccent.copy(alpha = 0.16f), Color.White.copy(alpha = 0.04f)), - ) - } else { - Brush.verticalGradient( - listOf(Color.White.copy(alpha = 0.34f), Color.White.copy(alpha = 0.06f), Color.White.copy(alpha = 0.02f)), - ) - }, - shape = shape, - ) -} - -fun Modifier.auroraPanel(cornerRadius: Dp = 20.dp): Modifier { - val shape = RoundedCornerShape(cornerRadius) - return this - .clip(shape) - .background(AuroraGlassTint.copy(alpha = 0.42f)) - .border( - width = 1.dp, - color = Color.White.copy(alpha = 0.22f), - shape = shape, - ) -} - -/** Warm cream pill with a gold focus glow — the Aurora primary action. */ -@Composable -fun AuroraPrimaryButton( - label: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, - icon: ImageVector? = null, - focusRequester: FocusRequester? = null, - focusHalo: Boolean = true, - filledAtRest: Boolean = true, - neutralFocusFill: Boolean = false, - enabled: Boolean = true, -) { - var isFocused by remember { mutableStateOf(false) } - val shape = RoundedCornerShape(14.dp) - val scale by animateFloatAsState( - if (isFocused && focusHalo) 1.05f else 1f, - label = "auroraPrimaryScale", - ) - val glow by animateDpAsState( - if (!focusHalo) 0.dp else if (isFocused) 26.dp else 14.dp, - label = "auroraPrimaryGlow", - ) - val enabledAlpha = if (enabled) 1f else 0.48f - val fillBrush = if (isFocused && neutralFocusFill) { - Brush.verticalGradient(listOf(Color.White, Color.White)) - } else if (isFocused || filledAtRest) { - Brush.verticalGradient(listOf(AuroraCreamTop, AuroraCreamBottom)) - } else { - Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.06f), Color.White.copy(alpha = 0.06f))) - } - val contentColor = if (isFocused && neutralFocusFill) { - Color.Black.copy(alpha = 0.88f) - } else if (isFocused || filledAtRest) { - AuroraCreamInk - } else { - AuroraInk.copy(alpha = 0.68f) - } - - Box( - modifier = modifier - .graphicsLayer { scaleX = scale; scaleY = scale } - .shadow( - elevation = glow, - shape = shape, - clip = false, - ambientColor = if (isFocused && focusHalo) AuroraAccent else Color.Black, - spotColor = if (isFocused && focusHalo) AuroraAccent else Color.Black, - ) - .clip(shape) - .background(fillBrush) - .border( - width = if (isFocused) 3.dp else 1.dp, - color = if (isFocused) Color.White else Color.White.copy(alpha = 0.14f), - shape = shape, - ) - .then(focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier) - .onFocusChanged { isFocused = it.isFocused } - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - enabled = enabled, - onClick = onClick, - ) - .padding(horizontal = 30.dp, vertical = 18.dp), - contentAlignment = Alignment.Center, - ) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { - if (icon != null) { - Icon( - imageVector = icon, - contentDescription = null, - tint = contentColor.copy(alpha = enabledAlpha), - modifier = Modifier.size(24.dp), - ) - } - Text( - text = label, - color = contentColor.copy(alpha = enabledAlpha), - fontWeight = FontWeight.SemiBold, - fontSize = 24.sp, - ) - } - } -} - -/** - * Numbered onboarding step — a gold-outlined disc with the step number beside a - * line of ink copy. Ported from prairie-apple's `AuroraStepRow`; used by the - * phone-first sign-in hero ("1 Scan with your phone's camera", …). - */ -@Composable -fun AuroraStepRow(number: Int, text: String, modifier: Modifier = Modifier) { - Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { - Box( - modifier = Modifier - .size(40.dp) - .clip(RoundedCornerShape(20.dp)) - .background(AuroraAccent.copy(alpha = 0.14f)) - .border(1.dp, AuroraAccent.copy(alpha = 0.55f), RoundedCornerShape(20.dp)), - contentAlignment = Alignment.Center, - ) { - Text( - text = number.toString(), - color = AuroraAccent, - fontWeight = FontWeight.SemiBold, - fontSize = 19.sp, - ) - } - Spacer(Modifier.width(20.dp)) - Text( - text = text, - color = AuroraInk.copy(alpha = 0.92f), - fontWeight = FontWeight.Medium, - fontSize = 21.sp, - ) - } -} - -/** Tertiary ghost button (e.g. "Use a password instead", "Change server"). */ -@Composable -fun AuroraGhostButton( - label: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, - fontSize: TextUnit = 22.sp, - horizontalPadding: Dp = 22.dp, - verticalPadding: Dp = 12.dp, -) { - var isFocused by remember { mutableStateOf(false) } - val shape = RoundedCornerShape(12.dp) - val fill by animateColorAsState(if (isFocused) AuroraInk else Color.White.copy(alpha = 0.06f), label = "auroraGhostFill") - val content by animateColorAsState( - if (isFocused) AuroraNightBottom else AuroraInk.copy(alpha = 0.62f), - label = "auroraGhostContent", - ) - - Box( - modifier = modifier - .clip(shape) - .background(fill) - .border( - width = if (isFocused) 2.dp else 1.dp, - color = if (isFocused) Color.White else Color.White.copy(alpha = 0.14f), - shape = shape, - ) - .onFocusChanged { isFocused = it.isFocused } - .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick) - .padding(horizontal = horizontalPadding, vertical = verticalPadding), - contentAlignment = Alignment.Center, - ) { - Text(text = label, color = content, fontWeight = FontWeight.Medium, fontSize = fontSize) - } -} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvCardActionsHelpers.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvCardActionsHelpers.kt index bfa452a88c..30720e595f 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvCardActionsHelpers.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvCardActionsHelpers.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import org.prairieserver.prairie.domain.MediaActionsCoordinator import org.prairieserver.prairie.model.catalog.BrowseItem +import org.prairieserver.prairie.model.catalog.withWatched import org.prairieserver.prairie.model.catalog.MediaItemUserState import org.prairieserver.prairie.network.ApiResult import kotlinx.coroutines.launch @@ -24,23 +25,23 @@ import org.koin.compose.koinInject @Composable fun rememberTvBrowseItemCardActions( item: BrowseItem, -): Pair { +): Triple { val coordinator: MediaActionsCoordinator = koinInject() val scope = rememberCoroutineScope() // Key on userState too (phone parity): when refreshed data re-emits with a // changed userState, the card must reflect new watched/favorite/watchlist // badges instead of keeping the stale snapshot. - var state by remember(item.contentId, item.userState) { - mutableStateOf(item.userState ?: MediaItemUserState()) + var state by remember(item.contentId, item.userState, item.positionSeconds) { + mutableStateOf(item) } - val actions = remember(item.contentId, coordinator, scope) { + val actions = remember(item.contentId, item.userState, item.positionSeconds, coordinator, scope) { TvMediaCardActions( onSetWatched = { watched -> val writeIntent = coordinator.beginWatched(item.contentId, watched) val previous = state - state = state.copy(played = watched) + state = state.withWatched(watched) scope.launch { if (coordinator.performPersonalWrite(writeIntent) !is ApiResult.Success) { if (coordinator.isCurrent(writeIntent)) state = previous @@ -59,10 +60,10 @@ fun rememberTvBrowseItemCardActions( } val membershipActions by coordinator.memberships.actions.collectAsState() - var displayed = state + var displayed = state.userState ?: MediaItemUserState() membershipActions.values.filter { it.intent.key.itemId == item.contentId && it.baseline != null && coordinator.memberships.current(it.intent) }.forEach { displayed = if (it.intent.key.kind == org.prairieserver.prairie.repository.port.MembershipPort.Kind.FAVORITE) displayed.copy(isFavorite = it.baseline!!.present) else displayed.copy(inWatchlist = it.baseline!!.present) } - return actions to displayed + return Triple(actions, displayed, state.positionSeconds) } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvCatalogGrid.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvCatalogGrid.kt index a0e458d295..3617b4ef28 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvCatalogGrid.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvCatalogGrid.kt @@ -1,5 +1,8 @@ package org.prairieserver.prairie.tv.ui.components +import org.prairieserver.prairie.common.cards.LocalEpisodeSpoilerPrefs +import org.prairieserver.prairie.model.settings.EpisodeSpoilers + import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.layout.Arrangement @@ -265,7 +268,9 @@ fun TvCatalogGrid( key = { _, item -> item.contentId }, contentType = { _, item -> item.type }, ) { index, item -> - val (actions, userState) = rememberTvBrowseItemCardActions(item) + val (actions, userState, positionSeconds) = rememberTvBrowseItemCardActions(item) + val spoilerPrefs = LocalEpisodeSpoilerPrefs.current + val hideArtwork = EpisodeSpoilers.hidesBrowseArtwork(item, spoilerPrefs, userState.played, positionSeconds = positionSeconds) val isRestoreTarget = restoreItemFocusRequester != null && index == resolvedRestoreItemIndex if (isRestoreTarget) { @@ -296,6 +301,7 @@ fun TvCatalogGrid( } } TvMediaCard( + hideArtwork = hideArtwork, title = item.title, posterUrl = item.posterUrl, posterThumbhash = item.posterThumbhash, diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvDialog.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvDialog.kt new file mode 100644 index 0000000000..727d0751a5 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvDialog.kt @@ -0,0 +1,238 @@ +package org.prairieserver.prairie.tv.ui.components + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.tv.material3.Text +import org.prairieserver.prairie.tv.ui.focus.TvContentInitialFocusMaxAttempts +import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved +import org.prairieserver.prairie.tv.ui.focus.tvModalFocusBoundary +import org.prairieserver.prairie.tv.ui.screens.player.TvPlayerChrome +import org.prairieserver.prairie.tv.ui.screens.player.TvPlayerType + +/** How a dialog button reads: the safe way out, the recommended action, or a destructive one. */ +enum class TvDialogActionStyle { Default, Primary, Destructive } + +data class TvDialogAction( + val label: String, + val onClick: () -> Unit, + val style: TvDialogActionStyle = TvDialogActionStyle.Default, +) + +/** The one TV dialog surface: an opaque card with a hairline over a deep scrim. */ +internal object TvDialogDefaults { + val Scrim = Color.Black.copy(alpha = 0.78f) + val Shape: Shape = RoundedCornerShape(22.dp) + val Width = 470.dp +} + +/** The card every TV dialog and content modal is drawn on. */ +internal fun Modifier.tvDialogSurface(shape: Shape = TvDialogDefaults.Shape): Modifier = this + .shadow(elevation = 30.dp, shape = shape, ambientColor = Color.Black, spotColor = Color.Black) + .clip(shape) + .background(TvPlayerChrome.Card) + .border(1.dp, TvPlayerChrome.PanelStroke, shape) + +/** + * The one TV confirmation and alert: a title, what happens, and a row of + * buttons in the order given. Focus starts on the first [TvDialogActionStyle.Default] + * action — the safe way out — so a stray Select never runs the destructive + * one; pass [initialFocusIndex] to choose another. + * + * Drawn in its own window. Callers already inside a Popup or an in-window + * overlay use [TvDialogCard] directly so they keep their own window. + */ +@Composable +fun TvDialog( + title: String, + onDismissRequest: () -> Unit, + actions: List, + modifier: Modifier = Modifier, + message: String? = null, + initialFocusIndex: Int = defaultTvDialogFocusIndex(actions), + width: Dp = TvDialogDefaults.Width, + content: (@Composable ColumnScope.() -> Unit)? = null, +) { + BackHandler(onBack = onDismissRequest) + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(TvDialogDefaults.Scrim), + contentAlignment = Alignment.Center, + ) { + TvDialogCard( + title = title, + actions = actions, + modifier = modifier, + message = message, + initialFocusIndex = initialFocusIndex, + width = width, + content = content, + ) + } + } +} + +/** The safe way out: the first Default-style action, else the first action. */ +fun defaultTvDialogFocusIndex(actions: List): Int = + actions.indexOfFirst { it.style == TvDialogActionStyle.Default }.coerceAtLeast(0) + +/** + * [TvDialog]'s card without a window: title, message, optional [content], + * and the buttons, with focus contained and placed on [initialFocusIndex]. + */ +@Composable +fun TvDialogCard( + title: String, + actions: List, + modifier: Modifier = Modifier, + message: String? = null, + initialFocusIndex: Int = defaultTvDialogFocusIndex(actions), + width: Dp = TvDialogDefaults.Width, + content: (@Composable ColumnScope.() -> Unit)? = null, +) { + val initialFocus = remember { FocusRequester() } + var focusedIndex by remember { mutableIntStateOf(-1) } + // A dialog window or focusable popup hands focus to its first control on + // its own, so ask for the intended button until that button holds it. + LaunchedEffect(initialFocusIndex) { + requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = initialFocus::requestFocus, + isFocused = { focusedIndex == initialFocusIndex }, + ) + } + + Column( + modifier = modifier + .width(width) + .tvDialogSurface() + .tvModalFocusBoundary() + .padding(start = 32.dp, end = 32.dp, top = 30.dp, bottom = 28.dp), + ) { + Text(text = title, style = TvPlayerType.ConfirmTitle) + if (!message.isNullOrBlank()) { + Text(text = message, style = TvPlayerType.Body, modifier = Modifier.padding(top = 8.dp)) + } + if (content != null) { + Column( + modifier = Modifier.padding(top = 16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + content = content, + ) + } + if (actions.isNotEmpty()) { + Row( + modifier = Modifier.padding(top = 24.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + actions.forEachIndexed { index, action -> + TvDialogButton( + label = action.label, + onClick = action.onClick, + style = action.style, + modifier = Modifier + .then(if (index == initialFocusIndex) Modifier.focusRequester(initialFocus) else Modifier) + .onFocusChanged { state -> + if (state.isFocused) { + focusedIndex = index + } else if (focusedIndex == index) { + focusedIndex = -1 + } + }, + ) + } + } + } + } +} + +/** + * A dialog button. At rest it is a quiet capsule (destructive: bright red on + * a red tint, legible on the dark card); focused, it fills Paper, or red for + * a destructive action, so the button you are on is never in doubt. + */ +@Composable +fun TvDialogButton( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + style: TvDialogActionStyle = TvDialogActionStyle.Default, + enabled: Boolean = true, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val destructive = style == TvDialogActionStyle.Destructive + val (fill, textColor) = when { + isFocused && destructive -> TvPlayerChrome.Stop to Color.White + isFocused -> TvPlayerChrome.Paper to TvPlayerChrome.Ink + destructive -> TvPlayerChrome.StopRest to TvPlayerChrome.StopText + else -> Color.White.copy(alpha = 0.08f) to TvPlayerChrome.Paper + } + val shape = RoundedCornerShape(22.dp) + Box( + modifier = modifier + .height(44.dp) + .then( + if (isFocused) { + Modifier.shadow(elevation = 12.dp, shape = shape, ambientColor = Color.Black, spotColor = Color.Black) + } else { + Modifier + }, + ) + .clip(shape) + .background(fill) + .clickable( + enabled = enabled, + interactionSource = interactionSource, + indication = null, + role = Role.Button, + onClick = onClick, + ) + .alpha(if (enabled) 1f else 0.4f) + .padding(horizontal = 26.dp), + contentAlignment = Alignment.Center, + ) { + Text(text = label, style = TvPlayerType.Button, color = textColor, maxLines = 1) + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvEpisodeCard.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvEpisodeCard.kt index 22e68c89a4..a195eeaf96 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvEpisodeCard.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvEpisodeCard.kt @@ -1,5 +1,7 @@ package org.prairieserver.prairie.tv.ui.components +import org.prairieserver.prairie.common.ui.components.SpoilerImage +import org.prairieserver.prairie.common.ui.components.DefaultArtworkKind import org.prairieserver.prairie.common.ui.components.ThumbhashImage import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource @@ -63,11 +65,15 @@ fun TvEpisodeCard( stillUrl: String?, stillThumbhash: String?, onClick: () -> Unit, + /** Spoiler protection: blur the still of an episode not yet started. */ + hideStill: Boolean = false, modifier: Modifier = Modifier, seriesTitle: String? = null, year: Int? = null, seasonNumber: Int? = null, episodeNumber: Int? = null, + /** Catalog item type; picks the mark on the default artwork. */ + mediaType: String? = null, progress: Float? = null, width: Dp = tvEpisodeCardWidth(), focusRequester: FocusRequester? = null, @@ -113,12 +119,14 @@ fun TvEpisodeCard( .aspectRatio(16f / 9f), ) { Box(modifier = Modifier.fillMaxSize()) { - ThumbhashImage( + SpoilerImage( url = stillUrl, thumbhash = stillThumbhash, + hidden = hideStill, contentDescription = title, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), + defaultArtwork = DefaultArtworkKind.forItemType(mediaType), ) Box( diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFactsRow.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFactsRow.kt new file mode 100644 index 0000000000..ffcc6b021d --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFactsRow.kt @@ -0,0 +1,145 @@ +package org.prairieserver.prairie.tv.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +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.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Icon +import androidx.tv.material3.Text +import org.prairieserver.prairie.common.ui.RatingEntry +import org.prairieserver.prairie.common.ui.WholeTokenRow +import org.prairieserver.prairie.model.catalog.DisplayRating +import org.prairieserver.prairie.tv.ui.theme.SuccessGreen + +/** + * Tokens for a hero facts row, mirroring tvOS `TVHeroFactToken`. + * + * - [TextToken] plain text (year / runtime / genre). [TextToken.truncates] + * lets a long value (an episode title) end in "…" instead of dropping. + * [TextToken.givesWayFirst] marks a source token (type, genre, episode + * number) that drops before any rating or format. + * - [ExternalRating] one external rating as its source's mark and score. + * - [Rating] a maturity/check token: green check icon + label. + * - [Chip] a playback-format value (4K / HDR / DOLBY VISION / ATMOS / CC). + * Detail renders these with the same quiet monospaced treatment as Home's + * format line rather than promoting every value to an outlined badge. + */ +sealed class TvHeroFactToken { + data class TextToken( + val value: String, + val truncates: Boolean = false, + val givesWayFirst: Boolean = false, + ) : TvHeroFactToken() + data class ExternalRating(val rating: DisplayRating) : TvHeroFactToken() + data class Rating(val value: String) : TvHeroFactToken() + data class Chip(val value: String) : TvHeroFactToken() +} + +/** + * One line of [tokens] with a "·" between them, used by the detail hero, the + * Home marquee and request detail. Past the line's width, whole tokens drop + * from the end. Text tokens marked [TvHeroFactToken.TextToken.givesWayFirst] + * go first; then, with [ratingsDropFirst], external ratings go, from the end of + * the list, before the other facts and formats. [leading] (a content-rating + * chip) sits before the first token with no divider. + * + * [style] should carry the text's color and font size. Ratings keep their mark + * at that size too: the ten-foot text floor rules out the smaller mark + * [RatingEntry] draws by default. + */ +@Composable +internal fun TvFactsRow( + tokens: List, + style: TextStyle, + modifier: Modifier = Modifier, + spacing: Dp = 7.dp, + dividerColor: Color = style.color, + ratingsDropFirst: Boolean = false, + leading: (@Composable () -> Unit)? = null, +) { + WholeTokenRow(spacing = spacing, modifier = modifier) { + leading?.invoke() + tokens.forEachIndexed { index, token -> + // The row shows a "·" only between two facts it keeps. + if (index > 0) { + Text( + text = "·", + style = style.merge(TextStyle(fontWeight = FontWeight.SemiBold, color = dividerColor)), + maxLines = 1, + modifier = Modifier.separator(), + ) + } + val tokenModifier = Modifier + .then( + when { + token is TvHeroFactToken.TextToken && token.givesWayFirst -> Modifier.dropFirst(rank = 2) + ratingsDropFirst && token is TvHeroFactToken.ExternalRating -> Modifier.dropFirst() + else -> Modifier + }, + ) + .then(if (token is TvHeroFactToken.TextToken && token.truncates) Modifier.shrinkable() else Modifier) + Box(modifier = tokenModifier, contentAlignment = Alignment.CenterStart) { + FactToken(token = token, style = style) + } + } + } +} + +@Composable +private fun FactToken(token: TvHeroFactToken, style: TextStyle) { + when (token) { + is TvHeroFactToken.TextToken -> Text( + text = token.value, + style = style, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + is TvHeroFactToken.ExternalRating -> RatingEntry( + rating = token.rating, + style = style, + markFontSize = style.fontSize, + ) + is TvHeroFactToken.Rating -> Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + ) { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = null, + tint = SuccessGreen.copy(alpha = 0.9f), + modifier = Modifier.height(12.dp), + ) + Text(text = token.value, style = style, maxLines = 1) + } + is TvHeroFactToken.Chip -> Text( + text = formatChipLabel(token.value), + style = style.merge( + TextStyle( + fontFamily = FontFamily.Monospace, + letterSpacing = 0.52.sp, + color = Color.White.copy(alpha = 0.55f), + ), + ), + maxLines = 1, + ) + } +} + +private fun formatChipLabel(value: String): String = when (value.uppercase()) { + "DOLBY VISION" -> "Dolby Vision" + "ATMOS" -> "Atmos" + else -> value +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarquee.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarquee.kt index 5bbb257bfc..21e0a9eb5f 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarquee.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarquee.kt @@ -1,7 +1,11 @@ package org.prairieserver.prairie.tv.ui.components import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -29,14 +33,20 @@ import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.TextStyle 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.tv.material3.LocalTextStyle import androidx.tv.material3.Text +import org.prairieserver.prairie.common.settings.titleLogoUrl +import org.prairieserver.prairie.common.ui.components.MachineTranslatedLabel import org.prairieserver.prairie.common.ui.components.ThumbhashImage +import org.prairieserver.prairie.model.catalog.hasMachineTranslatedOverview import org.prairieserver.prairie.tv.ui.theme.PrairieOnSurface import org.prairieserver.prairie.tv.ui.theme.PrairieSecondaryText +import org.prairieserver.prairie.common.cards.LocalEpisodeSpoilerPrefs /** * Passive Skyline billboard, anchored bottom-left, that previews whichever row @@ -53,11 +63,17 @@ import org.prairieserver.prairie.tv.ui.theme.PrairieSecondaryText fun TvFocusMarquee( content: TvMarqueeContent?, detailLine: String? = content?.detailLine, + /** The rested item's description translation; like [detailLine], it + * updates the block in place instead of replaying the crossfade. */ + translation: TvMarqueeTranslation? = null, modifier: Modifier = Modifier, startPadding: androidx.compose.ui.unit.Dp = 44.dp, topPadding: androidx.compose.ui.unit.Dp = 0.dp, bottomPadding: androidx.compose.ui.unit.Dp = 0.dp, animateTransition: Boolean = true, + /** An extra line under the block for pages that preview more than catalog + * items (the Requests page's status and stage track). */ + footer: (@Composable (TvMarqueeContent) -> Unit)? = null, ) { Box( modifier = modifier @@ -73,6 +89,7 @@ fun TvFocusMarquee( // animation — a cold entry paints the finished marquee block instead // of fading it up. Focus-driven swaps keep the crossfade. var hasDisplayedContent by remember { mutableStateOf(false) } + val spoilerPrefs = LocalEpisodeSpoilerPrefs.current val snapInitialContent = content != null && !hasDisplayedContent LaunchedEffect(content != null) { if (content != null) hasDisplayedContent = true @@ -95,15 +112,29 @@ fun TvFocusMarquee( contentAlignment = Alignment.BottomStart, ) { if (value != null) { + // A layer for the item still shown takes its current + // copy at once, so a synopsis protection now hides + // doesn't fade out. TvMarqueeBlock( - content = value, + content = if (content != null && value.id == content.id) { + content + } else { + value.underPrefs(spoilerPrefs) + }, detailLine = detailLine.takeIf { value.id == content?.id }, + translation = translation.takeIf { it?.contentId == value.contentId }, + footer = footer, ) } } } } else if (content != null) { - TvMarqueeBlock(content = content, detailLine = detailLine) + TvMarqueeBlock( + content = content, + detailLine = detailLine, + translation = translation.takeIf { it?.contentId == content.contentId }, + footer = footer, + ) } } } @@ -112,12 +143,16 @@ fun TvFocusMarquee( private fun TvMarqueeBlock( content: TvMarqueeContent, detailLine: String?, + translation: TvMarqueeTranslation? = null, + footer: (@Composable (TvMarqueeContent) -> Unit)? = null, ) { // tvOS parity (TVFocusMarquee): when the text-fallback title wraps to two // lines the synopsis drops to one, keeping the bottom-anchored block's // height bounded so it never climbs into the top-menu-bar zone. var titleLineCount by remember(content.id) { mutableStateOf(1) } - var logoLoaded by remember(content.logoUrl) { mutableStateOf(false) } + // "Show title art" off: the marquee always names the title in text. + val logoUrl = titleLogoUrl(content.logoUrl) + var logoLoaded by remember(logoUrl) { mutableStateOf(false) } val logoAlpha by animateFloatAsState( targetValue = if (logoLoaded) 1f else 0f, animationSpec = tween(TvMarqueeCrossfadeMs, easing = TvMarqueeEasing), @@ -133,7 +168,7 @@ private fun TvMarqueeBlock( // Keep the semantic text title visible until transparent logo artwork // has actually decoded. A bad/slow URL therefore never creates a blank // title slot; successful artwork fades over the fixed-height fallback. - if (!content.logoUrl.isNullOrBlank()) { + if (!logoUrl.isNullOrBlank()) { Box( modifier = Modifier .height(MarqueeLogoMaxHeight) @@ -154,7 +189,7 @@ private fun TvMarqueeBlock( ) } ThumbhashImage( - url = content.logoUrl, + url = logoUrl, thumbhash = null, contentDescription = content.title, contentScale = ContentScale.Fit, @@ -184,21 +219,42 @@ private fun TvMarqueeBlock( ) } + val hidesSynopsis = content.isEpisode && LocalEpisodeSpoilerPrefs.current.hidesOverview( + org.prairieserver.prairie.model.settings.EpisodeSpoilers.isUnwatched(content.source), + ) + val synopsisText = (translation?.synopsis ?: content.synopsis) + ?.takeIf { it.isNotBlank() && !hidesSynopsis } + val translating = translation?.translating == true + val machineTranslated = !translating && synopsisText != null && + (translation?.machineTranslated ?: hasMachineTranslatedOverview(content.source.machineTranslatedFields)) + // Badge + meta line. - if (content.badges.isNotEmpty() || content.metaParts.isNotEmpty()) { + if (content.badges.isNotEmpty() || content.metaParts.isNotEmpty() || machineTranslated) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { content.badges.forEach { badge -> MarqueeBadge(badge) } if (content.metaParts.isNotEmpty()) { - Text( - text = content.metaParts.joinToString(" · "), + TvFactsRow( + tokens = content.metaParts, + style = LocalTextStyle.current.merge( + TextStyle( + color = PrairieSecondaryText, + fontSize = MarqueeMetaSize, + fontWeight = FontWeight.Medium, + ), + ), + spacing = MarqueeMetaGap, + ) + } + // The block has no spare row, so the machine-translation + // marker is the compact icon at the end of the meta line. + if (machineTranslated) { + MachineTranslatedLabel( color = PrairieSecondaryText, - fontSize = MarqueeMetaSize, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + compact = true, + iconSize = 14.dp, ) } } @@ -207,7 +263,19 @@ private fun TvMarqueeBlock( // Two synopsis lines (one when a text title wraps): the raised // typography floors made the old three-line block tall enough to // climb under the top menu bar, and the bar zone wins. - content.synopsis?.takeIf { it.isNotBlank() }?.let { synopsis -> + synopsisText?.let { synopsis -> + // A running translation pulses the original text until the + // translated description replaces it. + val pulse = if (translating) { + rememberInfiniteTransition(label = "marqueeTranslating").animateFloat( + initialValue = 0.35f, + targetValue = 0.7f, + animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse), + label = "marqueeTranslatingAlpha", + ).value + } else { + 1f + } Text( text = synopsis, color = PrairieSecondaryText, @@ -215,7 +283,9 @@ private fun TvMarqueeBlock( lineHeight = MarqueeSynopsisSize * 1.35f, maxLines = if (titleLineCount > 1) 1 else 2, overflow = TextOverflow.Ellipsis, - modifier = Modifier.widthIn(max = MarqueeSynopsisMaxWidth), + modifier = Modifier + .widthIn(max = MarqueeSynopsisMaxWidth) + .alpha(pulse), ) } @@ -264,6 +334,8 @@ private fun TvMarqueeBlock( badges.forEach { badge -> MarqueeBadge(badge.uppercase()) } } } + + footer?.invoke(content) } } @@ -302,6 +374,9 @@ private val MarqueeLogoMaxHeight = 84.dp private val MarqueeDetailLineHeight = 20.dp private val MarqueeTitleSize = 44.sp private val MarqueeMetaSize = 14.sp + +/** About a space at [MarqueeMetaSize], so `·` spacing matches the joined text. */ +private val MarqueeMetaGap = 4.dp private val MarqueeDetailSize = 14.sp private val MarqueeSynopsisSize = 16.sp private val MarqueeBadgeSize = 10.5.sp diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModel.kt index 8ef40cc3d8..272a72eb8a 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModel.kt @@ -3,13 +3,19 @@ package org.prairieserver.prairie.tv.ui.components import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import org.prairieserver.prairie.model.catalog.ExternalRatings import org.prairieserver.prairie.model.catalog.ItemDetail import org.prairieserver.prairie.model.catalog.OverlaySummary +import org.prairieserver.prairie.common.cards.LocalEpisodeSpoilerPrefs +import org.prairieserver.prairie.model.section.ResolvedSection import org.prairieserver.prairie.model.section.SectionItem +import org.prairieserver.prairie.model.settings.EpisodeSpoilerPrefs +import org.prairieserver.prairie.model.settings.EpisodeSpoilers import kotlinx.coroutines.delay import java.text.SimpleDateFormat import java.util.Locale @@ -30,8 +36,11 @@ data class TvMarqueeContent( val logoUrl: String?, /** Optional uppercase content-classification badge before the editorial metadata. */ val badges: List, - /** Dot-joined editorial metadata after the badge. */ - val metaParts: List, + /** + * Dot-separated editorial metadata after the badge. A title's one external + * rating (IMDb, else TMDB) sits after its length, before the genre. + */ + val metaParts: List, val synopsis: String?, /** A quieter detail line: cast / air-date when carried by the payload. */ val detailLine: String?, @@ -49,6 +58,11 @@ data class TvMarqueeContent( /** The source item, retained so the ambient tint extracts its palette from * the same (debounced) card the marquee + backdrop show. */ val source: SectionItem, + /** Spoiler protection hides this unwatched episode's stills, including an + * enrichment backdrop that is (or may be) one. */ + val hidesStills: Boolean = false, + /** Whether [backdropUrl], from the section item or enrichment, is an episode still; null when unknown. */ + val backdropIsEpisodeStill: Boolean? = null, ) { /** Backdrop art for the root hero. Like tvOS, the section artwork is shown * immediately; an episode can later upgrade to its higher-resolution series @@ -60,6 +74,38 @@ data class TvMarqueeContent( /** Stable per-item key for the §9 enrichment cache + stale-fetch guard. */ val contentId: String get() = source.contentId + /** + * This content under [prefs], so a layer still fading out of a crossfade + * follows a protection change at once: the same instance when nothing + * changes, without what [prefs] now hide, or rebuilt from its source item + * when [prefs] reveal something. + */ + fun underPrefs(prefs: EpisodeSpoilerPrefs): TvMarqueeContent { + if (!isEpisode) return this + val unwatched = EpisodeSpoilers.isUnwatched(source) + val hideStills = prefs.hidesImage(unwatched) + val hasOverview = !source.overview.isNullOrBlank() + val hideSynopsis = prefs.hidesOverview(unwatched) && hasOverview + val synopsisHidden = synopsis == null && hasOverview + if (hideStills == hidesStills && hideSynopsis == synopsisHidden) return this + // Revealing needs the source item's artwork and text back. + if ((hidesStills && !hideStills) || (synopsisHidden && !hideSynopsis)) { + return from(source, rowTitle = "", spoilers = prefs).copy(id = id) + } + // Hiding drops only what is now hidden, so explicit series artwork + // (such as an enrichment backdrop) stays. + val dropBackdrop = hideStills && backdropIsEpisodeStill != false + val dropPoster = hideStills && source.posterIsEpisodeStill != false + return copy( + synopsis = if (hideSynopsis) null else synopsis, + backdropUrl = if (dropBackdrop) null else backdropUrl, + backdropThumbhash = if (dropBackdrop) null else backdropThumbhash, + posterUrl = if (dropPoster) null else posterUrl, + posterThumbhash = if (dropPoster) null else posterThumbhash, + hidesStills = hideStills, + ) + } + /** * Fold a landed [TvMarqueeEnrichment] into this content (tvOS * `TVFocusMarqueeModel.backdropURL` + `detailLine`). The aired/cast line @@ -68,7 +114,8 @@ data class TvMarqueeContent( * preserved, so the swap reads as an in-place refresh, not a new block. */ fun withEnrichment(enrichment: TvMarqueeEnrichment): TvMarqueeContent { - val upgradeBackdrop = isEpisode && !enrichment.backdropUrl.isNullOrBlank() + val upgradeBackdrop = isEpisode && !enrichment.backdropUrl.isNullOrBlank() && + (!hidesStills || enrichment.backdropIsEpisodeStill == false) return copy( detailLine = enrichment.detailLine ?: detailLine, backdropUrl = if (upgradeBackdrop) enrichment.backdropUrl else backdropUrl, @@ -77,26 +124,35 @@ data class TvMarqueeContent( } else { backdropThumbhash }, + backdropIsEpisodeStill = if (upgradeBackdrop) enrichment.backdropIsEpisodeStill else backdropIsEpisodeStill, ) } companion object { + /** + * [spoilers] applies spoiler protection to an episode the profile has + * not started: no synopsis, and no still as the backdrop. The still's + * ThumbHash stays as a colour field until enrichment swaps in the + * series backdrop. + */ fun from( item: SectionItem, rowTitle: String, rowIdentity: String = rowTitle, + spoilers: EpisodeSpoilerPrefs = EpisodeSpoilerPrefs.NONE, ): TvMarqueeContent { val isEpisode = item.type.equals("episode", ignoreCase = true) + val unwatchedEpisode = isEpisode && EpisodeSpoilers.isUnwatched(item) - val meta = mutableListOf() + val meta = mutableListOf() if (isEpisode) { - episodeToken(item.seasonNumber, item.episodeNumber)?.let(meta::add) - if (item.title.isNotBlank()) meta.add(item.title) + episodeToken(item.seasonNumber, item.episodeNumber)?.let { meta += TvHeroFactToken.TextToken(it) } + if (item.title.isNotBlank()) meta += TvHeroFactToken.TextToken(item.title, truncates = true) } else { - if (item.year > 0) meta.add(item.year.toString()) - lengthText(item.runtime, item.durationSeconds)?.let(meta::add) - ratingToken(item.ratingImdb)?.let(meta::add) - item.genres.firstOrNull { it.isNotBlank() }?.let(meta::add) + if (item.year > 0) meta += TvHeroFactToken.TextToken(item.year.toString()) + lengthText(item.runtime, item.durationSeconds)?.let { meta += TvHeroFactToken.TextToken(it) } + ExternalRatings.primary(item.ratingImdb, item.ratingTmdb)?.let { meta += TvHeroFactToken.ExternalRating(it) } + item.genres.firstOrNull { it.isNotBlank() }?.let { meta += TvHeroFactToken.TextToken(it) } } val badges = item.contentRating @@ -105,6 +161,8 @@ data class TvMarqueeContent( ?.let(::listOf) .orEmpty() + val hidesBackdrop = spoilers.hidesImage(unwatchedEpisode, item.backdropIsEpisodeStill) + val hidesPoster = spoilers.hidesImage(unwatchedEpisode, item.posterIsEpisodeStill) val sectionBackdropUrl = item.backdropUrl?.takeIf { it.isNotBlank() } val sectionPosterUrl = item.posterUrl?.takeIf { it.isNotBlank() } return TvMarqueeContent( @@ -113,22 +171,30 @@ data class TvMarqueeContent( logoUrl = item.logoUrl?.takeIf { it.isNotBlank() }, badges = badges, metaParts = meta, - synopsis = item.overview?.takeIf { it.isNotBlank() }, + synopsis = item.overview + ?.takeIf { it.isNotBlank() && !spoilers.hidesOverview(unwatchedEpisode) }, detailLine = null, specLine = specLine(item.overlaySummary), // Match tvOS: a section backdrop (or poster fallback) is always // available for the first rested frame. Episode enrichment may - // replace it with series art later. - backdropUrl = sectionBackdropUrl ?: sectionPosterUrl, - backdropThumbhash = if (sectionBackdropUrl != null) { + // replace it with series art later. Hidden stills also drop + // their ThumbHash so the placeholder cannot reveal them. + backdropUrl = (if (hidesBackdrop) null else sectionBackdropUrl) ?: (if (hidesPoster) null else sectionPosterUrl), + backdropThumbhash = if (!hidesBackdrop && sectionBackdropUrl != null) { item.backdropThumbhash - } else { + } else if (!hidesPoster) { item.posterThumbhash - }, - posterUrl = sectionPosterUrl, - posterThumbhash = item.posterThumbhash, + } else null, + posterUrl = if (hidesPoster) null else sectionPosterUrl, + posterThumbhash = if (hidesPoster) null else item.posterThumbhash, isEpisode = isEpisode, source = item, + hidesStills = spoilers.hidesImage(unwatchedEpisode), + backdropIsEpisodeStill = if (!hidesBackdrop && sectionBackdropUrl != null) { + item.backdropIsEpisodeStill + } else { + item.posterIsEpisodeStill + }, ) } @@ -193,17 +259,6 @@ data class TvMarqueeContent( "$minutes min" } } - - private fun ratingToken(rating: Double?): String? = - validImdbRating(rating)?.let(::formatRating) - - private fun validImdbRating(rating: Double?): Double? = - rating?.takeIf { it.isFinite() && it > 0.0 && it <= 10.0 } - - private fun formatRating(rating: Double): String { - val rounded = (rating * 10).roundToInt() / 10.0 - return rounded.toString() - } } } @@ -224,6 +279,8 @@ data class TvMarqueeEnrichment( val detailLine: String?, val backdropUrl: String?, val backdropThumbhash: String?, + /** The detail's provenance for [backdropUrl]; null when unknown. */ + val backdropIsEpisodeStill: Boolean? = null, ) { companion object { fun from(detail: ItemDetail): TvMarqueeEnrichment { @@ -239,6 +296,7 @@ data class TvMarqueeEnrichment( detailLine = if (parts.isEmpty()) null else parts.joinToString(" · "), backdropUrl = detail.backdropUrl?.takeIf { it.isNotBlank() }, backdropThumbhash = detail.backdropThumbhash, + backdropIsEpisodeStill = detail.backdropIsEpisodeStill, ) } @@ -295,9 +353,33 @@ class TvFocusMarqueeState internal constructor() { private val enrichmentCache = mutableMapOf() private val enrichmentRequests = mutableSetOf() + /** The profile's spoiler protection, kept current by [rememberTvFocusMarqueeState]. */ + internal var spoilerPrefs: EpisodeSpoilerPrefs = EpisodeSpoilerPrefs.NONE + set(value) { + if (field == value) return + field = value + content = content?.withCurrentSpoilers() + candidate = candidate?.withCurrentSpoilers() + } + + private fun TvMarqueeContent.withCurrentSpoilers(): TvMarqueeContent = + TvMarqueeContent.from(source, rowTitle = "", spoilers = spoilerPrefs).copy(id = id) + + /** Refresh retained watch state without another focus event or a new detail fetch. */ + internal fun refreshSources(rows: List) { + fun TvMarqueeContent.refreshed(): TvMarqueeContent { + val row = rows.firstOrNull { "${it.id}#$contentId" == id } ?: return this + val item = row.items.firstOrNull { it.contentId == contentId } ?: return this + if (item == source) return this + return TvMarqueeContent.from(item, row.title, row.id, spoilerPrefs) + } + content = content?.refreshed() + candidate = candidate?.refreshed() + } + /** Report card focus. The displayed content swaps on the next composition turn. */ fun preview(item: SectionItem, rowTitle: String, rowIdentity: String = rowTitle) { - val next = TvMarqueeContent.from(item, rowTitle, rowIdentity) + val next = TvMarqueeContent.from(item, rowTitle, rowIdentity, spoilerPrefs) focusedMarqueeId = next.id // Focus is back on the already-displayed card: cancel any pending swap // so a brief A→B→A scrub within the debounce window can't commit a @@ -316,7 +398,7 @@ class TvFocusMarqueeState internal constructor() { */ fun seedInitialPreview(item: SectionItem, rowTitle: String, rowIdentity: String = rowTitle) { if (focusedMarqueeId != null) return - val next = TvMarqueeContent.from(item, rowTitle, rowIdentity) + val next = TvMarqueeContent.from(item, rowTitle, rowIdentity, spoilerPrefs) if (candidate?.id == next.id || content?.id == next.id) return candidate = next } @@ -325,7 +407,7 @@ class TvFocusMarqueeState internal constructor() { // Apply cached enrichment in the same snapshot as the base-content swap, // so revisiting an item presents one complete frame without a refetch. enrichment = value?.contentId?.let(enrichmentCache::get) - content = value + content = value?.withCurrentSpoilers() } /** True if detail for [contentId] is already cached (skip the fetch). */ @@ -369,12 +451,14 @@ fun rememberTvFocusMarqueeState( fetchDetail: (suspend (String) -> ItemDetail?)? = null, ): TvFocusMarqueeState { val state = remember { TvFocusMarqueeState() } - LaunchedEffect(state.candidate?.id) { + val spoilerPrefs = LocalEpisodeSpoilerPrefs.current + SideEffect { state.spoilerPrefs = spoilerPrefs } + LaunchedEffect(state.candidate) { val candidate = state.candidate ?: return@LaunchedEffect // Page-entry seed stays immediate so the hero never opens blank. Only // subsequent D-pad focus moves wait for the focus-rest interval. if (state.content != null) delay(TvMarqueeFocusRestMillis) - if (state.candidate?.id == candidate.id) state.commit(candidate) + if (state.candidate == candidate) state.commit(candidate) } // Populate the cache and enrich the active hero when identity still diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvMarqueeTranslation.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvMarqueeTranslation.kt new file mode 100644 index 0000000000..080678b626 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvMarqueeTranslation.kt @@ -0,0 +1,203 @@ +package org.prairieserver.prairie.tv.ui.components + +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import org.prairieserver.prairie.metadata.DescriptionTranslationController +import org.prairieserver.prairie.metadata.DescriptionTranslationPhase +import org.prairieserver.prairie.model.catalog.hasMachineTranslatedOverview +import org.prairieserver.prairie.model.feature.MetadataAiFeatureStore +import org.prairieserver.prairie.model.metadata.MetadataAiOnView +import org.prairieserver.prairie.model.section.ResolvedSection +import org.prairieserver.prairie.model.section.SectionItem +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.repository.MetadataAiRepository + +/** + * How the focus marquee shows the rested item's description translation: + * the landed text (if any), whether it is machine-translated, and whether a + * translation of this item is running. Applied only to the item it names, so + * one card's progress never shows on another. + */ +data class TvMarqueeTranslation( + val contentId: String, + val synopsis: String?, + val machineTranslated: Boolean, + val translating: Boolean, +) + +/** + * On-view description translation for the focus marquee, the Android TV + * counterpart of the web Featured hero: in `auto` mode a Featured-section item + * the viewer rests on translates once per (item, language), then its detail is re-read + * until the server reports the description available. The marquee is never + * focusable, so `button` mode leaves the action to the item's detail page. + */ +@Stable +internal class TvMarqueeTranslationState( + private val repository: MetadataAiRepository, + private val scope: CoroutineScope, +) { + private val controller = DescriptionTranslationController( + repository = repository, + delayMs = { delay(it) }, + ) + val phase = controller.phase + val runningContentId = controller.runningContentId + + private data class Landed(val overview: String?, val machineTranslated: Boolean, val pending: String?) + + /** Descriptions re-read while this feed was on screen, by content id. */ + private val landed = mutableStateMapOf() + private var translationJob: Job? = null + private var focusedContentId: String? = null + private var failedPreflightContentId: String? = null + val working = MutableStateFlow(false) + + fun onFocusChanged(contentId: String?) { + if (focusedContentId != contentId) failedPreflightContentId = null + focusedContentId = contentId + } + + fun close() { + translationJob?.cancel() + } + + fun pendingLanguage(item: SectionItem): String? = + if (item.contentId in landed) landed[item.contentId]?.pending + else item.pendingTranslationLanguage?.takeIf { it.isNotBlank() } + + fun translateOnView(item: SectionItem, onLanded: () -> Unit) { + if (translationJob?.isActive == true) return + val contentId = item.contentId + val hintedTarget = pendingLanguage(item) ?: return + if (failedPreflightContentId == contentId || !controller.shouldAutoFire(contentId, hintedTarget)) return + working.value = true + translationJob = scope.launch { + try { + // Retained cards can still name the previous metadata language. + // Read the current profile's description before queuing work. + val owner = repository.captureAuthority() + val latest = repository.refreshDetail(contentId, owner) + currentCoroutineContext().ensureActive() + if (latest !is ApiResult.Success || !repository.isCurrent(owner)) { + // Retry on refocus without repeatedly reading a failing endpoint + // while focus rests here. No translation claim has been consumed. + failedPreflightContentId = contentId + return@launch + } + fun publish(detail: org.prairieserver.prairie.model.catalog.ItemDetail) { + landed[contentId] = Landed( + overview = detail.overview?.takeIf { it.isNotBlank() }, + machineTranslated = hasMachineTranslatedOverview(detail.machineTranslatedFields), + pending = detail.pendingTranslationLanguage?.takeIf { it.isNotBlank() }, + ) + } + publish(latest.data) + val target = latest.data.pendingTranslationLanguage?.takeIf { it.isNotBlank() } + if (target == null) { + onLanded() + return@launch + } + if (!controller.claimAutoFire(contentId, target)) return@launch + controller.resetFailure() + controller.translate( + contentId = contentId, + targetLanguage = target, + refetchPendingLanguage = { currentOwner -> + when (val result = repository.refreshDetail(contentId, currentOwner)) { + is ApiResult.Success -> if (repository.isCurrent(currentOwner)) { + currentCoroutineContext().ensureActive() + publish(result.data) + landed[contentId]?.pending + } else target + else -> target + } + }, + onTranslated = { onLanded() }, + ) + } finally { + working.value = false + } + } + } + + fun presentation(item: SectionItem, phase: DescriptionTranslationPhase, runningId: String?): TvMarqueeTranslation { + val update = landed[item.contentId] + return TvMarqueeTranslation( + contentId = item.contentId, + synopsis = update?.overview ?: item.overview?.takeIf { it.isNotBlank() }, + machineTranslated = update?.machineTranslated ?: hasMachineTranslatedOverview(item.machineTranslatedFields), + translating = phase == DescriptionTranslationPhase.Translating && runningId == item.contentId, + ) + } +} + +/** + * Whether the marquee's item came from a Featured section (the section's + * `featured` flag). Only those translate on view, matching the web client, + * which translates the visible Featured slide only; cards in ordinary rows + * keep their text and marker but never start a job when focus rests on them. + * [TvMarqueeContent.id] is `"
#"`. + */ +internal fun isFeaturedMarqueeContent(content: TvMarqueeContent?, sections: List): Boolean = + content != null && sections.any { section -> + section.featured && content.id == "${section.id}#${content.contentId}" + } + +/** How long focus rests on a card before `auto` mode counts it as viewed. */ +private const val TvMarqueeTranslateDwellMs = 1_000L + +/** + * Drives [TvMarqueeTranslationState] for the item the marquee shows and + * returns what the marquee should render for it. + */ +@Composable +internal fun rememberTvMarqueeTranslation( + item: SectionItem?, + /** Start on-view translation for this item; false only displays it. */ + autoTranslate: Boolean, + /** Called once a translation lands, to re-read the rows that hold the card. */ + onTranslated: () -> Unit = {}, +): TvMarqueeTranslation? { + val latestOnTranslated by rememberUpdatedState(onTranslated) + val repository: MetadataAiRepository = koinInject() + val metadataAiStore: MetadataAiFeatureStore = koinInject() + val status by metadataAiStore.status.collectAsState() + val language: org.prairieserver.prairie.domain.settings.CatalogLanguageRevision = koinInject() + val languageRevision by language.revision.collectAsState() + val scope = rememberCoroutineScope() + val state = remember(repository, scope, languageRevision) { TvMarqueeTranslationState(repository, scope) } + DisposableEffect(state) { onDispose { state.close() } } + val phase by state.phase.collectAsState() + val runningId by state.runningContentId.collectAsState() + val pending = item?.let(state::pendingLanguage) + val working by state.working.collectAsState() + val busy = working || phase == DescriptionTranslationPhase.Translating + // Re-run when a running translation ends, so an item skipped meanwhile + // gets its turn while the viewer is still resting on it. + LaunchedEffect(state, item?.contentId, pending, status.onView, busy, autoTranslate) { + state.onFocusChanged(item?.contentId) + if (!autoTranslate || item == null || pending == null || status.onView != MetadataAiOnView.Auto) { + return@LaunchedEffect + } + delay(TvMarqueeTranslateDwellMs) + state.translateOnView(item) { latestOnTranslated() } + } + return item?.let { state.presentation(it, phase, runningId) } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvMediaCard.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvMediaCard.kt index d097c90901..13893d6c9f 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvMediaCard.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvMediaCard.kt @@ -1,5 +1,7 @@ package org.prairieserver.prairie.tv.ui.components +import org.prairieserver.prairie.common.ui.components.SpoilerImage +import org.prairieserver.prairie.common.ui.components.DefaultArtworkKind import org.prairieserver.prairie.common.ui.components.ThumbhashImage import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource @@ -69,6 +71,7 @@ import org.prairieserver.prairie.tv.ui.util.tvArtworkAspectRatioForMediaType @Composable fun TvMediaCard( title: String, + hideArtwork: Boolean = false, posterUrl: String?, posterThumbhash: String?, onClick: () -> Unit, @@ -141,12 +144,14 @@ fun TvMediaCard( }, ) { Box(modifier = Modifier.fillMaxSize()) { - ThumbhashImage( + SpoilerImage( + hidden = hideArtwork, url = posterUrl, thumbhash = posterThumbhash, contentDescription = title, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), + defaultArtwork = DefaultArtworkKind.forItemType(mediaType), ) // Card-overlay badge layer. Over the poster, under the diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvMediaRow.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvMediaRow.kt index 12114c668b..c978515f65 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvMediaRow.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvMediaRow.kt @@ -26,7 +26,9 @@ import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.unit.dp import androidx.compose.ui.ExperimentalComposeUiApi +import org.prairieserver.prairie.common.cards.LocalEpisodeSpoilerPrefs import org.prairieserver.prairie.model.section.SectionItem +import org.prairieserver.prairie.model.settings.EpisodeSpoilers import org.prairieserver.prairie.overlays.OverlayData import org.prairieserver.prairie.overlays.OverlayDataExtractor import org.prairieserver.prairie.tv.ui.focus.TvFocusLog @@ -51,6 +53,10 @@ private data class TvMediaRowItemModel( val shelfSubtitle: String?, val overlay: OverlayData, val contentType: String, + /** Spoiler protection: blur this card's still (an unstarted episode). */ + val hidesArtwork: Boolean, + /** The same for the poster layout, which shows the item's own poster. */ + val hidesPoster: Boolean, ) /** @@ -75,6 +81,12 @@ fun TvMediaRow( icon: ImageVector? = null, onSeeAllClick: (() -> Unit)? = null, showProgress: Boolean = false, + /** + * This profile's own unwatched state per content id, when [items] carry + * someone else's progress (a party picker's group progress). Spoiler + * protection uses it instead of the items' position. + */ + unwatchedById: Map? = null, style: TvRowStyle = TvRowStyle.Poster, cardLayout: TvRowCardLayout = TvRowCardLayout.Default, horizontalPadding: androidx.compose.ui.unit.Dp = Spacing.safeArea, @@ -135,13 +147,16 @@ fun TvMediaRow( } if (items.isEmpty()) return val rowState = rememberLazyListState() - val rowItems = remember(items, showProgress, style, cardLayout) { + val spoilerPrefs = LocalEpisodeSpoilerPrefs.current + val rowItems = remember(items, showProgress, style, cardLayout, spoilerPrefs, unwatchedById) { // Deduplicate before keying. A repeated contentId inside one row makes // the lazy list throw ("Key ... was already used"), which is fatal — // and a row has no reason to show the same title twice anyway. Feeds // can legitimately overlap, so this is a property of the row, not a // bug to fix upstream of it. items.distinctBy { it.contentId }.map { item -> + val unwatchedEpisode = item.type.equals("episode", ignoreCase = true) && + (unwatchedById?.get(item.contentId) ?: EpisodeSpoilers.isUnwatched(item)) TvMediaRowItemModel( item = item, progress = if (showProgress) item.progressFraction() else null, @@ -152,6 +167,9 @@ fun TvMediaRow( shelfSubtitle = item.shelfSubtitle(showProgress = showProgress), overlay = OverlayDataExtractor.fromSectionItem(item), contentType = "${cardLayout.name}:${style.name}:${item.type}", + // Protect the selected still while preserving series fallback art. + hidesArtwork = spoilerPrefs.hidesImage(unwatchedEpisode, EpisodeSpoilers.selectedImageIsStill(item)), + hidesPoster = spoilerPrefs.hidesImage(unwatchedEpisode, item.posterIsEpisodeStill), ) } } @@ -342,11 +360,13 @@ fun TvMediaRow( val itemLongClick = remember(item, longClickAction) { longClickAction(item) } when (cardLayout) { TvRowCardLayout.ReferenceShelf -> TvReferenceShelfCard( + hideArtwork = rowItem.hidesArtwork, title = rowItem.shelfTitle, imageUrl = rowItem.backdropUrl, imageThumbhash = rowItem.backdropThumbhash, subtitle = rowItem.shelfSubtitle, detail = if (showProgress) rowItem.remainingMinutes?.let { "${it}m left" } else null, + mediaType = item.type, progress = rowItem.progress, onClick = { onItemClick(item.contentId) }, focusRequester = itemFocusRequester, @@ -360,9 +380,11 @@ fun TvMediaRow( title = item.title, stillUrl = rowItem.backdropUrl, stillThumbhash = rowItem.backdropThumbhash, + hideStill = rowItem.hidesArtwork, seriesTitle = item.seriesTitle, seasonNumber = item.seasonNumber, episodeNumber = item.episodeNumber, + mediaType = item.type, progress = rowItem.progress, year = item.year.takeIf { it > 0 }, onClick = { onItemClick(item.contentId) }, @@ -374,6 +396,7 @@ fun TvMediaRow( onLongClick = itemLongClick, ) TvRowStyle.Poster -> TvMediaCard( + hideArtwork = rowItem.hidesPoster, title = item.title, posterUrl = item.posterUrl, posterThumbhash = item.posterThumbhash, @@ -426,11 +449,11 @@ private fun SectionItem.remainingMinutes(): Int? { /** Prefer wide artwork for 16:9 row cards, falling back to poster only if needed. */ private fun SectionItem.bestBackdropUrl(): String? { - return backdropUrl ?: posterUrl + return backdropUrl?.takeIf { it.isNotBlank() } ?: posterUrl } private fun SectionItem.bestBackdropThumbhash(): String? { - return backdropThumbhash ?: posterThumbhash + return if (!backdropUrl.isNullOrBlank()) backdropThumbhash else posterThumbhash } private fun SectionItem.shelfTitle(showProgress: Boolean): String { @@ -453,4 +476,3 @@ private fun SectionItem.shelfSubtitle(showProgress: Boolean): String? { else -> null } } - diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvOptionDialog.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvOptionDialog.kt index 50dd7b16ea..10b4f1b04c 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvOptionDialog.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvOptionDialog.kt @@ -1,15 +1,10 @@ package org.prairieserver.prairie.tv.ui.components -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn @@ -22,40 +17,34 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.rounded.Check import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import androidx.compose.ui.window.PopupPositionProvider -import androidx.tv.material3.Border import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.Glow import androidx.tv.material3.Icon -import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text -import org.prairieserver.prairie.tv.ui.theme.DarkBackground -import org.prairieserver.prairie.tv.ui.theme.FocusedContainer -import org.prairieserver.prairie.tv.ui.theme.FocusedContent +import org.prairieserver.prairie.tv.ui.screens.player.TvPlayerChrome +import org.prairieserver.prairie.tv.ui.screens.player.TvPlayerType data class TvDialogOption( val key: String, @@ -63,6 +52,11 @@ data class TvDialogOption( val subtitle: String? = null, val selected: Boolean = false, val enabled: Boolean = true, + /** + * Marks an action ("Mark as watched") rather than a choice. It takes the + * leading slot where a choice shows its check: a check means "selected". + */ + val icon: ImageVector? = null, val onClick: () -> Unit, ) @@ -72,9 +66,12 @@ fun TvOptionDialog( title: String, options: List, onDismiss: () -> Unit, + /** The option to focus first, when it is enabled; otherwise the selected or first enabled one. */ + initialFocusKey: String? = null, ) { val firstRowFocus = remember { FocusRequester() } - val focusedKey = options.firstOrNull { it.selected && it.enabled }?.key + val focusedKey = initialFocusKey?.takeIf { key -> options.any { it.key == key && it.enabled } } + ?: options.firstOrNull { it.selected && it.enabled }?.key ?: options.firstOrNull { it.enabled }?.key val focusedIndex = options.indexOfFirst { it.key == focusedKey } val listState: LazyListState = rememberLazyListState() @@ -107,37 +104,26 @@ fun TvOptionDialog( .padding(start = 36.dp, top = 60.dp, end = 36.dp, bottom = 42.dp), contentAlignment = Alignment.Center, ) { - val panelShape = RoundedCornerShape(14.dp) + // Opaque: a translucent panel let the page behind it (the + // synopsis, a Continue Watching card) read through the header. Column( modifier = Modifier - .width(320.dp) - .background( - color = DarkBackground.copy(alpha = 0.68f), - shape = panelShape, - ) - .border(0.6.dp, Color.White.copy(alpha = 0.20f), panelShape) - .padding(horizontal = 12.dp, vertical = 12.dp) + .width(380.dp) + .tvDialogSurface(RoundedCornerShape(20.dp)) + .padding(start = 10.dp, end = 10.dp, top = 14.dp, bottom = 10.dp) .then(rememberTvDialogInitialFocus(firstRowFocus)), - verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( text = title.uppercase(), - style = MaterialTheme.typography.labelMedium.copy( - fontSize = 14.sp, - lineHeight = 18.sp, - letterSpacing = 0.8.sp, - fontWeight = FontWeight.Bold, - ), - color = Color.White.copy(alpha = 0.58f), - modifier = Modifier.padding(horizontal = 6.dp), + style = TvPlayerType.Eyebrow, + modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 6.dp), ) LazyColumn( state = listState, modifier = Modifier .fillMaxWidth() - .heightIn(max = 240.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), + .heightIn(max = 300.dp), ) { items( options, @@ -149,6 +135,7 @@ fun TvOptionDialog( subtitle = option.subtitle, selected = option.selected, enabled = option.enabled, + icon = option.icon, onClick = option.onClick, modifier = if (option.key == focusedKey) { Modifier.focusRequester(firstRowFocus) @@ -182,13 +169,18 @@ private fun TvOptionDialogRow( subtitle: String?, selected: Boolean, enabled: Boolean, + icon: ImageVector?, onClick: () -> Unit, modifier: Modifier = Modifier, ) { val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() - val shape = RoundedCornerShape(12.dp) - val restContent = if (enabled) Color.White else Color.White.copy(alpha = 0.42f) + val shape = RoundedCornerShape(10.dp) + val content = when { + isFocused -> TvPlayerChrome.Ink + enabled -> TvPlayerChrome.Paper + else -> TvPlayerChrome.Faint + } Surface( onClick = onClick, @@ -196,93 +188,52 @@ private fun TvOptionDialogRow( interactionSource = interactionSource, shape = ClickableSurfaceDefaults.shape(shape = shape), colors = ClickableSurfaceDefaults.colors( - containerColor = if (selected) Color.White.copy(alpha = 0.13f) else Color.White.copy(alpha = 0.04f), - contentColor = restContent, - focusedContainerColor = FocusedContainer, - focusedContentColor = FocusedContent, - pressedContainerColor = FocusedContainer, - pressedContentColor = FocusedContent, - disabledContainerColor = Color.White.copy(alpha = 0.03f), - disabledContentColor = Color.White.copy(alpha = 0.38f), + // The current choice keeps a soft fill at rest; focus inverts to Paper. + containerColor = if (selected) TvPlayerChrome.Selected else Color.Transparent, + contentColor = content, + focusedContainerColor = TvPlayerChrome.Paper, + focusedContentColor = TvPlayerChrome.Ink, + pressedContainerColor = TvPlayerChrome.Paper, + pressedContentColor = TvPlayerChrome.Ink, + disabledContainerColor = Color.Transparent, + disabledContentColor = TvPlayerChrome.Faint, ), scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), - border = ClickableSurfaceDefaults.border( - border = Border( - border = BorderStroke( - width = if (selected) 1.dp else 0.dp, - color = Color.White.copy(alpha = if (selected) 0.22f else 0f), - ), - shape = shape, - ), - focusedBorder = Border( - border = BorderStroke(1.5.dp, DarkBackground.copy(alpha = 0.82f)), - shape = shape, - ), - ), - glow = ClickableSurfaceDefaults.glow( - focusedGlow = Glow( - elevationColor = Color.White.copy(alpha = 0.14f), - elevation = 10.dp, - ), - ), modifier = modifier .fillMaxWidth() - .heightIn(min = 40.dp) - .semantics { this.selected = selected } - .then( - if (isFocused) { - Modifier.border(1.5.dp, Color.White.copy(alpha = 0.98f), shape) - } else { - Modifier - }, - ), + .heightIn(min = 38.dp) + .semantics { this.selected = selected }, ) { Row( modifier = Modifier .fillMaxWidth() - .alpha(if (enabled) 1f else 0.72f) - .padding(horizontal = 12.dp, vertical = 7.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, + .padding(horizontal = 10.dp, vertical = 9.dp), + verticalAlignment = Alignment.Top, ) { - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium.copy( - fontSize = 15.sp, - lineHeight = 18.sp, - fontWeight = FontWeight.SemiBold, - ), - color = if (isFocused) FocusedContent else restContent, - ) + // Leading slot, kept even when empty so titles line up: a check + // for the current choice, an icon for an action. + Box(modifier = Modifier.width(28.dp), contentAlignment = Alignment.CenterStart) { + val leadingIcon = icon ?: if (selected) Icons.Rounded.Check else null + if (leadingIcon != null) { + Icon( + imageVector = leadingIcon, + contentDescription = null, + tint = content, + modifier = Modifier.size(19.dp), + ) + } + } + Column(modifier = Modifier.weight(1f)) { + Text(text = title, style = TvPlayerType.Row, color = content) subtitle?.takeIf { it.isNotBlank() }?.let { Text( text = it, - style = MaterialTheme.typography.bodySmall.copy( - fontSize = 14.sp, - lineHeight = 18.sp, - ), - color = if (isFocused) { - FocusedContent.copy(alpha = 0.70f) - } else { - Color.White.copy(alpha = if (enabled) 0.56f else 0.32f) - }, + style = TvPlayerType.RowDetail, + color = if (isFocused) TvPlayerChrome.InkMuted else TvPlayerChrome.Graphite, + modifier = Modifier.padding(top = 2.dp), ) } } - if (selected) { - Icon( - imageVector = Icons.Filled.CheckCircle, - contentDescription = null, - tint = if (isFocused) FocusedContent else Color.White.copy(alpha = 0.82f), - modifier = Modifier.size(18.dp), - ) - } else { - Spacer(modifier = Modifier.size(18.dp)) - } } } } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvPinEntryDialog.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvPinEntryDialog.kt index 6ad44ad31b..55de47aa4d 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvPinEntryDialog.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvPinEntryDialog.kt @@ -1,5 +1,17 @@ package org.prairieserver.prairie.tv.ui.components +import org.prairieserver.prairie.common.ui.marquee.MarqueeBackdrop +import org.prairieserver.prairie.common.ui.marquee.MarqueeColors +import org.prairieserver.prairie.common.ui.marquee.MarqueeProfileAvatar +import org.prairieserver.prairie.common.ui.marquee.MarqueeScrim +import org.prairieserver.prairie.common.ui.marquee.MarqueeScrimStyle +import org.prairieserver.prairie.model.profile.Profile +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeButton +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeButtonKind +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics + import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -12,12 +24,8 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Backspace import androidx.compose.runtime.Composable @@ -29,14 +37,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Popup @@ -48,10 +52,6 @@ import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text -import org.prairieserver.prairie.common.ui.components.ThumbhashImage -import org.prairieserver.prairie.common.ui.components.ProfileAvatarRef -import org.prairieserver.prairie.common.ui.components.profileAvatarDisplayText -import org.prairieserver.prairie.common.ui.components.rememberProfileAvatarImage import org.prairieserver.prairie.tv.ui.focus.TvControlState import org.prairieserver.prairie.tv.ui.focus.tvControlSemantics import org.prairieserver.prairie.tv.ui.theme.FocusedContainer @@ -59,17 +59,22 @@ import org.prairieserver.prairie.tv.ui.theme.FocusedContent import org.prairieserver.prairie.tv.ui.theme.PrairieOnSurface private const val PIN_LENGTH = 4 +private val PinKeySize = 50.dp -/** Compact, remote-first PIN keypad for protected TV profiles. */ +/** + * Full-screen, remote-first PIN prompt over the brand light: the person's + * avatar, four dots, and a round keypad (prairie-apple `PINEntryView` on tvOS). + * A wrong PIN turns the dots red and clears for another try. + */ @OptIn(ExperimentalTvMaterial3Api::class) @Composable fun TvPinEntryDialog( - profileName: String, - profileAvatar: ProfileAvatarRef = ProfileAvatarRef.None, + profile: Profile, onPinEntered: (String) -> Unit, onDismiss: () -> Unit, errorMessage: String? = null, isVerifying: Boolean = false, + prompt: String = "Enter your PIN", ) { var pin by remember { mutableStateOf("") } val fiveFocusRequester = remember { FocusRequester() } @@ -91,26 +96,14 @@ fun TvPinEntryDialog( ), ) { Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.72f)), + modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { - val panelShape = RoundedCornerShape(17.dp) + MarqueeBackdrop(mirrored = true) + MarqueeScrim(MarqueeScrimStyle.Ambient) + Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.35f))) Column( modifier = Modifier - .width(310.dp) - .shadow( - elevation = 18.dp, - shape = panelShape, - clip = false, - ambientColor = Color.Black.copy(alpha = 0.45f), - spotColor = Color.Black.copy(alpha = 0.45f), - ) - .clip(panelShape) - .background(Color(0xFF15171C)) - .border(0.5.dp, Color.White.copy(alpha = 0.16f), panelShape) - .padding(horizontal = 28.dp, vertical = 22.dp) // Retry-until-focused initial grab targeting the "5" key // (issue #64's fix, now shared). Exits as soon as ANYTHING // in the dialog holds focus, so a user who reaches Cancel @@ -118,32 +111,26 @@ fun TvPinEntryDialog( .then(rememberTvDialogInitialFocus(fiveFocusRequester)), horizontalAlignment = Alignment.CenterHorizontally, ) { - ProfilePinAvatar(profileName = profileName, profileAvatar = profileAvatar) - Spacer(modifier = Modifier.height(5.dp)) + MarqueeProfileAvatar(profile, size = 80.dp, baseSize = 110.dp, showsBadges = false) Text( - text = "Enter PIN for $profileName", - style = MaterialTheme.typography.titleMedium.copy( - fontSize = 14.sp, - lineHeight = 17.sp, - fontWeight = FontWeight.SemiBold, - ), - color = MaterialTheme.colorScheme.onBackground, - textAlign = TextAlign.Center, - maxLines = 2, + text = profile.name, + fontSize = 19.sp, + fontWeight = FontWeight.Bold, + color = MarqueeColors.Ink, + maxLines = 1, + modifier = Modifier + .padding(top = 11.dp) + .semantics { contentDescription = "Enter PIN for ${profile.name}" }, ) - Spacer(modifier = Modifier.height(14.dp)) - PinDots(pinLength = pin.length, error = latestError != null) - - if (latestError != null || isVerifying) { - Spacer(modifier = Modifier.height(7.dp)) - Text( - text = latestError ?: "Verifying...", - style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp, lineHeight = 18.sp), - color = if (latestError != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, - ) + PinDots(pinLength = pin.length, error = latestError != null && pin.isEmpty(), modifier = Modifier.padding(top = 17.dp)) + Box(Modifier.padding(top = 9.dp).height(17.dp), contentAlignment = Alignment.Center) { + when { + latestError != null -> Text(latestError, fontSize = 14.sp, color = MarqueeColors.Error) + isVerifying -> Text("Checking…", fontSize = 14.sp, color = MarqueeColors.InkSecondary) + else -> Text(prompt, fontSize = 14.sp, color = MarqueeColors.InkSecondary) + } } - - Spacer(modifier = Modifier.height(14.dp)) + Spacer(modifier = Modifier.height(15.dp)) PinKeypad( fiveFocusRequester = fiveFocusRequester, enabled = !isVerifying, @@ -156,83 +143,32 @@ fun TvPinEntryDialog( }, onBackspacePressed = { if (pin.isNotEmpty() && !isVerifying) pin = pin.dropLast(1) }, ) - Spacer(modifier = Modifier.height(10.dp)) - Surface( + // Not disabled while the PIN is checked: on TV a disabled + // control loses focus. The view model drops a late answer. + TvMarqueeButton( + text = "Cancel", onClick = onDismiss, - modifier = Modifier.heightIn(min = 48.dp), - shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(50)), - colors = ClickableSurfaceDefaults.colors( - containerColor = Color.Transparent, - contentColor = PrairieOnSurface, - focusedContainerColor = FocusedContainer, - focusedContentColor = FocusedContent, - pressedContainerColor = FocusedContainer, - pressedContentColor = FocusedContent, - ), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.045f), - border = ClickableSurfaceDefaults.border( - focusedBorder = Border( - border = BorderStroke(1.dp, Color.White.copy(alpha = 0.90f)), - shape = RoundedCornerShape(50), - ), - ), - ) { - Text( - text = "Cancel", - modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp), - style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp, lineHeight = 18.sp), - ) - } + kind = TvMarqueeButtonKind.Plain, + compact = true, + modifier = Modifier.padding(top = 15.dp), + ) } } } } @Composable -private fun ProfilePinAvatar(profileName: String, profileAvatar: ProfileAvatarRef) { - val avatarImage = rememberProfileAvatarImage(profileAvatar) - Box( - modifier = Modifier - .size(42.dp) - .clip(CircleShape) - .background(Color.White.copy(alpha = 0.10f)), - contentAlignment = Alignment.Center, - ) { - if (avatarImage != null) { - ThumbhashImage( - url = avatarImage.url, - thumbhash = null, - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - cacheKey = avatarImage.cacheKey, - onError = avatarImage.onLoadFailed, - ) - } else { - Text( - text = profileAvatarDisplayText(profileAvatar, profileName), - style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp, lineHeight = 21.sp), - color = MaterialTheme.colorScheme.onBackground, - ) - } - } -} - -@Composable -private fun PinDots(pinLength: Int, error: Boolean) { - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { +private fun PinDots(pinLength: Int, error: Boolean, modifier: Modifier = Modifier) { + val color = if (error) MarqueeColors.Error else MarqueeColors.Ink + Row(modifier, horizontalArrangement = Arrangement.spacedBy(14.dp)) { repeat(PIN_LENGTH) { index -> + val filled = error || index < pinLength Box( modifier = Modifier - .size(12.dp) + .size(13.dp) .clip(CircleShape) - .background( - when { - error -> MaterialTheme.colorScheme.error - index < pinLength -> MaterialTheme.colorScheme.primary - else -> Color.Black.copy(alpha = 0.45f) - }, - ), + .background(if (filled) color else Color.Transparent) + .border(1.5.dp, color.copy(alpha = if (filled) 1f else 0.7f), CircleShape), ) } } @@ -252,10 +188,10 @@ private fun PinKeypad( // the initial-focus policy is one-shot and never re-fires. val keyState = TvControlState.transient(enabled) Column( - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), ) { listOf("123", "456", "789").forEach { row -> - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { row.forEach { digit -> PinKey( label = digit.toString(), @@ -266,8 +202,8 @@ private fun PinKeypad( } } } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Spacer(modifier = Modifier.size(48.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Spacer(modifier = Modifier.size(PinKeySize)) PinKey(label = "0", controlState = keyState, onClick = { onDigitPressed('0') }) PinKey( label = null, @@ -290,7 +226,7 @@ private fun PinKey( ) { val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() - val keyShape = RoundedCornerShape(9.dp) + val keyShape = CircleShape Surface( onClick = { controlState.perform(onClick) }, enabled = controlState.focusable, @@ -300,19 +236,19 @@ private fun PinKey( // (the panel shows its own progress), so the resting and disabled // slots are the same colours either way. colors = ClickableSurfaceDefaults.colors( - containerColor = Color.White.copy(alpha = 0.10f), + containerColor = Color.White.copy(alpha = 0.13f), contentColor = PrairieOnSurface, focusedContainerColor = FocusedContainer, focusedContentColor = FocusedContent, pressedContainerColor = FocusedContainer, pressedContentColor = FocusedContent, - disabledContainerColor = Color.White.copy(alpha = 0.10f), + disabledContainerColor = Color.White.copy(alpha = 0.13f), disabledContentColor = PrairieOnSurface, ), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f), border = ClickableSurfaceDefaults.border( border = Border( - border = BorderStroke(0.5.dp, Color.White.copy(alpha = 0.18f)), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.10f)), shape = keyShape, ), focusedBorder = Border( @@ -321,7 +257,7 @@ private fun PinKey( ), ), modifier = modifier - .size(48.dp) + .size(PinKeySize) .tvControlSemantics(controlState), ) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -329,10 +265,9 @@ private fun PinKey( Text( text = label, style = MaterialTheme.typography.titleLarge.copy( - fontSize = 32.sp, - lineHeight = 36.sp, - fontWeight = FontWeight.Bold, - fontFamily = FontFamily.Monospace, + fontSize = 22.sp, + lineHeight = 26.sp, + fontWeight = FontWeight.Normal, ), color = if (isFocused) FocusedContent else PrairieOnSurface, ) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvPoster.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvPoster.kt index 340d69940c..ecf34977ba 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvPoster.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvPoster.kt @@ -3,14 +3,8 @@ package org.prairieserver.prairie.tv.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Movie import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale @@ -18,10 +12,11 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme -import coil3.compose.AsyncImage +import org.prairieserver.prairie.common.ui.components.DefaultArtworkKind +import org.prairieserver.prairie.common.ui.components.SpoilerImage /** - * Poster image with a solid fallback + movie icon when the URL is missing or fails to load. + * Poster image that shows the default artwork when the URL is missing or fails to load. * * Sized according to the parent Modifier; the caller owns the aspect ratio. For 2:3 posters, * pass `Modifier.size(width, width * 3f / 2f)` (or use BoxWithConstraints). @@ -33,30 +28,23 @@ fun TvPoster( contentDescription: String?, modifier: Modifier = Modifier, cornerRadius: Dp = 8.dp, + defaultArtwork: DefaultArtworkKind = DefaultArtworkKind.Video, + /** Spoiler protection hides this artwork: an unwatched episode's still. */ + hidden: Boolean = false, ) { - val shape = RoundedCornerShape(cornerRadius) Box( modifier = modifier - .clip(shape) + .clip(RoundedCornerShape(cornerRadius)) .background(MaterialTheme.colorScheme.surfaceVariant), - contentAlignment = Alignment.Center, ) { - if (imageUrl.isNullOrBlank()) { - Icon( - imageVector = Icons.Filled.Movie, - contentDescription = contentDescription, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .size(48.dp) - .padding(8.dp), - ) - } else { - AsyncImage( - model = imageUrl, - contentDescription = contentDescription, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) - } + SpoilerImage( + url = imageUrl, + thumbhash = null, + hidden = hidden, + contentDescription = contentDescription, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + defaultArtwork = defaultArtwork, + ) } } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvReferenceShelfCard.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvReferenceShelfCard.kt index 6fe389038d..00d605883e 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvReferenceShelfCard.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvReferenceShelfCard.kt @@ -32,6 +32,8 @@ import androidx.tv.material3.CardDefaults import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import org.prairieserver.prairie.common.ui.components.SpoilerImage +import org.prairieserver.prairie.common.ui.components.DefaultArtworkKind import org.prairieserver.prairie.common.ui.components.ThumbhashImage import org.prairieserver.prairie.tv.ui.theme.ProgressFill import org.prairieserver.prairie.tv.ui.theme.ProgressTrack @@ -41,12 +43,15 @@ import org.prairieserver.prairie.tv.ui.theme.prairieCardDefaults @Composable fun TvReferenceShelfCard( title: String, + hideArtwork: Boolean = false, imageUrl: String?, imageThumbhash: String?, onClick: () -> Unit, modifier: Modifier = Modifier, subtitle: String? = null, detail: String? = null, + /** Catalog item type; picks the mark on the default artwork. */ + mediaType: String? = null, progress: Float? = null, width: Dp = 268.dp, focusRequester: FocusRequester? = null, @@ -77,12 +82,14 @@ fun TvReferenceShelfCard( .fillMaxSize(), ) { Box(modifier = Modifier.fillMaxSize()) { - ThumbhashImage( + SpoilerImage( + hidden = hideArtwork, url = imageUrl, thumbhash = imageThumbhash, contentDescription = title, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), + defaultArtwork = DefaultArtworkKind.forItemType(mediaType), ) Box( diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvRootHeroBackdrop.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvRootHeroBackdrop.kt index 833bd0b9d7..ad629b7686 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvRootHeroBackdrop.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvRootHeroBackdrop.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.tv.material3.MaterialTheme +import org.prairieserver.prairie.common.cards.LocalEpisodeSpoilerPrefs import org.prairieserver.prairie.common.ui.components.ThumbhashImage import org.prairieserver.prairie.model.section.SectionItem @@ -52,6 +53,7 @@ fun TvRootHeroBackdrop( emptyWashColor: Color? = null, animateTransition: Boolean = true, ) { + val spoilerPrefs = LocalEpisodeSpoilerPrefs.current val tintState = LocalAmbientBackdropTint.current val ambientAccent = tintState.accent @@ -144,10 +146,19 @@ fun TvRootHeroBackdrop( ), label = "tvRootHeroBackdropArt", ) { value -> - if (value?.heroBackdropUrl != null) { + val art = when { + value == null -> null + // Protection turning on for the item still shown hides + // its still at once instead of fading the sharp one out. + content != null && value.id == content.id -> + if (content.hidesStills && !value.hidesStills) content else value + // A departing item follows the current preferences too. + else -> value.underPrefs(spoilerPrefs) + } + if (art?.heroBackdropUrl != null) { CornerAnchoredArt( - url = value.heroBackdropUrl, - thumbhash = value.heroBackdropThumbhash, + url = art.heroBackdropUrl, + thumbhash = art.heroBackdropThumbhash, ) } else { Box(modifier = Modifier.fillMaxSize()) @@ -245,8 +256,10 @@ fun TvRootHeroBackdrop( item: SectionItem?, modifier: Modifier = Modifier, ) { - val content = remember(item?.contentId) { - item?.let { TvMarqueeContent.from(it, rowTitle = "") } + val spoilers = LocalEpisodeSpoilerPrefs.current + // Keyed on the whole item: a watch-state change re-decides spoiler hiding. + val content = remember(item, spoilers) { + item?.let { TvMarqueeContent.from(it, rowTitle = "", spoilers = spoilers) } } TvRootHeroBackdrop(content = content, modifier = modifier) } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSelectorRowVisualState.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSelectorRowVisualState.kt index 4b50f301c0..c625293a28 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSelectorRowVisualState.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSelectorRowVisualState.kt @@ -30,7 +30,7 @@ internal fun tvSelectorRowVisualState( FocusedContent.copy(alpha = 0.22f), ) selected -> TvSelectorRowVisualState( - PrairieOnSurface.copy(alpha = 0.14f), + PrairieOnSurface.copy(alpha = 0.075f), PrairieOnSurface, PrairieOnSurface.copy(alpha = 0.28f), ) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylinePanelChrome.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylinePanelChrome.kt index cc88cf4346..823d134fae 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylinePanelChrome.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylinePanelChrome.kt @@ -16,13 +16,14 @@ import androidx.compose.ui.unit.dp * selector + flyout (§5.3) and the Stage-5 profile dropdown (§5.8) — so they * read as one family. Port of tvOS `TVSkylinePanelChrome` (`TVTopMenuBar.swift`). * - * A near-opaque vertical tint (lighter at the top, as if lit from above) + * An opaque vertical tint (lighter at the top, as if lit from above) * finished with a gradient hairline that brightens along the top lip and a * drop shadow, so the panel floats over the page on its own depth rather than * needing a page scrim to darken everything behind it. * - * Compose has no `.regularMaterial` blur; the dark fill is opaque enough that - * the absence of a frost blur is not noticeable over the dimmed page. + * Opaque, not near-opaque: Compose has no `.regularMaterial` blur, and + * without one a 92% fill let the synopsis and Continue Watching cards read + * through the menu as a second layer of text. */ fun Modifier.tvSkylinePanelChrome(corner: Dp = 11.dp): Modifier { val shape = RoundedCornerShape(corner) @@ -45,8 +46,8 @@ fun Modifier.tvSkylinePanelChrome(corner: Dp = 11.dp): Modifier { .background( Brush.verticalGradient( colors = listOf( - Color(0xFF23252C).copy(alpha = 0.92f), - Color(0xFF141519).copy(alpha = 0.95f), + Color(0xFF1D1F24), + Color(0xFF17181B), ), ), ) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineSectionFeed.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineSectionFeed.kt index afc0072f28..49e4d0d97a 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineSectionFeed.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineSectionFeed.kt @@ -23,6 +23,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -47,6 +48,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.tv.material3.MaterialTheme import coil3.SingletonImageLoader import coil3.request.ImageRequest +import org.prairieserver.prairie.common.settings.LocalShowTitleArt import org.prairieserver.prairie.model.catalog.ItemDetail import org.prairieserver.prairie.model.section.ResolvedSection import org.prairieserver.prairie.tv.ui.focus.TvReturnResolution @@ -165,6 +167,8 @@ fun TvSkylineSectionFeed( /** A non-null action replaces the card context menu for that long press. */ longClickActionForSection: (ResolvedSection, SectionItem) -> (() -> Unit)? = { _, _ -> null }, onContentUpFallbackChanged: ((((Boolean) -> Boolean)?) -> Unit)? = null, + /** Re-reads [sections] after the marquee translated a card's description. */ + onMarqueeTranslated: () -> Unit = {}, ) { val rows = remember(sections) { sections.filter { it.items.isNotEmpty() } } val diagnosticsSurface = when { @@ -195,6 +199,7 @@ fun TvSkylineSectionFeed( } } val marquee = rememberTvFocusMarqueeState(fetchDetail = fetchDetail) + SideEffect { marquee.refreshSources(rows) } val initialMarqueeSeed = remember(rows) { rows.firstOrNull()?.let { section -> section.items.firstOrNull()?.let { item -> @@ -428,7 +433,8 @@ fun TvSkylineSectionFeed( ?.let { row -> focusedContentId?.let { contentId -> "${row.id}#$contentId" } }, settledMarqueeId = marquee.content?.id, ) - LaunchedEffect(rows, settledFocus, fetchDetail) { + val prefetchLogos = LocalShowTitleArt.current + LaunchedEffect(rows, settledFocus, fetchDetail, prefetchLogos) { val focus = settledFocus ?: return@LaunchedEffect val row = rows.getOrNull(focus.rowIndex) ?: return@LaunchedEffect val window = settledPrefetchItems( @@ -453,7 +459,8 @@ fun TvSkylineSectionFeed( .build(), ) } - item.logoUrl?.takeIf { it.isNotBlank() }?.let { url -> + // No logo is drawn with title art off; don't fetch one. + item.logoUrl?.takeIf { prefetchLogos && it.isNotBlank() }?.let { url -> add( ImageRequest.Builder(context) .data(url) @@ -887,6 +894,11 @@ fun TvSkylineSectionFeed( TvFocusMarquee( content = marquee.content, detailLine = marquee.enrichment?.detailLine, + translation = rememberTvMarqueeTranslation( + item = marquee.content?.source, + autoTranslate = isFeaturedMarqueeContent(marquee.content, rows), + onTranslated = onMarqueeTranslated, + ), startPadding = TvSkyline.safeAreaX, // Keep the marquee out of the top-menu-bar zone: the block is // bottom-anchored and grows upward, and the raised typography @@ -1029,16 +1041,16 @@ private const val HeroLogoPreloadHeightPx = 200 private const val HeroFocusPrefetchRadius = 2 /** tvOS MediaRow cardSpacing 40pt maps to 20dp. */ -private val TvSkylineItemSpacing = 20.dp +internal val TvSkylineItemSpacing = 20.dp /** A little breathing room between adjacent Home sections. */ -private val TvSkylineRowPreviewSpacing = 14.dp +internal val TvSkylineRowPreviewSpacing = 14.dp /** tvOS rowBandCardVerticalPadding 14pt maps to 7dp. */ -private val TvSkylineRowCardVerticalPadding = 7.dp +internal val TvSkylineRowCardVerticalPadding = 7.dp /** tvOS rowBandBottomInset 20pt maps to 10dp. */ -private val TvSkylineRowBandBottomInset = 10.dp +internal val TvSkylineRowBandBottomInset = 10.dp // The band is sized from what its first row actually needs — section header, // card at the scaled dense-poster height, and whichever caption lines the @@ -1053,9 +1065,9 @@ private val TvSkylineCaptionMetadataHeight = 18.dp // TvMediaCard: 18sp year lin private val TvSkylineRowPreviewPeek = 24.dp // sliver of the next section's header /** The band never pushes the marquee below ~40% of the screen. */ -private const val TvSkylineRowBandMaxFraction = 0.58f +internal const val TvSkylineRowBandMaxFraction = 0.58f -private fun tvSkylineRowBandHeight(presentation: CardPresentation): Dp { +internal fun tvSkylineRowBandHeight(presentation: CardPresentation): Dp { val cardHeight = RowDimens.DensePosterWidth * presentation.posterSize.posterScale * 3f / 2f val captionHeight = when { !presentation.caption.showsTitle -> 0.dp @@ -1069,13 +1081,13 @@ private fun tvSkylineRowBandHeight(presentation: CardPresentation): Dp { } /** Gap between the marquee block and the top of the row band. */ -private val TvSkylineMarqueeBottomGap = 4.dp +internal val TvSkylineMarqueeBottomGap = 4.dp // Row-band relocation requests are close to row-sized; horizontal card rails // have much wider viewports and must still use the smooth scroll distance. private const val TvSkylineVerticalContainerRatio = 3f -private val TvSkylineBringIntoViewSpec: BringIntoViewSpec = object : BringIntoViewSpec { +internal val TvSkylineBringIntoViewSpec: BringIntoViewSpec = object : BringIntoViewSpec { override val scrollAnimationSpec: AnimationSpec = tween( durationMillis = 520, easing = FastOutSlowInEasing, diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/marquee/TvActiveServer.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/marquee/TvActiveServer.kt new file mode 100644 index 0000000000..89de8599a5 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/marquee/TvActiveServer.kt @@ -0,0 +1,26 @@ +package org.prairieserver.prairie.tv.ui.components.marquee + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import org.koin.core.context.GlobalContext +import org.prairieserver.prairie.common.ui.marquee.ServerBrandingLoader +import org.prairieserver.prairie.common.ui.marquee.SignInServer +import org.prairieserver.prairie.common.ui.marquee.rememberSignInServer +import org.prairieserver.prairie.network.ServerRegistry + +/** + * The active server's name and branding for a TV first-run screen, tinting + * the backdrop to its accent. Null where no Koin graph is running (screen + * tests), so screens fall back to the name their view model already has. + */ +@Composable +fun rememberTvActiveServer(): SignInServer? { + val koin = remember { GlobalContext.getOrNull() } ?: return null + val registry = remember(koin) { koin.getOrNull() } ?: return null + val loader = remember(koin) { koin.getOrNull() } ?: return null + val entry by registry.activeEntry.collectAsState() + val url = entry?.url ?: return null + return rememberSignInServer(serverUrl = url, savedName = entry?.fetchedName, loader = loader) +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/marquee/TvMarquee.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/marquee/TvMarquee.kt new file mode 100644 index 0000000000..3c588f38bb --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/marquee/TvMarquee.kt @@ -0,0 +1,425 @@ +package org.prairieserver.prairie.tv.ui.components.marquee + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.Image +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +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.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Icon +import androidx.tv.material3.Text +import androidx.compose.material.icons.outlined.PhoneAndroid +import androidx.compose.material3.CircularProgressIndicator +import org.prairieserver.prairie.common.ui.marquee.MarqueeColors +import org.prairieserver.prairie.common.ui.marquee.MarqueeScrim +import org.prairieserver.prairie.common.ui.marquee.MarqueeScrimStyle +import org.prairieserver.prairie.common.ui.marquee.MarqueeServerMark +import org.prairieserver.prairie.common.ui.marquee.rememberReduceMotion +import org.prairieserver.prairie.tv.R + +/** + * First-run sizes on Android TV: the Apple TV values at half scale, since + * the TV surface is about 960×540dp against Apple TV's 1920×1080 points. + */ +object TvMarqueeMetrics { + val ButtonHeight = 38.dp + val ButtonFont = 14.5.sp + val ButtonPadding = 20.dp + val FieldHeight = 42.dp + val FieldFont = 15.sp + val FieldCorner = 8.dp + val HeroFont = 44.sp + val LeadFont = 14.sp + val CopyWidth = 450.dp + val CardWidth = 320.dp +} + +/** + * Every TV first-run button rests as glass (plain ones as bare text) and + * fills white when focused, like the rest of the TV app. + */ +enum class TvMarqueeButtonKind { Primary, Glass, Plain } + +/** + * Not disabled while work runs by default: on TV a disabled control loses + * focus. Callers ignore repeat presses instead, and pass [enabled] false only + * when the action truly can't run. + */ +@Composable +fun TvMarqueeButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + kind: TvMarqueeButtonKind = TvMarqueeButtonKind.Primary, + isLoading: Boolean = false, + enabled: Boolean = true, + compact: Boolean = false, + focusRequester: FocusRequester? = null, + label: @Composable RowScope.(ink: Color) -> Unit, +) { + var focused by remember { mutableStateOf(false) } + val reduceMotion = rememberReduceMotion() + val scale by animateFloatAsState(if (focused && !reduceMotion) 1.06f else 1f, label = "tvMarqueeButtonScale") + val ink = when { + focused -> Color.Black + kind == TvMarqueeButtonKind.Plain -> MarqueeColors.InkSecondary + else -> MarqueeColors.Ink + } + val height = if (compact) TvMarqueeMetrics.ButtonHeight * 0.8f else TvMarqueeMetrics.ButtonHeight + Row( + modifier = modifier + .graphicsLayer { scaleX = scale; scaleY = scale } + .shadow(if (focused) 12.dp else 0.dp, CircleShape, clip = false) + .height(height) + .clip(CircleShape) + .then( + when { + focused -> Modifier.background(MarqueeColors.Ink) + kind == TvMarqueeButtonKind.Plain -> Modifier + else -> Modifier.background(MarqueeColors.GlassFill).border(1.dp, MarqueeColors.Hairline, CircleShape) + }, + ) + .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) + .onFocusChanged { focused = it.isFocused } + .clickable( + enabled = enabled, + role = Role.Button, + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ) + .alpha(if (enabled) 1f else 0.45f) + .padding(horizontal = if (compact) TvMarqueeMetrics.ButtonPadding * 0.7f else TvMarqueeMetrics.ButtonPadding), + horizontalArrangement = Arrangement.spacedBy(7.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isLoading) CircularProgressIndicator(color = ink, strokeWidth = 1.5.dp, modifier = Modifier.size(14.dp)) + androidx.compose.runtime.CompositionLocalProvider( + androidx.tv.material3.LocalTextStyle provides androidx.compose.ui.text.TextStyle( + fontSize = if (compact) TvMarqueeMetrics.ButtonFont * 0.85f else TvMarqueeMetrics.ButtonFont, + fontWeight = if (kind == TvMarqueeButtonKind.Plain && !focused) FontWeight.Medium else FontWeight.SemiBold, + ), + ) { + label(ink) + } + } +} + +@Composable +fun TvMarqueeButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + kind: TvMarqueeButtonKind = TvMarqueeButtonKind.Primary, + isLoading: Boolean = false, + enabled: Boolean = true, + compact: Boolean = false, + icon: ImageVector? = null, + focusRequester: FocusRequester? = null, +) { + TvMarqueeButton(onClick, modifier, kind, isLoading, enabled, compact, focusRequester) { ink -> + if (icon != null) Icon(icon, contentDescription = null, tint = ink, modifier = Modifier.size(if (compact) 14.dp else 16.dp)) + Text(text, color = ink, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + +/** + * Apple TV-style first-run screen: the backdrop full-bleed under a leading + * scrim, copy on the left, the action card on the right. Each side is its + * own focus group. + */ +@Composable +fun TvMarqueeScreen( + modifier: Modifier = Modifier, + /** The copy column scrolls only when it can't fit (large text, the keyboard up). */ + copyScroll: ScrollState = rememberScrollState(), + accessory: @Composable RowScope.() -> Unit = {}, + copy: @Composable ColumnScope.() -> Unit, + card: @Composable () -> Unit, +) { + Box(modifier.fillMaxSize()) { + MarqueeScrim(MarqueeScrimStyle.Leading) + Column(Modifier.fillMaxSize().padding(horizontal = 45.dp, vertical = 30.dp)) { + Row(Modifier.fillMaxWidth().height(32.dp), verticalAlignment = Alignment.CenterVertically) { + Image( + painter = painterResource(R.drawable.prairie_wordmark), + contentDescription = "Prairie", + contentScale = ContentScale.Fit, + modifier = Modifier.width(75.dp), + ) + Spacer(Modifier.weight(1f)) + accessory() + } + Row( + Modifier.fillMaxWidth().weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(40.dp), + ) { + Column( + Modifier + .width(TvMarqueeMetrics.CopyWidth) + .verticalScroll(copyScroll) + .focusGroup(), + content = copy, + ) + Spacer(Modifier.weight(1f)) + Box(Modifier.focusGroup()) { card() } + } + } + } +} + +/** The right-hand glass card on TV first-run screens. */ +@Composable +fun TvMarqueeCard( + modifier: Modifier = Modifier, + width: Dp = TvMarqueeMetrics.CardWidth, + content: @Composable ColumnScope.() -> Unit, +) { + val shape = RoundedCornerShape(20.dp) + Column( + modifier + .width(width) + .shadow(20.dp, shape, clip = false) + .clip(shape) + .background(Color(0xFF1F1F1F).copy(alpha = 0.72f)) + .border(1.dp, MarqueeColors.Hairline, shape) + .padding(26.dp), + horizontalAlignment = Alignment.CenterHorizontally, + content = content, + ) +} + +/** Big TV title. */ +@Composable +fun TvMarqueeHeadline(text: String, modifier: Modifier = Modifier, maxLines: Int = 3) { + Text( + text, + modifier = modifier.semantics { heading() }, + color = MarqueeColors.Ink, + fontSize = TvMarqueeMetrics.HeroFont, + fontWeight = FontWeight.ExtraBold, + letterSpacing = (-1).sp, + lineHeight = 48.sp, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + ) +} + +/** Body copy at TV size, secondary ink. */ +@Composable +fun TvMarqueeBody( + text: String, + modifier: Modifier = Modifier, + size: TextUnit = TvMarqueeMetrics.LeadFont, + color: Color = MarqueeColors.InkSecondary, + textAlign: TextAlign? = null, +) { + Text(text, modifier = modifier, color = color, fontSize = size, lineHeight = size * 1.35f, textAlign = textAlign) +} + +/** Inline error under a control. */ +@Composable +fun TvMarqueeErrorText(text: String, modifier: Modifier = Modifier) { + Text( + text, + modifier = modifier.clearAndSetSemantics { contentDescription = "Error: $text" }, + color = MarqueeColors.Error, + fontSize = 14.sp, + lineHeight = 16.sp, + ) +} + +/** Small status capsule ("Looking for a phone or tablet…"). */ +@Composable +fun TvMarqueeStatusChip(text: String, modifier: Modifier = Modifier, showsSpinner: Boolean = false, icon: ImageVector? = null) { + Row( + modifier + .height(30.dp) + .clip(CircleShape) + .background(MarqueeColors.GlassFill) + .border(1.dp, MarqueeColors.Hairline, CircleShape) + .padding(horizontal = 11.dp), + horizontalArrangement = Arrangement.spacedBy(7.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (showsSpinner) { + CircularProgressIndicator(color = MarqueeColors.InkSecondary, strokeWidth = 2.dp, modifier = Modifier.size(13.dp)) + } else if (icon != null) { + Icon(icon, contentDescription = null, tint = MarqueeColors.InkSecondary, modifier = Modifier.size(12.dp)) + } + Text(text, color = MarqueeColors.InkSecondary, fontSize = 14.sp, fontWeight = FontWeight.Medium, maxLines = 1) + } +} + +/** A large symbol in a glass circle, for card states without a QR code or code. */ +@Composable +fun TvMarqueeCardSymbol(icon: ImageVector, tint: Color = MarqueeColors.Ink, size: Dp = 56.dp) { + Box( + Modifier + .size(size) + .clip(CircleShape) + .background(MarqueeColors.GlassFill) + .border(1.dp, MarqueeColors.Hairline, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(size * 0.46f)) + } +} + +/** + * The card beside TV text entry: a nearby phone can still finish setup, so + * nothing has to be typed with the remote. + */ +@Composable +fun TvUsePhoneCard(text: String) { + TvMarqueeCard { + TvMarqueeCardSymbol(androidx.compose.material.icons.Icons.Outlined.PhoneAndroid) + Text( + "Easier with a phone", + color = MarqueeColors.Ink, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 17.dp), + ) + TvMarqueeBody(text, size = 14.sp, textAlign = TextAlign.Center, modifier = Modifier.padding(top = 7.dp)) + } +} + +/** + * "Now showing" card: which server this TV is signing in to, with its mark, + * address, and whether the connection is encrypted. + */ +@Composable +fun TvMarqueeServerCard(name: String, address: String, markUrl: String?, secure: Boolean, modifier: Modifier = Modifier) { + val shape = RoundedCornerShape(15.dp) + Row( + modifier + .clip(shape) + .background(Color.White.copy(alpha = 0.07f)) + .border(1.dp, MarqueeColors.Hairline, shape) + .padding(12.dp) + .semantics(mergeDescendants = true) { + contentDescription = "$name, $address, ${if (secure) "Secure" else "HTTP"}" + }, + horizontalArrangement = Arrangement.spacedBy(11.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MarqueeServerMark(name = name, imageUrl = markUrl, size = 40.dp) + Column(verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text(name, color = MarqueeColors.Ink, fontSize = 17.sp, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(address, color = MarqueeColors.InkTertiary, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + val warning = !secure + Row( + Modifier + .height(19.dp) + .clip(CircleShape) + .background(if (warning) MarqueeColors.Warning.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.45f)) + .border(1.dp, if (warning) MarqueeColors.Warning.copy(alpha = 0.35f) else MarqueeColors.Hairline, CircleShape) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + if (secure) "SECURE" else "HTTP", + color = if (warning) MarqueeColors.Warning else MarqueeColors.Ink, + fontSize = 9.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.8.sp, + ) + } + } +} + +/** + * The code a person compares between the TV and their phone. Codes are + * server-generated and may be longer than eight characters, so tiles shrink + * to keep the row inside the card. + */ +@Composable +fun TvMarqueeCodeTiles(code: String, modifier: Modifier = Modifier, maxWidth: Dp = 260.dp) { + val characters = code.uppercase().toList() + val gap = 7.dp + val tileWidth = minOf(42.dp, (maxWidth - gap * (characters.size - 1).coerceAtLeast(0)) / characters.size.coerceAtLeast(1)) + Row( + modifier.clearAndSetSemantics { contentDescription = "Code: " + characters.joinToString(", ") }, + horizontalArrangement = Arrangement.spacedBy(gap), + ) { + characters.forEach { character -> + val separator = character == '-' || character == ' ' + Box( + Modifier + .width(if (separator) tileWidth * 0.45f else tileWidth) + .height(tileWidth * 1.24f) + .then( + if (separator) { + Modifier + } else { + Modifier + .clip(RoundedCornerShape(8.dp)) + .background(MarqueeColors.GlassFill) + .border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(8.dp)) + }, + ), + contentAlignment = Alignment.Center, + ) { + Text( + if (character == '-') "–" else character.toString(), + color = if (separator) MarqueeColors.InkTertiary else MarqueeColors.Ink, + fontSize = (tileWidth.value * 0.66f).sp, + fontWeight = FontWeight.Bold, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + textAlign = TextAlign.Center, + ) + } + } + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/marquee/TvMarqueeField.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/marquee/TvMarqueeField.kt new file mode 100644 index 0000000000..ae81c6b0aa --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/marquee/TvMarqueeField.kt @@ -0,0 +1,161 @@ +package org.prairieserver.prairie.tv.ui.components.marquee + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +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.alpha +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.isImeVisible +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Icon +import androidx.tv.material3.Text +import org.prairieserver.prairie.common.ui.marquee.MarqueeColors +import org.prairieserver.prairie.common.ui.marquee.rememberReduceMotion +import org.prairieserver.prairie.tv.ui.components.tvShowImeOnSelect + +/** + * TV text field in the first-run look: glass at rest, white with black ink + * when focused. SELECT opens the keyboard ([tvShowImeOnSelect]), so focus + * moving through the form never raises it by itself. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun TvMarqueeField( + value: String, + onValueChange: (String) -> Unit, + icon: ImageVector, + placeholder: String, + modifier: Modifier = Modifier, + keyboardType: KeyboardType = KeyboardType.Text, + imeAction: ImeAction = ImeAction.Next, + onImeAction: (() -> Unit)? = null, + isSecure: Boolean = false, + /** With [isSecure], shows the characters instead of dots. */ + revealed: Boolean = false, + isError: Boolean = false, + enabled: Boolean = true, + focusRequester: FocusRequester? = null, + onFocusChange: (Boolean) -> Unit = {}, +) { + var focused by remember { mutableStateOf(false) } + val focusManager = LocalFocusManager.current + // Text is typed on the keyboard; without it up, Left and Right move + // between controls (the show-password button beside a field) rather + // than the invisible cursor. + val imeVisible = WindowInsets.isImeVisible + val reduceMotion = rememberReduceMotion() + val scale by animateFloatAsState(if (focused && !reduceMotion) 1.04f else 1f, label = "tvMarqueeFieldScale") + val shape = RoundedCornerShape(TvMarqueeMetrics.FieldCorner) + val ink = if (focused) Color.Black else MarqueeColors.Ink + BasicTextField( + value = value, + onValueChange = onValueChange, + enabled = enabled, + singleLine = true, + textStyle = TextStyle(color = ink, fontSize = TvMarqueeMetrics.FieldFont), + cursorBrush = SolidColor(if (focused) Color.Black else MarqueeColors.Ink), + visualTransformation = if (isSecure && !revealed) PasswordVisualTransformation() else VisualTransformation.None, + keyboardOptions = KeyboardOptions( + keyboardType = if (isSecure) KeyboardType.Password else keyboardType, + imeAction = imeAction, + autoCorrectEnabled = false, + showKeyboardOnFocus = false, + ), + keyboardActions = KeyboardActions(onAny = { onImeAction?.invoke() }), + modifier = modifier + .fillMaxWidth() + .height(TvMarqueeMetrics.FieldHeight) + .graphicsLayer { scaleX = scale; scaleY = scale } + .background(if (focused) MarqueeColors.Ink else Color.White.copy(alpha = if (isError) 0.06f else 0.08f), shape) + .border( + width = if (isError) 1.5.dp else 1.dp, + color = when { + isError -> MarqueeColors.Error.copy(alpha = 0.75f) + focused -> Color.Transparent + else -> Color.White.copy(alpha = 0.12f) + }, + shape = shape, + ) + .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) + .onFocusChanged { + focused = it.isFocused + onFocusChange(it.isFocused) + } + .onPreviewKeyEvent { event -> + if (imeVisible || event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when (event.key) { + Key.DirectionRight -> focusManager.moveFocus(FocusDirection.Right) + Key.DirectionLeft -> focusManager.moveFocus(FocusDirection.Left) + else -> false + } + } + .tvShowImeOnSelect() + .alpha(if (enabled) 1f else 0.5f) + .semantics { contentDescription = placeholder }, + decorationBox = { inner -> + Row( + Modifier.fillMaxWidth().padding(horizontal = 15.dp), + horizontalArrangement = Arrangement.spacedBy(9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + icon, + contentDescription = null, + tint = if (focused) Color.Black.copy(alpha = 0.45f) else MarqueeColors.InkTertiary, + modifier = Modifier.size(16.dp), + ) + Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { + if (value.isEmpty()) { + Text( + placeholder, + color = if (focused) Color.Black.copy(alpha = 0.4f) else MarqueeColors.InkTertiary, + fontSize = TvMarqueeMetrics.FieldFont, + maxLines = 1, + ) + } + inner() + } + } + }, + ) +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/marquee/TvMarqueeSetupSteps.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/marquee/TvMarqueeSetupSteps.kt new file mode 100644 index 0000000000..638fdbc004 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/marquee/TvMarqueeSetupSteps.kt @@ -0,0 +1,150 @@ +package org.prairieserver.prairie.tv.ui.components.marquee + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Icon +import androidx.tv.material3.Text +import org.prairieserver.prairie.common.ui.marquee.MarqueeColors +import org.prairieserver.prairie.tv.R + +/** + * How to set this TV up from a phone: three numbered steps, one action each, + * with a small picture of exactly that action. Short titles only, so they + * read from across the room (prairie-apple `MarqueeTVSetupSteps`). + */ +@Composable +fun TvMarqueeSetupSteps(modifier: Modifier = Modifier) { + Column(modifier.width(350.dp), verticalArrangement = Arrangement.spacedBy(15.dp)) { + Step(1, "Open Prairie on your phone") { AppIconPicture() } + Step(2, "Tap Set up") { SetupCardPicture() } + Step(3, "Finish on your phone") { DonePicture() } + } +} + +@Composable +private fun Step(number: Int, title: String, picture: @Composable () -> Unit) { + Row( + Modifier.clearAndSetSemantics { contentDescription = "Step $number: $title" }, + horizontalArrangement = Arrangement.spacedBy(17.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box { + Box( + Modifier + .size(84.dp) + .clip(RoundedCornerShape(17.dp)) + .background(Color.White.copy(alpha = 0.06f)) + .border(1.dp, MarqueeColors.Hairline, RoundedCornerShape(17.dp)), + contentAlignment = Alignment.Center, + ) { picture() } + Box( + Modifier + .offset(x = (-8).dp, y = (-8).dp) + .shadow(4.dp, CircleShape) + .size(26.dp) + .clip(CircleShape) + .background(MarqueeColors.Ink), + contentAlignment = Alignment.Center, + ) { + Text("$number", color = Color.Black, fontSize = 14.sp, fontWeight = FontWeight.ExtraBold) + } + } + Text(title, color = MarqueeColors.Ink, fontSize = 19.sp, fontWeight = FontWeight.Bold, letterSpacing = (-0.2).sp) + } +} + +/** The Prairie app icon: the mark on the deep blue field. */ +@Composable +private fun AppIconPicture() { + Box( + Modifier + .size(46.dp) + .clip(RoundedCornerShape(11.dp)) + .background(colorResource(R.color.prairie_icon_background)) + .border(1.dp, Color.White.copy(alpha = 0.18f), RoundedCornerShape(11.dp)), + contentAlignment = Alignment.Center, + ) { + // The icon draws the mark at 84% of its height. + Image( + painter = painterResource(R.drawable.silo_mark), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.height(38.5.dp), + ) + } +} + +/** The setup card Prairie shows on a nearby phone, with its Set up button ringed. */ +@Composable +private fun SetupCardPicture() { + Column( + Modifier + .width(64.dp) + .clip(RoundedCornerShape(10.dp)) + .background(Color(0xFF262626).copy(alpha = 0.95f)) + .border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(10.dp)) + .padding(start = 6.dp, end = 6.dp, top = 7.dp, bottom = 6.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Box(Modifier.width(40.dp).height(4.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.3f))) + Box(Modifier.width(28.dp).height(4.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.16f))) + Box( + Modifier + .fillMaxWidth() + .border(2.dp, MarqueeColors.BrandOrange.copy(alpha = 0.85f), CircleShape) + .padding(2.dp) + .height(15.dp) + .clip(CircleShape) + .background(MarqueeColors.Ink), + contentAlignment = Alignment.Center, + ) { + Text("Set up", color = Color.Black, fontSize = 7.5.sp, fontWeight = FontWeight.Bold) + } + } +} + +/** A green check: the rest happens on the phone. */ +@Composable +private fun DonePicture() { + val green = MarqueeColors.Live + Box( + Modifier + .size(48.dp) + .clip(CircleShape) + .background(green.copy(alpha = 0.16f)) + .border(1.5.dp, green, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Filled.Check, contentDescription = null, tint = green, modifier = Modifier.size(24.dp)) + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAppNavigation.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAppNavigation.kt index d630666e24..4d5803f64b 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAppNavigation.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAppNavigation.kt @@ -3,6 +3,7 @@ package org.prairieserver.prairie.tv.ui.navigation import androidx.compose.foundation.layout.fillMaxWidth import android.net.Uri import android.util.Log +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -18,17 +19,21 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.produceState import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.navigation.NavBackStackEntry import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState +import org.prairieserver.prairie.common.ui.marquee.MarqueeBackdrop import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import org.prairieserver.prairie.common.player.video.VideoPlayerRouteArgs import org.prairieserver.prairie.network.TokenManager import org.prairieserver.prairie.repository.AuthRepository +import org.prairieserver.prairie.repository.ProfilePromptAction import org.prairieserver.prairie.repository.ProfileRepository +import org.prairieserver.prairie.repository.ProfileVerificationRecovery import org.prairieserver.prairie.tv.MainTvActivity import org.prairieserver.prairie.tv.ui.components.TvSelectToShowImeHost import org.prairieserver.prairie.tv.ui.shell.TvMainShell @@ -52,19 +57,30 @@ import org.prairieserver.prairie.tv.ui.screens.settings.diagnostics.TvDiagnostic import org.prairieserver.prairie.tv.ui.screens.settings.diagnostics.TvDiagnosticsReportScreen import org.prairieserver.prairie.tv.ui.screens.settings.diagnostics.TvDiagnosticsSurfacePresence import org.prairieserver.prairie.tv.ui.screens.settings.diagnostics.TvDiagnosticsViewModel -import org.prairieserver.prairie.tv.ui.screens.watchtogether.TvWatchTogetherLobbyScreen -import org.prairieserver.prairie.tv.ui.screens.watchtogether.tvWatchTogetherDestination -import org.prairieserver.prairie.model.watchtogether.RoomSnapshot -import org.prairieserver.prairie.watchtogether.WatchTogetherEntryTarget -import org.prairieserver.prairie.watchtogether.watchTogetherEntryTarget +import org.prairieserver.prairie.tv.ui.screens.watchparty.TvWatchPartyHubScreen +import org.prairieserver.prairie.tv.ui.screens.watchparty.TvWatchPartyLobbyScreen +import org.prairieserver.prairie.tv.ui.screens.watchparty.TvWatchPartyPlayGuardDialog +import org.prairieserver.prairie.tv.ui.screens.watchparty.rememberTvWatchPartyPlayGuard +import org.prairieserver.prairie.repository.WatchTogetherRepository +import org.prairieserver.prairie.watchtogether.WatchPartyDestination import org.prairieserver.prairie.common.cards.ProvideCardPresentation +import org.prairieserver.prairie.common.cards.ProvideEpisodeSpoilerPrefs +import org.prairieserver.prairie.common.settings.ProvideTitleArt +import org.prairieserver.prairie.common.settings.TitleArtStore import org.prairieserver.prairie.common.overlays.ProvideCardOverlays import org.prairieserver.prairie.common.diagnostics.DiagnosticsLifecycleLogger import org.prairieserver.prairie.common.settings.CardPresentationStore import org.prairieserver.prairie.common.settings.LibraryPlaybackPrefsStore import org.prairieserver.prairie.common.settings.OverlayPrefsStore import org.prairieserver.prairie.tv.watchnext.WatchNextSeeder +import org.prairieserver.prairie.tv.cast.RemotePlaybackIdentityManager import org.prairieserver.prairie.tv.cast.TvPrairieCastReceiver +import org.prairieserver.prairie.common.profiles.ProfileLaunchPreferences +import org.prairieserver.prairie.tv.profiles.TvActiveProfileReset +import org.prairieserver.prairie.common.profiles.ProfileAwayTracker +import org.prairieserver.prairie.common.player.PlaybackSessionLifecycle +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull import org.prairieserver.prairie.tv.ui.screens.cast.TvPrairieCastStandbyView import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch @@ -97,6 +113,16 @@ internal fun tvShouldShowDiagnosticsPrompt( */ private const val MAX_DEEP_LINK_NAV_ATTEMPTS = 3 +/** + * How long the Profile Selection return rule waits for a phone-launched + * title's playback teardown before ending its identity. Same bound as + * TvPrairieCastReceiver's PLAYBACK_TEARDOWN_TIMEOUT_MS. + */ +private const val CAST_PLAYBACK_TEARDOWN_TIMEOUT_MS = 15_000L + +/** Upper bound on how long the Profile Selection cover waits for the picker's transition. */ +private const val PROFILE_COVER_MAX_MS = 2_000L + /** * True when item detail for exactly [contentId]/[seasonNumber] is already the * top of the stack. @@ -250,30 +276,95 @@ private fun NavHostController.navigateToTvPlayback( ) } +/** Whether [entry] is the party screen for [destination]. */ +private fun tvIsWatchPartyEntry(entry: NavBackStackEntry, destination: WatchPartyDestination): Boolean = + when (destination) { + is WatchPartyDestination.Lobby -> + entry.destination.route == TvRoute.WatchPartyLobby.ROUTE && + entry.arguments?.getString(TvRoute.WatchPartyLobby.ARG_ROOM_ID) == destination.roomId + is WatchPartyDestination.Player -> + entry.destination.route == TvRoute.Player.ROUTE && + entry.arguments?.getString(TvRoute.Player.ARG_ROOM_ID) == destination.roomId + } + +/** Plays a shuffle's first pick, from the beginning like every pick. */ +private fun NavHostController.navigateToTvShufflePlayback( + shuffle: org.prairieserver.prairie.model.shuffle.Shuffle, + libraryId: Int?, + lastPlaybackNavigation: MutableState, +) { + navigateToTvPlayback( + destination = TvRoute.Player( + contentId = shuffle.current.contentId, + resumePositionSeconds = 0.0, + libraryId = libraryId, + shuffleId = shuffle.id, + ).route, + contentId = shuffle.current.contentId, + lastPlaybackNavigation = lastPlaybackNavigation, + ) +} + /** - * Watch Together enters either a lobby or a player. The player case is an - * ordinary playback navigation and must go through [navigateToTvPlayback] — - * it used `launchSingleTop`, which mutates the existing player entry's - * arguments while PRESERVING its id, so a recorded solo request could still - * look current afterwards and suppress the user's next real request. + * Shows a Watch Party screen: the hub (null), the room's lobby, or its + * player. A party screen already on the back stack is returned to instead of + * stacked again, so Back never walks through stale party screens. The player + * goes through [navigateToTvPlayback] like every other playback request. */ -private fun NavHostController.navigateToTvWatchTogether( - room: RoomSnapshot, +private fun NavHostController.navigateToWatchParty( + destination: WatchPartyDestination?, + repository: WatchTogetherRepository, lastPlaybackNavigation: MutableState, ) { - val destination = tvWatchTogetherDestination(room) - val contentId = room.selectedContentId - if (watchTogetherEntryTarget(room) == WatchTogetherEntryTarget.Player && contentId != null) { - navigateToTvPlayback( - destination = destination, - contentId = contentId, - lastPlaybackNavigation = lastPlaybackNavigation, - ) - } else { - navigate(destination) { launchSingleTop = true } + // The topmost entry of the party screen's destination, if it is this + // room's. (One party at a time, and D5 keeps solo players off a party's + // stack, so the topmost entry is the only candidate.) + val existing = destination?.let { target -> + val pattern = when (target) { + is WatchPartyDestination.Lobby -> TvRoute.WatchPartyLobby.ROUTE + is WatchPartyDestination.Player -> TvRoute.Player.ROUTE + } + runCatching { getBackStackEntry(pattern) }.getOrNull()?.takeIf { tvIsWatchPartyEntry(it, target) } + } + if (existing != null) { + popBackStack(existing.destination.id, inclusive = false) + return + } + val fromHub = currentBackStackEntry?.destination?.route == TvRoute.WatchPartyHub.ROUTE + when (destination) { + null -> navigate(TvRoute.WatchPartyHub().route) { launchSingleTop = true } + is WatchPartyDestination.Lobby -> navigate(TvRoute.WatchPartyLobby(destination.roomId).route) { + // Back from the lobby returns to where the viewer came from, not the hub. + if (fromHub) popUpTo(TvRoute.WatchPartyHub.ROUTE) { inclusive = true } + } + is WatchPartyDestination.Player -> { + val room = repository.roomSnapshot.value?.takeIf { it.roomId == destination.roomId } + val contentId = room?.selectedContentId?.takeIf { it.isNotBlank() } ?: destination.roomId + // The player replaces the hub, as the lobby does, so leaving the + // party returns to where the viewer came from. + if (fromHub) popBackStack() + navigateToTvPlayback( + destination = TvRoute.Player( + contentId = contentId, + fileId = room?.selectedFileId, + libraryId = room?.selectedLibraryId, + roomId = destination.roomId, + ).route, + contentId = contentId, + lastPlaybackNavigation = lastPlaybackNavigation, + ) + } } } +/** + * Whether a party route for [roomId] is backed by a live membership. The + * room token lives only in memory, so after process death a restored lobby or + * player route has nothing behind it and must not silently re-adopt. + */ +private fun tvHasLiveWatchParty(repository: WatchTogetherRepository, roomId: String?): Boolean = + roomId != null && repository.roomSnapshot.value?.roomId == roomId + /** Pushes item detail, collapsing only an exact repeat of the current page. */ private fun NavHostController.navigateToTvItemDetail( contentId: String, @@ -327,6 +418,38 @@ private val preMainAuthRoutes: Set = setOf( TvRoute.EditProfile.ROUTE, ) +/** First-run routes, drawn transparent over the shared [MarqueeBackdrop]. */ +private val tvMarqueeRoutes: Set = setOf( + TvRoute.ServerSetup.route, + TvRoute.Setup.route, + TvRoute.Signup.route, + TvRoute.Login.ROUTE, + TvRoute.ProfileSelection.route, +) + +/** The profile picker and its screens, where a prompt has nothing to add. */ +private val TvProfilePromptPickerRoutes: Set = setOf( + TvRoute.ProfileSelection.route, + TvRoute.CreateProfile.route, + TvRoute.EditProfile.ROUTE, +) + +/** + * Destinations a profile-verification prompt waits behind: playback keeps + * going on the session it started with, and the sign-in and server screens + * have no profile in use. + */ +private val TvProfilePromptDeferredRoutes: Set = setOf( + TvRoute.Player.ROUTE, + TvRoute.AudiobookPlayer.ROUTE, + TvRoute.ServerSetup.route, + TvRoute.Setup.route, + TvRoute.Signup.route, + TvRoute.Login.ROUTE, + TvRoute.ServerList.route, + TvRoute.PairDevice.ROUTE, +) + /** * Page-to-page cross-fade duration (ms). A middle ground between Compose Nav's * sluggish 700ms default and a phone-snappy 200ms — a touch more deliberate for @@ -349,19 +472,111 @@ fun TvAppNavigation( val profileRepository: ProfileRepository = koinInject() val overlayPrefsStore: OverlayPrefsStore = koinInject() val cardPresentationStore: CardPresentationStore = koinInject() + val episodeSpoilerStore: org.prairieserver.prairie.common.settings.EpisodeSpoilerStore = koinInject() val seekIntervalStore: org.prairieserver.prairie.common.settings.SeekIntervalStore = koinInject() + val titleArtStore: TitleArtStore = koinInject() val libraryPlaybackPrefsStore: LibraryPlaybackPrefsStore = koinInject() val watchNextSeeder: WatchNextSeeder = koinInject() val siloCastReceiver: TvPrairieCastReceiver = koinInject() + val watchPartyRepository: WatchTogetherRepository = koinInject() val diagnosticsViewModel = koinViewModel() val diagnosticsState by diagnosticsViewModel.state.collectAsState() val pendingDeepLink: MutableStateFlow = koinInject(qualifier = named("pendingDeepLink")) val siloCastStandby by siloCastReceiver.standbyState.collectAsState() + val profileAwayTracker: ProfileAwayTracker = koinInject() + val profileLaunchPreferences: ProfileLaunchPreferences = koinInject() + val activeProfileReset: TvActiveProfileReset = koinInject() + val remotePlaybackIdentityManager: RemotePlaybackIdentityManager = koinInject() + val playbackLifecycle: PlaybackSessionLifecycle = koinInject() + + // Android can restore Prairie's previous back stack over the start route + // MainTvActivity chose: after a configuration change while Prairie was away, + // or after its process was killed in the background. When Profile + // Selection has already cleared the profile, that brings back Home with + // no profile behind it, so go to Who's Watching instead. + LaunchedEffect(navController) { + navController.currentBackStackEntryFlow.first() + val restoredMain = runCatching { navController.getBackStackEntry(TvRoute.Main.route) }.isSuccess + if (restoredMain && + !tokenManager.getAccessToken().isNullOrBlank() && + tokenManager.getProfileId().isNullOrBlank() + ) { + navController.navigate(TvRoute.ProfileSelection.route) { + popUpTo(0) { inclusive = true } + launchSingleTop = true + } + } + } + + // Profile Selection's return rule (prairie-apple `applyProfileReturnPolicy`): + // when Prairie comes back from the background and the setting asks, Who's + // Watching replaces whatever was on screen, as Switch Profile does. + LaunchedEffect(profileAwayTracker) { + kotlinx.coroutines.flow.combine( + profileAwayTracker.selectionRequired, + navController.currentBackStackEntryFlow, + ) { required, entry -> required to entry.destination.route }.collect { (required, route) -> + if (!required) return@collect + // Nothing to replace when signed out, or when neither a profile + // nor its screens are left (already choosing a profile, or adding + // a server). Judge by the profile and the back stack, not the + // route: Manage Servers and the add-server screens can sit on top + // of Main, and an Activity recreated during the return (a + // configuration change while Prairie was away) restores Main after + // MainTvActivity has already cleared the profile. + val mainOnBackStack = runCatching { navController.getBackStackEntry(TvRoute.Main.route) }.isSuccess + val showsProfileScreens = !tokenManager.getAccessToken().isNullOrBlank() && + (!tokenManager.getProfileId().isNullOrBlank() || mainOnBackStack) + if (route == null || !showsProfileScreens) { + profileAwayTracker.onSelectionHandled() + return@collect + } + // A phone-launched title's temporary identity is not this TV's + // profile to clear. End it first, as tvOS does, but in + // TvPrairieCastReceiver.stop()'s order and with its guard: the + // player's queued final report and stopSession ride on that + // identity, so let them land (bounded) first, and leave it alone + // if a newer receiver run installed or reused it meanwhile. That + // is a phone's live title, and its viewer is the one watching. + if (tokenManager.hasTemporaryScope()) { + val claimedByRun = remotePlaybackIdentityManager.activeIdentity?.receiverRun + withTimeoutOrNull(CAST_PLAYBACK_TEARDOWN_TIMEOUT_MS) { playbackLifecycle.awaitPendingStop() } + if (claimedByRun != null) remotePlaybackIdentityManager.endIfNotClaimedSince(claimedByRun) + if (tokenManager.hasTemporaryScope()) { + profileAwayTracker.onSelectionHandled() + return@collect + } + } + // Re-check after the wait, as prairie-apple's return policy does. + if (!profileLaunchPreferences.requiresSelectionAfterBackground()) { + profileAwayTracker.onSelectionHandled() + return@collect + } + // Leave the profile's screens before clearing its state (see + // onSwitchProfile). MainTvActivity.onStop already flushed pending + // device-setting writes under this profile. + navController.navigate(TvRoute.ProfileSelection.route) { + popUpTo(0) { inclusive = true } + launchSingleTop = true + } + activeProfileReset.clearActiveProfile() + // MainTvActivity covers the screen while the request is up; lift + // it once the previous profile's screens have faded out. + withTimeoutOrNull(PROFILE_COVER_MAX_MS) { + navController.visibleEntries.first { entries -> + entries.all { it.destination.route in preMainAuthRoutes } + } + } + profileAwayTracker.onSelectionHandled() + } + } LaunchedEffect(siloCastReceiver) { - siloCastReceiver.launchRequests.collect { request -> - val playback = request.playback + siloCastReceiver.launchRequests.collect { launch -> + // An expired launch, or one whose identity changed, never opens. + if (!siloCastReceiver.confirmLaunch(launch.id)) return@collect + val playback = launch.request.playback val destination = TvRoute.Player( contentId = playback.contentId, libraryId = playback.libraryId, @@ -369,6 +584,7 @@ fun TvAppNavigation( resumePositionSeconds = if (playback.startFromBeginning) 0.0 else playback.resumePosition, audioTrackIndex = playback.audioTrackIndex, subtitleTrackIndex = playback.subtitleTrackIndex, + castLaunchId = launch.id, ).route // Replace whichever player is on top, not just the video one. This // only knew about TvRoute.Player, so a cast launch during an @@ -405,7 +621,9 @@ fun TvAppNavigation( kotlinx.coroutines.flow.combine( pendingDeepLink, navController.currentBackStackEntryFlow, - ) { uri, entry -> uri to entry }.collect { (uri, entry) -> + // Re-runs a held link once a chosen profile ends the away interval. + profileLaunchPreferences.state, + ) { uri, entry, _ -> uri to entry }.collect { (uri, entry) -> if (uri == null) return@collect if (uri != attemptUri) { attemptUri = uri @@ -446,10 +664,22 @@ fun TvAppNavigation( // never go stale on an authenticated session. val route = entry.destination.route if (route == null || route in preMainAuthRoutes) return@collect // unauthenticated flow: keep queued until Main + // Profile Selection (prairie-apple `handleDeepLink`): a link that + // arrives while Prairie is returning must not open the previous + // profile's data before Who's Watching replaces it. Keep it queued + // until a profile is chosen. + if (profileLaunchPreferences.requiresSelectionAfterBackground()) return@collect val contentId = uri.pathSegments.lastOrNull() ?: run { pendingDeepLink.value = null return@collect } + // D5: a launcher play link while this TV is in a Watch Party opens + // the title's page instead, where Play asks to leave the party + // first. Playing it outright would hide the departure. + if (uri.host == "play" && watchPartyRepository.roomSnapshot.value != null) { + pendingDeepLink.value = uri.buildUpon().authority("item").clearQuery().build() + return@collect + } // Arrived: the current destination is this link's target, so the // link is spent. This is the only place a successful content link // is cleared — see the bookkeeping comment above. @@ -538,7 +768,7 @@ fun TvAppNavigation( // preserved so they don't have to re-enter the URL). LaunchedEffect(Unit) { tokenManager.sessionExpired.collect { - navController.navigate(TvRoute.Login().route) { + navController.navigate(TvRoute.Login(sessionExpired = true).route) { // Clear the entire back stack so the user can't press Back // to return to a screen that has no credentials to render. popUpTo(0) { inclusive = true } @@ -547,6 +777,57 @@ fun TvAppNavigation( } } + // The server stopped accepting the active profile's PIN proof (an admin + // changed the account's access). The recovery has already cleared the + // stale profile selection, but not the sign-in; send the viewer to the + // profile picker to choose a profile and enter its PIN again. Playback and + // the sign-in screens are not interrupted: the prompt waits until the + // viewer leaves them (or a remote-playback overlay ends), and is dropped + // after a sign-out, a server switch, or a profile pick. + val profileVerificationRecovery: ProfileVerificationRecovery = koinInject() + LaunchedEffect(Unit) { + // promptChecks re-emits when a remote-playback overlay ends, so a + // prompt deferred behind it does not wait for the next navigation. + kotlinx.coroutines.flow.combine( + profileVerificationRecovery.promptChecks, + navController.currentBackStackEntryFlow, + ) { prompt, entry -> prompt to entry.destination.route } + .collect { (prompt, route) -> + if (prompt == null) return@collect + when ( + profileVerificationRecovery.actionFor( + prompt = prompt, + currentRoute = route, + deferRoutes = TvProfilePromptDeferredRoutes, + pickerRoutes = TvProfilePromptPickerRoutes, + ) + ) { + ProfilePromptAction.Defer -> Unit + // Already on the picker: it reloads itself, and a PIN + // being typed is kept. + ProfilePromptAction.AlreadyThere, + ProfilePromptAction.Drop, + -> profileVerificationRecovery.consume(prompt) + ProfilePromptAction.Navigate -> { + profileVerificationRecovery.consume(prompt) + navController.navigate(TvRoute.ProfileSelection.route) { + popUpTo(0) { inclusive = true } + launchSingleTop = true + } + // Same per-profile teardown as Switch Profile; the + // picked profile re-seeds Watch Next on selection. + libraryPlaybackPrefsStore.clear() + overlayPrefsStore.clear() + cardPresentationStore.clear() + seekIntervalStore.clear() + episodeSpoilerStore.clear() + titleArtStore.clear() + watchNextSeeder.clear() + } + } + } + } + // Re-read the authenticated profile id whenever the destination changes // (Login → ProfileSelection → Main). Drives card-overlay hydration off the // authenticated identity instead of a one-shot at app start, where the user @@ -564,7 +845,29 @@ fun TvAppNavigation( ProvideCardOverlays(store = overlayPrefsStore, sessionKey = overlaySessionKey) { ProvideCardPresentation(store = cardPresentationStore, sessionKey = overlaySessionKey) { + ProvideEpisodeSpoilerPrefs(store = episodeSpoilerStore, sessionKey = overlaySessionKey) { + val launcherSpoilerState by episodeSpoilerStore.state.collectAsState() + LaunchedEffect(overlaySessionKey, launcherSpoilerState.support, launcherSpoilerState.prefs.hideImages) { + // The launcher row belongs to this TV's own profile: a phone's + // temporary cast identity must not reseed it with its titles. + if (overlaySessionKey != null && !tokenManager.hasTemporaryScope() && + tokenManager.getProfileId() == overlaySessionKey && + launcherSpoilerState.support != org.prairieserver.prairie.common.settings.EpisodeSpoilerSupport.Unknown) { + watchNextSeeder.updateImageProtection(launcherSpoilerState.prefs.hideImages) + } + } + ProvideTitleArt(store = titleArtStore, sessionKey = overlaySessionKey) { Box(modifier = Modifier.fillMaxSize()) { + // The first-run screens draw over one shared brand-light backdrop that + // lives outside the destinations, so it keeps moving while they fade. + // Mirrored: TV copy sits on the left, so the light pools behind the card. + AnimatedVisibility( + visible = currentEntry?.destination?.route in tvMarqueeRoutes, + enter = fadeIn(tween(TvPageFadeDurationMs)), + exit = fadeOut(tween(TvPageFadeDurationMs)), + ) { + MarqueeBackdrop(mirrored = true) + } NavHost( navController = navController, startDestination = startDestination, @@ -660,8 +963,9 @@ fun TvAppNavigation( libraryPlaybackPrefsStore.clear() overlayPrefsStore.clear() cardPresentationStore.clear() - // Not seekIntervalStore.clear(): its identity flow already reset - // it for the new server and is hydrating; clearing would drop that. + // Not seekIntervalStore.clear() or episodeSpoilerStore.clear(): + // their identity flow already reset them for the new server + // and is hydrating; clearing would drop that. watchNextSeeder.clear() watchNextSeeder.seedNow() watchNextSeeder.enqueuePeriodic() @@ -689,12 +993,18 @@ fun TvAppNavigation( type = NavType.BoolType defaultValue = false }, + navArgument(TvRoute.Login.ARG_SESSION_EXPIRED) { + type = NavType.BoolType + defaultValue = false + }, ), ) { backStack -> val signupEnabled = backStack.arguments?.getBoolean(TvRoute.Login.ARG_SIGNUP_ENABLED) ?: false + val sessionExpired = backStack.arguments?.getBoolean(TvRoute.Login.ARG_SESSION_EXPIRED) ?: false TvSelectToShowImeHost { TvLoginScreen( signupEnabled = signupEnabled, + sessionExpired = sessionExpired, onCreateAccount = { navController.navigate(TvRoute.Signup.route) }, // Point this TV at a different server — drop Login so Back from // setup can't return to a credential form with no server bound. @@ -721,6 +1031,9 @@ fun TvAppNavigation( composable(TvRoute.ProfileSelection.route) { TvProfileSelectionScreen( onProfileSelected = { + // A chosen profile ends any away interval (prairie-apple + // `remember`), which lets Watch Next and held links through. + profileLaunchPreferences.clearBackgroundedAt() navController.navigate(TvRoute.Main.route) { popUpTo(TvRoute.ProfileSelection.route) { inclusive = true } } @@ -732,7 +1045,13 @@ fun TvAppNavigation( // The switch-profile paths cleared the seek intervals; the // identity flow only reloads when the profile id changes, so // re-selecting the same profile needs this. - scope.launch { seekIntervalStore.hydrateIfNeeded() } + scope.launch { + seekIntervalStore.hydrateIfNeeded() + // Same for spoiler protection, which must be back on + // before the new session's rows show episode stills. + episodeSpoilerStore.hydrateIfNeeded() + titleArtStore.hydrateIfNeeded() + } }, onAddProfile = { navController.navigate(TvRoute.CreateProfile.route) { launchSingleTop = true } @@ -780,6 +1099,8 @@ fun TvAppNavigation( } composable(TvRoute.Main.route) { mainEntry -> + val watchPartyPlayGuard = rememberTvWatchPartyPlayGuard() + TvWatchPartyPlayGuardDialog(watchPartyPlayGuard) val returnToManageServers = mainEntry.savedStateHandle.get(RETURN_TO_MANAGE_SERVERS_KEY) == true TvMainShell( @@ -809,12 +1130,12 @@ fun TvAppNavigation( episodeContentId = episodeContentId, ) }, - onOpenWatchTogether = { room -> - navController.navigateToTvWatchTogether(room, lastPlaybackNavigation) + onOpenWatchParty = { + navController.navigateToWatchParty(null, watchPartyRepository, lastPlaybackNavigation) }, - onOpenLibraryCollectionDetail = { libraryId, collectionId, title, libraryType -> + onOpenLibraryCollectionDetail = { libraryId, collectionId, title, libraryType, source, scope -> navController.navigate( - TvRoute.LibraryCollectionDetail(libraryId, collectionId, title, libraryType).route, + TvRoute.LibraryCollectionDetail(libraryId, collectionId, title, libraryType, source, scope).route, ) }, onOpenCollectionDetail = { collectionId, title -> @@ -844,6 +1165,8 @@ fun TvAppNavigation( overlayPrefsStore.clear() cardPresentationStore.clear() seekIntervalStore.clear() + episodeSpoilerStore.clear() + titleArtStore.clear() // Drop our Watch Next rows + cancel the periodic refresh so // the launcher doesn't keep showing the signed-out user's // progress. @@ -862,19 +1185,7 @@ fun TvAppNavigation( navController.navigate(TvRoute.ProfileSelection.route) { popUpTo(TvRoute.Main.route) { inclusive = true } } - profileRepository.clearProfile() - // Library/overlay prefs are per-profile — drop the caches - // so the next profile's prefs don't ghost-render the - // previous user's rows. Parity with the Settings - // switch-profile path. - libraryPlaybackPrefsStore.clear() - overlayPrefsStore.clear() - cardPresentationStore.clear() - seekIntervalStore.clear() - // Clear the previous profile's Watch Next rows before - // landing on the picker; the new profile will re-seed - // via [onProfileSelected]. - watchNextSeeder.clear() + activeProfileReset.clearActiveProfile() } }, // Android TV is now multi-server (parity with tvOS). "Switch @@ -890,19 +1201,24 @@ fun TvAppNavigation( launchSingleTop = true } }, + onPlayShuffle = { shuffle, libraryId -> + navController.navigateToTvShufflePlayback(shuffle, libraryId, lastPlaybackNavigation) + }, onPlayItem = { playContentId, itemType, resumePositionSeconds -> // A fast double Select otherwise stacks a second player, // starting two sessions and leaving Back on a duplicate. - navController.navigateToTvPlayback( - destination = tvPlayDestinationFor( - itemType = itemType, + watchPartyPlayGuard.requestPlay { + navController.navigateToTvPlayback( + destination = tvPlayDestinationFor( + itemType = itemType, + contentId = playContentId, + fileId = null, + resumePositionSeconds = resumePositionSeconds, + ), contentId = playContentId, - fileId = null, - resumePositionSeconds = resumePositionSeconds, - ), - contentId = playContentId, - lastPlaybackNavigation = lastPlaybackNavigation, - ) + lastPlaybackNavigation = lastPlaybackNavigation, + ) + } }, onOpenPersonDetail = { personId -> navController.navigate(TvRoute.PersonDetail(personId).route) { @@ -981,6 +1297,9 @@ fun TvAppNavigation( // actually binds to that version instead of always defaulting // to the server's first listed file (which for multi-version // titles is often the lower-resolution encode). + onShuffleStarted = { shuffle -> + navController.navigateToTvShufflePlayback(shuffle, libraryId, lastPlaybackNavigation) + }, onPlay = { playContentId, fileId, audioTrackIndex, audioPicked, subtitleSelection, itemType, resumePositionSeconds -> // A fast Select after entering detail can overlap the route // transition. Collapse an identical second Play request @@ -1044,8 +1363,8 @@ fun TvAppNavigation( onSeasonClick = { seriesId, selectedSeason -> navController.navigateToTvItemDetail(seriesId, selectedSeason, libraryId = libraryId) }, - onWatchTogether = { snapshot -> - navController.navigateToTvWatchTogether(snapshot, lastPlaybackNavigation) + onWatchParty = { destination -> + navController.navigateToWatchParty(destination, watchPartyRepository, lastPlaybackNavigation) }, onOpenPerson = { personId -> navController.navigate(TvRoute.PersonDetail(personId).route) { @@ -1126,8 +1445,7 @@ fun TvAppNavigation( defaultValue = null }, navArgument(TvRoute.Player.ARG_ROOM_ID) { - // Watch Together room binding; null for solo play. Consumed - // by TvPlayerScreen in T3. + // Watch Party room binding; null for solo play. type = NavType.StringType nullable = true defaultValue = null @@ -1172,8 +1490,36 @@ fun TvAppNavigation( nullable = true defaultValue = null }, + navArgument(TvRoute.Player.ARG_SHUFFLE_ID) { + type = NavType.StringType + nullable = true + defaultValue = null + }, + navArgument(TvRoute.Player.ARG_CAST_LAUNCH) { + type = NavType.StringType + nullable = true + defaultValue = null + }, ), ) { backStack -> + val castLaunchId = backStack.arguments?.getString(TvRoute.Player.ARG_CAST_LAUNCH) + // Acts only while this entry is on top: once a newer launch has + // replaced it, that navigation already tears this player down. + val leaveStaleCastLaunch: (stopPlayback: () -> Unit) -> Unit = { stopPlayback -> + if (navController.currentBackStackEntry?.id == backStack.id) { + stopPlayback() + navController.popBackStack() + } + } + // A phone's launch that went stale after navigation took it never + // creates its player, so nothing starts loading under another identity. + val castLaunchCurrent = remember(backStack.id) { + castLaunchId == null || siloCastReceiver.confirmLaunch(castLaunchId) + } + if (!castLaunchCurrent) { + LaunchedEffect(backStack.id) { leaveStaleCastLaunch {} } + return@composable + } val contentId = backStack.arguments ?.getString(TvRoute.Player.ARG_CONTENT_ID) .orEmpty() @@ -1213,6 +1559,19 @@ fun TvAppNavigation( targetContentId = contentId, ) } + // A party player restored with no live membership (process death) + // opens the hub, which offers Rejoin, instead of re-adopting. + val livePartyRoute = remember(backStack.id) { + roomId == null || tvHasLiveWatchParty(watchPartyRepository, roomId) + } + if (!livePartyRoute) { + LaunchedEffect(backStack.id) { + navController.navigate(TvRoute.WatchPartyHub().route) { + popUpTo(TvRoute.Player.ROUTE) { inclusive = true } + } + } + return@composable + } // TvPlayerViewModel starts loading in its initializer, which runs // while TvPlayerScreen's default parameters are evaluated. Bind // the playback display first so the very first capability probe @@ -1241,7 +1600,39 @@ fun TvAppNavigation( initialSubtitleAutoResolved = subtitleAutoResolved, autoAdvanceCount = autoAdvanceCount, episodeSelectionHandoff = episodeSelectionHandoff, + navigationSettled = !transition.isRunning, + shuffleId = backStack.arguments?.getString(TvRoute.Player.ARG_SHUFFLE_ID), + castLaunchId = castLaunchId, + onStaleCastLaunch = leaveStaleCastLaunch, onExit = { navController.popBackStack() }, + // Up Next On Deck pick: replace this player with the pick's. + // No resume override: the On Deck snapshot dates from when + // this item started, so the player reads the saved position. + onPlayOnDeckItem = { pickContentId, pickType -> + navController.navigateToTvPlayback( + destination = tvPlayDestinationFor( + itemType = pickType, + contentId = pickContentId, + fileId = null, + ), + contentId = pickContentId, + lastPlaybackNavigation = lastPlaybackNavigation, + ) + }, + // Host Stop: back to the room's lobby in place of the player. + // The membership is kept, so the lobby follows the next Start. + onReturnToWatchPartyLobby = { id -> + navController.navigate(TvRoute.WatchPartyLobby(id, hostStopped = true).route) { + popUpTo(TvRoute.Player.ROUTE) { inclusive = true } + } + }, + // The party ended under the player: the hub says why and + // offers Rejoin when the room may still be going. + onWatchPartyEnded = { + navController.navigate(TvRoute.WatchPartyHub(showEnded = true).route) { + popUpTo(TvRoute.Player.ROUTE) { inclusive = true } + } + }, ) } @@ -1276,25 +1667,85 @@ fun TvAppNavigation( } composable( - route = TvRoute.WatchTogetherLobby.ROUTE, + route = TvRoute.WatchPartyHub.ROUTE, arguments = listOf( - navArgument(TvRoute.WatchTogetherLobby.ARG_ROOM_ID) { type = NavType.StringType }, + navArgument(TvRoute.WatchPartyHub.ARG_SHOW_ENDED) { + type = NavType.BoolType + defaultValue = false + }, + ), + ) { backStack -> + TvWatchPartyHubScreen( + showEnded = backStack.arguments?.getBoolean(TvRoute.WatchPartyHub.ARG_SHOW_ENDED) == true, + onDestination = { destination -> + if (navController.currentBackStackEntry?.id == backStack.id) { + navController.navigateToWatchParty(destination, watchPartyRepository, lastPlaybackNavigation) + } + }, + onBack = { navController.popBackStack() }, + ) + } + + composable( + route = TvRoute.WatchPartyLobby.ROUTE, + arguments = listOf( + navArgument(TvRoute.WatchPartyLobby.ARG_ROOM_ID) { type = NavType.StringType }, + navArgument(TvRoute.WatchPartyLobby.ARG_HOST_STOPPED) { + type = NavType.BoolType + defaultValue = false + }, ), ) { backStack -> val roomId = backStack.arguments - ?.getString(TvRoute.WatchTogetherLobby.ARG_ROOM_ID) + ?.getString(TvRoute.WatchPartyLobby.ARG_ROOM_ID) ?: return@composable - TvWatchTogetherLobbyScreen( + // A lobby restored with no live membership (process death) opens + // the hub, which offers Rejoin, instead of re-adopting silently. + val live = remember(backStack.id) { tvHasLiveWatchParty(watchPartyRepository, roomId) } + if (!live) { + LaunchedEffect(backStack.id) { + navController.navigate(TvRoute.WatchPartyHub().route) { + popUpTo(TvRoute.WatchPartyLobby.ROUTE) { inclusive = true } + } + } + return@composable + } + // Only the entry on top may navigate: an exiting lobby still + // composed during its fade must not act on a late snapshot. + val isTop = { navController.currentBackStackEntry?.id == backStack.id } + TvWatchPartyLobbyScreen( roomId = roomId, - // The lobby computes the synced-player route from its snapshot - // (T2). We pop the lobby so Back from the player exits the room - // rather than returning to a stale lobby. - onNavigateToPlayer = { route -> - navController.navigate(route) { - popUpTo(TvRoute.WatchTogetherLobby.ROUTE) { inclusive = true } + hostStopped = backStack.arguments?.getBoolean(TvRoute.WatchPartyLobby.ARG_HOST_STOPPED) == true, + // The lobby is replaced by the player, so Back from the + // player never returns to a stale lobby. + onOpenPlayer = { destination -> + if (isTop()) { + val room = watchPartyRepository.roomSnapshot.value?.takeIf { it.roomId == destination.roomId } + val contentId = room?.selectedContentId?.takeIf { it.isNotBlank() } ?: destination.roomId + navController.navigate( + TvRoute.Player( + contentId = contentId, + fileId = room?.selectedFileId, + libraryId = room?.selectedLibraryId, + roomId = destination.roomId, + ).route, + ) { + popUpTo(TvRoute.WatchPartyLobby.ROUTE) { inclusive = true } + } } }, - onBack = { navController.popBackStack() }, + onOpenDetail = { contentId -> + if (isTop()) navController.navigateToTvItemDetail(contentId) + }, + onEnded = { + if (isTop()) { + navController.navigate(TvRoute.WatchPartyHub(showEnded = true).route) { + popUpTo(TvRoute.WatchPartyLobby.ROUTE) { inclusive = true } + } + } + }, + onLeft = { if (isTop()) navController.popBackStack() }, + onBack = { if (isTop()) navController.popBackStack() }, ) } @@ -1313,6 +1764,14 @@ fun TvAppNavigation( type = NavType.StringType defaultValue = "" }, + navArgument(TvRoute.LibraryCollectionDetail.ARG_MEDIA_SCOPE) { + type = NavType.StringType + defaultValue = "" + }, + navArgument(TvRoute.LibraryCollectionDetail.ARG_SOURCE) { + type = NavType.StringType + defaultValue = "library_collection" + }, navArgument(TvRoute.LibraryCollectionDetail.ARG_LIBRARY_TYPE) { type = NavType.StringType defaultValue = "" @@ -1332,14 +1791,22 @@ fun TvAppNavigation( ?.getString(TvRoute.LibraryCollectionDetail.ARG_LIBRARY_TYPE) .orEmpty() TvLibraryCollectionDetailScreen( + mediaScope = backStack.arguments?.getString(TvRoute.LibraryCollectionDetail.ARG_MEDIA_SCOPE)?.takeIf { it in setOf("movie", "series") }, + collectionSource = backStack.arguments?.getString(TvRoute.LibraryCollectionDetail.ARG_SOURCE) ?: "library_collection", libraryId = libraryId, collectionId = collectionId, title = title, libraryType = libraryType, + // Collection items can live in other libraries, and a + // library-scoped item read 404s for those. Open them, and + // shuffle picks, unscoped, like the web client. onItemClick = { contentId -> - navController.navigateToTvItemDetail(contentId, libraryId = libraryId) + navController.navigateToTvItemDetail(contentId) }, onBack = { navController.popBackStack() }, + onShuffleStarted = { shuffle -> + navController.navigateToTvShufflePlayback(shuffle, null, lastPlaybackNavigation) + }, ) } @@ -1366,6 +1833,9 @@ fun TvAppNavigation( navController.navigateToTvItemDetail(contentId) }, onBack = { navController.popBackStack() }, + onShuffleStarted = { shuffle -> + navController.navigateToTvShufflePlayback(shuffle, null, lastPlaybackNavigation) + }, ) } } @@ -1375,6 +1845,7 @@ fun TvAppNavigation( Modifier.align(androidx.compose.ui.Alignment.BottomCenter).fillMaxWidth(), ) + org.prairieserver.prairie.tv.ui.screens.profiles.TvHouseholdReverifyHost(profileRepository) siloCastStandby?.let { state -> TvPrairieCastStandbyView( state = state, @@ -1408,4 +1879,6 @@ fun TvAppNavigation( } } } + } + } } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvRoute.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvRoute.kt index fe606607f8..ce16bd703f 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvRoute.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvRoute.kt @@ -38,11 +38,16 @@ sealed class TvRoute(val route: String) { /** Invite-code signup, reachable from Login when the server allows signups. */ data object Signup : TvRoute("signup") - data class Login(val signupEnabled: Boolean = false) : - TvRoute("login?signupEnabled=$signupEnabled") { + /** + * [sessionExpired]: arrived because the session ended elsewhere (refresh + * failed); the screen explains it with a "You were signed out" banner. + */ + data class Login(val signupEnabled: Boolean = false, val sessionExpired: Boolean = false) : + TvRoute("login?signupEnabled=$signupEnabled&sessionExpired=$sessionExpired") { companion object { - const val ROUTE = "login?signupEnabled={signupEnabled}" + const val ROUTE = "login?signupEnabled={signupEnabled}&sessionExpired={sessionExpired}" const val ARG_SIGNUP_ENABLED = "signupEnabled" + const val ARG_SESSION_EXPIRED = "sessionExpired" } } @@ -103,7 +108,7 @@ sealed class TvRoute(val route: String) { * Playback route. An optional `fileId` query param lets the detail screen * pre-select a specific file version (e.g. 4K vs 1080p). When absent, the * player uses `auto` selection. An optional `roomId` query param binds the - * player to a Watch Together room (synced playback); absent for solo play. + * player to a Watch Party room (synced playback); absent for solo play. */ data class Player( val contentId: String, @@ -134,6 +139,10 @@ sealed class TvRoute(val route: String) { /** Opaque key for a process-only, target-bound episode selection handoff. */ val episodeSelectionHandoffNonce: String? = null, val libraryId: Int? = null, + /** The running shuffle this item is a pick of; picks play from the beginning. */ + val shuffleId: String? = null, + /** The phone launch this player is opened for, checked when the player registers. */ + val castLaunchId: String? = null, ) : TvRoute( buildString { append("player/${contentId.routeEncode()}") @@ -157,6 +166,8 @@ sealed class TvRoute(val route: String) { VideoPlayerRouteArgs.encodeResumePosition(resumePositionSeconds)?.let { value -> add("${VideoPlayerRouteArgs.RESUME_POSITION}=$value") } + shuffleId?.takeIf { it.isNotBlank() }?.let { add("$ARG_SHUFFLE_ID=${it.routeEncode()}") } + castLaunchId?.takeIf { it.isNotBlank() }?.let { add("$ARG_CAST_LAUNCH=${it.routeEncode()}") } } if (query.isNotEmpty()) append("?").append(query.joinToString("&")) }, @@ -167,7 +178,9 @@ sealed class TvRoute(val route: String) { "&subtitleTrackIndex={subtitleTrackIndex}" + "&subtitleAutoResolved={subtitleAutoResolved}" + "&autoAdvanceCount={autoAdvanceCount}&resumePosition={resumePosition}" + - "&episodeSelectionHandoffNonce={episodeSelectionHandoffNonce}" + "&episodeSelectionHandoffNonce={episodeSelectionHandoffNonce}" + + "&shuffleId={shuffleId}" + + "&castLaunch={castLaunch}" const val ARG_CONTENT_ID = "contentId" const val ARG_FILE_ID = "fileId" const val ARG_QUALITY = "quality" @@ -179,6 +192,8 @@ sealed class TvRoute(val route: String) { const val ARG_AUTO_ADVANCE_COUNT = "autoAdvanceCount" const val ARG_RESUME_POSITION = VideoPlayerRouteArgs.RESUME_POSITION const val ARG_EPISODE_SELECTION_HANDOFF_NONCE = "episodeSelectionHandoffNonce" + const val ARG_SHUFFLE_ID = "shuffleId" + const val ARG_CAST_LAUNCH = "castLaunch" } } @@ -214,15 +229,32 @@ sealed class TvRoute(val route: String) { } /** - * Watch Together lobby — the waiting/vote/pick surface for a room that has - * no selection yet. Reached from the entry dialog's Host/Join when the room - * snapshot carries no `selectedContentId`. + * Watch Party hub: availability, Return to Party, Rejoin, Host, and Join + * with a code. [showEnded] explains why the last party ended (the lobby + * and player open it this way when a party ends under them). + */ + data class WatchPartyHub(val showEnded: Boolean = false) : + TvRoute(if (showEnded) "watch_party/hub?$ARG_SHOW_ENDED=true" else "watch_party/hub") { + companion object { + const val ROUTE = "watch_party/hub?showEnded={showEnded}" + const val ARG_SHOW_ENDED = "showEnded" + } + } + + /** + * Watch Party lobby for one room. Only the room's phase moves it to the + * player. [hostStopped] marks a return from playback the host stopped. + * The room token is memory-only, so a restored lobby with no live + * membership redirects to the hub. */ - data class WatchTogetherLobby(val roomId: String) : - TvRoute("watch_together/lobby/${roomId.routeEncode()}") { + data class WatchPartyLobby(val roomId: String, val hostStopped: Boolean = false) : + TvRoute( + "watch_party/lobby/${roomId.routeEncode()}" + if (hostStopped) "?$ARG_HOST_STOPPED=true" else "", + ) { companion object { - const val ROUTE = "watch_together/lobby/{roomId}" + const val ROUTE = "watch_party/lobby/{roomId}?hostStopped={hostStopped}" const val ARG_ROOM_ID = "roomId" + const val ARG_HOST_STOPPED = "hostStopped" } } @@ -233,17 +265,21 @@ sealed class TvRoute(val route: String) { val title: String, /** Drives which sort keys and filter facets the page offers. */ val libraryType: String = "", + val collectionSource: String = "library_collection", + val mediaScope: String? = null, ) : TvRoute( "library/$libraryId/collection/${collectionId.routeEncode()}" + - "?title=${title.routeEncode()}&libraryType=${libraryType.routeEncode()}" + "?title=${title.routeEncode()}&libraryType=${libraryType.routeEncode()}&source=${collectionSource.routeEncode()}&mediaScope=${mediaScope.orEmpty().routeEncode()}" ) { companion object { const val ROUTE = - "library/{libraryId}/collection/{collectionId}?title={title}&libraryType={libraryType}" + "library/{libraryId}/collection/{collectionId}?title={title}&libraryType={libraryType}&source={collectionSource}&mediaScope={mediaScope}" const val ARG_LIBRARY_ID = "libraryId" const val ARG_COLLECTION_ID = "collectionId" const val ARG_TITLE = "title" const val ARG_LIBRARY_TYPE = "libraryType" + const val ARG_SOURCE = "collectionSource" + const val ARG_MEDIA_SCOPE = "mediaScope" } } @@ -319,7 +355,6 @@ sealed class TvMainRoute(val route: String) { data object Music : TvMainRoute("main/music") data object Audiobooks : TvMainRoute("main/audiobooks") data object Requests : TvMainRoute("main/requests") - data object MyRequests : TvMainRoute("main/requests/mine") data object LiveTv : TvMainRoute("main/livetv") data class LiveTvPlayer( val channelId: String, @@ -378,6 +413,9 @@ sealed class TvMainRoute(val route: String) { internal val TvRemovedRoutes: List = listOf( // Diagnostics became a Settings category rather than a top-level screen. "diagnostics", + // The Watch Together lobby became [TvRoute.WatchPartyLobby]. A restored + // room route has no live membership (the room token is memory-only). + "watch_together/lobby/{roomId}", ) /** Nested [TvRoute.Main] equivalents of [TvRemovedRoutes]. */ diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/audiobook/TvAudiobookPlayerScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/audiobook/TvAudiobookPlayerScreen.kt index c8d22b398e..4302f43555 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/audiobook/TvAudiobookPlayerScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/audiobook/TvAudiobookPlayerScreen.kt @@ -73,6 +73,7 @@ import androidx.media3.session.SessionToken import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import org.prairieserver.prairie.common.ui.components.DefaultArtworkKind import org.prairieserver.prairie.common.player.AudiobookPlayerViewModel import org.prairieserver.prairie.common.player.PrairiePlaybackService import org.prairieserver.prairie.model.catalog.VersionChapter @@ -363,6 +364,7 @@ fun TvAudiobookPlayerScreen( .size(metrics.coverSizeDp.dp) .shadow(metrics.coverShadowDp.dp, RoundedCornerShape(metrics.coverCornerRadiusDp.dp)), cornerRadius = metrics.coverCornerRadiusDp.dp, + defaultArtwork = DefaultArtworkKind.Audiobook, ) Spacer(Modifier.width(metrics.coverGapDp.dp)) Column( @@ -560,10 +562,7 @@ internal data class TvAudiobookPlayerMetrics( val transportPrimaryButtonWidthDp: Int, val transportPrimaryButtonHeightDp: Int, val transportButtonSpacingDp: Int, -) { - val transportRowWidthDp: Int = - (transportButtonSizeDp * 4) + transportPrimaryButtonWidthDp + (transportButtonSpacingDp * 4) -} +) internal fun tvAudiobookPlayerMetrics( maxWidthDp: Int, diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/QrCodePanel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/QrCodePanel.kt index 92c911608b..b578f0d651 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/QrCodePanel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/QrCodePanel.kt @@ -12,6 +12,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.google.zxing.BarcodeFormat @@ -33,6 +35,8 @@ fun QrCodePanel( foreground: Color = Color.Black, background: Color = Color.White, modifier: Modifier = Modifier, + /** Spoken label; the QR is otherwise invisible to TalkBack. */ + description: String? = null, ) { val matrix = remember(content) { val writer = QRCodeWriter() @@ -47,7 +51,14 @@ fun QrCodePanel( Box( modifier = modifier .size(size) - .background(background), + .background(background) + .then( + if (description != null) { + Modifier.semantics { contentDescription = description } + } else { + Modifier + }, + ), contentAlignment = Alignment.Center, ) { Canvas(modifier = Modifier.fillMaxSize()) { diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvLoginScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvLoginScreen.kt index 539fe488fd..f1729ccd4b 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvLoginScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvLoginScreen.kt @@ -1,13 +1,11 @@ package org.prairieserver.prairie.tv.ui.screens.auth -import androidx.compose.foundation.Image +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -15,78 +13,104 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Login +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material3.IconButton -import androidx.compose.material3.OutlinedTextField +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material.icons.outlined.Key +import androidx.compose.material.icons.outlined.Lan +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material.icons.outlined.VisibilityOff +import androidx.compose.material.icons.outlined.PhoneAndroid +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved -import org.prairieserver.prairie.tv.ui.focus.TvContentInitialFocusMaxAttempts -import org.prairieserver.prairie.tv.ui.focus.TvFocusLog +import androidx.compose.ui.autofill.ContentType +import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.InputMode -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalInputModeManager -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.contentType +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.LifecycleStartEffect import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Icon -import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -import org.prairieserver.prairie.repository.DeviceLoginRepository +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf +import org.prairieserver.prairie.common.pairing.PairingReceiver +import org.prairieserver.prairie.common.pairing.PairingReceiverStatus +import org.prairieserver.prairie.common.pairing.TvPairingAdvertiser +import org.prairieserver.prairie.common.ui.marquee.MarqueeColors +import org.prairieserver.prairie.common.ui.marquee.MarqueeScene +import org.prairieserver.prairie.common.ui.marquee.marqueeShake +import org.prairieserver.prairie.common.ui.marquee.SignInHandoff +import org.prairieserver.prairie.model.auth.SignInProvider import org.prairieserver.prairie.tv.R -import org.prairieserver.prairie.tv.ui.components.AuroraEyebrow -import org.prairieserver.prairie.tv.ui.components.AuroraGhostButton -import org.prairieserver.prairie.tv.ui.components.AuroraJourneyProgress -import org.prairieserver.prairie.tv.ui.components.AuroraPrimaryButton -import org.prairieserver.prairie.tv.ui.components.AuroraStepRow -import org.prairieserver.prairie.tv.ui.components.auroraGlass -import org.prairieserver.prairie.tv.ui.components.auroraPanel -import org.prairieserver.prairie.tv.ui.components.TvAuroraBackdrop -import org.prairieserver.prairie.tv.ui.components.TvAuroraVariant -import org.prairieserver.prairie.tv.ui.components.TvHeroActionPill -import org.prairieserver.prairie.tv.ui.components.TvPillVariant +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeBody +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeButton +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeButtonKind +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeCard +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeCardSymbol +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeCodeTiles +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeErrorText +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeField +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeHeadline +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeMetrics +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeScreen +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeServerCard +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeStatusChip +import org.prairieserver.prairie.tv.ui.components.marquee.TvUsePhoneCard +import org.prairieserver.prairie.tv.ui.components.marquee.rememberTvActiveServer import org.prairieserver.prairie.tv.ui.components.rememberTvImeAwareFormScrollState import org.prairieserver.prairie.tv.ui.components.tvImeAwareFieldContext -import org.prairieserver.prairie.tv.ui.components.tvShowImeOnSelect -import org.prairieserver.prairie.tv.ui.components.TvAuthFormDefaults -import org.prairieserver.prairie.tv.ui.components.tvOutlinedTextFieldColors -import org.prairieserver.prairie.tv.ui.theme.Spacing -import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.tv.ui.focus.TvContentInitialFocusMaxAttempts +import org.prairieserver.prairie.tv.ui.focus.TvFocusLog +import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved /** - * Sign-in form — compact, TOP-anchored so the username/password fields stay - * above the on-screen IME. See [TvServerSetupScreen] for the rationale: on - * Android TV the soft keyboard eats the lower half of the viewport, so any - * centered form hides its inputs. + * TV sign-in: every way in is visible at once — scan the QR, type + * `/activate` and the code, or open Prairie on a nearby phone — with the + * password one click away. One copy deck with Apple TV (server spec "TV + * device sign-in UX"). The code renews itself while the screen is visible; + * there is no countdown. When the TV reached the server through a network + * provider such as Tailscale, "Continue as " leads and takes focus: + * one press signs in, and every other way in stays. + * + * TOP-anchored so the username/password fields stay above the on-screen IME: + * on Android TV the soft keyboard eats the lower half of the viewport. */ @OptIn(ExperimentalTvMaterial3Api::class) @Composable @@ -95,238 +119,667 @@ fun TvLoginScreen( onCreateAccount: () -> Unit = {}, onChangeServer: () -> Unit = {}, signupEnabled: Boolean = false, - viewModel: TvLoginViewModel = koinViewModel(), + sessionExpired: Boolean = false, + viewModel: TvLoginViewModel = koinViewModel(parameters = { parametersOf(sessionExpired) }), + pairingReceiver: PairingReceiver = koinInject(), + pairingAdvertiser: TvPairingAdvertiser = koinInject(), ) { val state by viewModel.uiState.collectAsState() - val deviceState by viewModel.deviceLoginState.collectAsState() + val device by viewModel.deviceSignIn.collectAsState() + val advertisement by viewModel.pairingAdvertisement.collectAsState() + val pairingStatus by pairingReceiver.status.collectAsState() val usernameFocus = remember { FocusRequester() } val passwordFocus = remember { FocusRequester() } val usePasswordFocus = remember { FocusRequester() } + val changeServerFocus = remember { FocusRequester() } + val actionFocus = remember { FocusRequester() } val signInFocus = remember { FocusRequester() } val createAccountFocus = remember { FocusRequester() } val backToPhoneFocus = remember { FocusRequester() } - val changeServerFocus = remember { FocusRequester() } + val formChangeServerFocus = remember { FocusRequester() } + val networkFocus = remember { FocusRequester() } val formScrollState = rememberTvImeAwareFormScrollState() - // Phone-first IA (mirrors tvOS TVLoginView): the QR device-login leads, and - // the username/password form is one focus-step away behind "Use a password - // instead". Nothing to type on the remote unless the viewer opts in. + // Phone-first IA (mirrors tvOS TVLoginView): the device code leads, and the + // username/password form is one focus-step away. A server without device + // sign-in goes straight to the form. var showPasswordForm by remember { mutableStateOf(false) } + // An OIDC-only server (local passwords off, no directory) takes no + // password from this TV: device sign-in alone. + val passwordFormVisible = (showPasswordForm || device.passwordOnly) && state.passwordAvailable + // Password sign-in turned off while the form was open (the options are + // re-read on return to this screen): the TV falls back to the code, and + // the form doesn't come back by itself if passwords are turned on again. + LaunchedEffect(state.passwordAvailable) { + if (!state.passwordAvailable) showPasswordForm = false + } + + // Stop polling in the background (or under the screensaver); poll at once, + // renewing the code if it expired, when the screen is visible again. + LifecycleStartEffect(viewModel) { + viewModel.onStart() + onStopOrDispose { viewModel.onStop() } + } + + // A signed-out TV advertises st=login with its server's identity so a + // nearby phone that holds the same server can sign it in (#419). Only + // while the screen is visible, like polling: nobody can answer the + // consent prompt of a TV in the background. + LifecycleStartEffect(advertisement) { + val token = advertisement?.let(pairingAdvertiser::start) + onStopOrDispose { token?.let(pairingAdvertiser::stop) } + } + val isActivePairing = pairingStatus.isActivePairing + + // The sign-in screen and a nearby phone's session can both finish the + // same sign-in; route on once. + var routed by remember { mutableStateOf(false) } + val routeOnce = { + if (!routed) { + routed = true + // A one-profile household with no PIN goes straight to Home. + SignInHandoff.skipsSingleProfilePicker = true + onLoginSuccess() + } + } + LaunchedEffect(pairingStatus) { + if (pairingStatus is PairingReceiverStatus.Completed) { + delay(1_800) + routeOnce() + } + } LaunchedEffect(state.loginSuccess) { if (state.loginSuccess) { + // A nearby phone that approved this code still has to hear the + // result and say done; leaving first closes its session, which it + // reports as a failure. Completed routes on by itself after its + // dwell. Bounded, so a phone that goes quiet can't hold the TV here. + val settled = withTimeoutOrNull(NEARBY_RESULT_WAIT_MS) { + pairingReceiver.status.first { + it !is PairingReceiverStatus.AwaitingApproval && it !is PairingReceiverStatus.SignedIn + } + } + // Consumed only now: it is this effect's key, so clearing it + // before the wait would cancel the wait and the fallback route. viewModel.onLoginSuccessConsumed() - onLoginSuccess() + if (settled !is PairingReceiverStatus.Completed) routeOnce() + } + } + + // "Use your phone instead" returns to a fresh code, not one that may have + // gone stale behind the form. + val backToPhone = { + showPasswordForm = false + viewModel.restartDeviceLogin() + } + // Back steps out of the password form and the nearby-phone panel instead + // of leaving the app. + BackHandler(enabled = passwordFormVisible && !device.passwordOnly && !isActivePairing, onBack = backToPhone) + BackHandler(enabled = isActivePairing, onBack = pairingReceiver::cancelActiveSession) + + // A server without device sign-in opens on the password form, so + // "Continue as …" shows there too, above the fields (Apple TV parity). + // It has its own focus key there, so focus is claimed again when the + // button moves from the code screen into the form. + val networkInForm = device.passwordOnly && state.networkProvider != null + val networkKey = if (passwordFormVisible) "formNetwork" else "network" + // "Continue as …" is the one-press way in, so it takes focus whenever the + // server offers it, unless it was refused and the code needs the person: + // then that recovery action leads again. + val networkLeads = state.networkProvider != null && + (state.networkError == null || !device.status.actionTakesFocus()) + + // After "Continue as …", focus moves to the state's action when the code + // needs the person ("Try again" / "Show a new code"), to "Change server" + // when only that helps, otherwise to the one local action, "Sign in with a + // password". "Can't reach" and "Too many requests" keep retrying by + // themselves, so their "Try again" doesn't take focus (Apple TV parity). + val focusTarget = when { + passwordFormVisible -> if (networkInForm && networkLeads) networkKey else "username" + networkLeads -> networkKey + device.status.actionTakesFocus() -> "action" + device.status == TvSignInStatus.UpdateRequired -> "changeServer" + device.status == TvSignInStatus.Unreachable || device.status == TvSignInStatus.TooManyRequests -> null + state.passwordAvailable -> "usePassword" + else -> "changeServer" + } + // Which claim target holds focus. Checking "the screen has focus" is not + // enough: after a state change focus is still on the old button, and the + // claim must move it to the state's action. + var focusedKey by remember { mutableStateOf(null) } + val trackFocus: (String) -> Modifier = { key -> + Modifier.onFocusChanged { state -> + if (state.hasFocus) focusedKey = key else if (focusedKey == key) focusedKey = null } } - // Default focus follows the active surface: the password form focuses the - // username field; the phone-first surface focuses the "Use a password - // instead" affordance so the remote never lands on a non-actionable QR. - var loginSurfaceHasFocus by remember { mutableStateOf(false) } - // Snapshot-backed: recomposes (and re-keys the claim below) when the viewer - // switches between pointer and key input. val inputMode = LocalInputModeManager.current.inputMode - LaunchedEffect(showPasswordForm, inputMode) { - // Acquisition on both branches: the surface has just swapped, so - // nothing on it holds focus yet. A dropped claim on the phone-first - // branch strands the remote on a QR code that cannot be actioned. - // + LaunchedEffect(focusTarget, inputMode, isActivePairing) { // Touch/mouse exception: nothing is auto-focused for pointer users // (product call 2026-08-14) — a programmatic claim on a text field in - // touch mode pops the IME despite showKeyboardOnFocus=false, and - // buttons refuse focus in touch mode anyway, so the claim would only - // burn its retry budget. Keying this effect on the input mode re-runs - // the claim the moment a key press flips the mode back, so the D-pad - // always has somewhere to land. - if (inputMode == InputMode.Touch) { - TvFocusLog.d { "login: claim skipped (touch mode, form=$showPasswordForm)" } + // touch mode pops the IME, and buttons refuse focus in touch mode. + if (inputMode == InputMode.Touch || isActivePairing || focusTarget == null) { + TvFocusLog.d { "login: claim skipped (touch=${inputMode == InputMode.Touch}, pairing=$isActivePairing, target=$focusTarget)" } return@LaunchedEffect } - val target = if (showPasswordForm) usernameFocus else usePasswordFocus - TvFocusLog.d { - "login: claiming ${if (showPasswordForm) "username field" else "'use password' button"} (mode=$inputMode)" + val target = when (focusTarget) { + "username" -> usernameFocus + "network", "formNetwork" -> networkFocus + "action" -> actionFocus + "changeServer" -> changeServerFocus + else -> usePasswordFocus } + TvFocusLog.d { "login: claiming $focusTarget (mode=$inputMode)" } val result = requestFocusUntilObserved( maxAttempts = TvContentInitialFocusMaxAttempts, awaitAttempt = { withFrameNanos { } }, requestFocus = target::requestFocus, - isFocused = { loginSurfaceHasFocus }, + isFocused = { focusedKey == focusTarget }, ) TvFocusLog.d { "login: claim result=$result" } - // The form arrives quiet — the viewer summons the keyboard with SELECT - // or a click. The legacy text field pops the IME on a focus arrival no - // matter what showKeyboardOnFocus says (unsupported on this overload, - // see tvShowImeOnSelect), so suppression lives in that modifier, which - // every field in this flow carries. No screen-level hide needed here. } + val changeServer = { + viewModel.onChangeServer() + onChangeServer() + } + val serverName = state.serverName ?: state.serverHost.orEmpty() + val network = state.networkProvider?.let { provider -> + NetworkSignInAction( + provider = provider, + busy = state.networkBusy, + error = state.networkError?.message(providerName = null, externalName = provider.displayName), + focusRequester = networkFocus, + focusTracking = trackFocus(networkKey), + onClick = viewModel::onNetworkSignInClick, + ) + } + + val server = rememberTvActiveServer() + LaunchedEffect(Unit) { + MarqueeScene.focus = MarqueeScene.Focus.Account + MarqueeScene.personalTint = null + } + val displayName = server?.name ?: serverName + // No code and no password on this server: "Continue as …" stands alone. + val networkOnly = device.passwordOnly && !state.passwordAvailable && network != null + Box( modifier = Modifier .fillMaxSize() - // Either branch's target lives under this root, so "focus is on the - // login surface" is the criterion both claims are protecting. - .onFocusChanged { loginSurfaceHasFocus = it.hasFocus } .imePadding(), ) { - TvAuroraBackdrop(variant = TvAuroraVariant.SignIn) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .align(Alignment.TopCenter) - .fillMaxSize() - // The scroll is an IME/odd-surface safety valve only. At the - // reference TV surface (1920x1080 @ 320dpi = 960x540dp) BOTH - // branches must measure shorter than the viewport, because a - // scrolled column takes the brand mark and the SERVER/ACCOUNT/ - // PROFILE step chrome off the top with no D-pad way back — the - // header is not focusable, so nothing can scroll it into view. - // The credential branch is budgeted for this in - // [CredentialFormCard]; keep it that way. - .verticalScroll(formScrollState) - // Vertical padding sits at the overscan floor (Spacing - // .safeAreaVertical) on both branches — dipping under it to buy - // room for a too-tall form just trades a scroll for a bezel - // clip on real hardware. - .padding( - top = Spacing.safeAreaVertical, - bottom = Spacing.safeAreaVertical, - start = 54.dp, - end = 54.dp, - ), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - BrandHeader() - AuroraJourneyProgress( - currentStep = 2, - modifier = Modifier.width(215.dp), + if (isActivePairing) { + ActivePairingPanel( + status = pairingStatus, + onCancel = pairingReceiver::cancelActiveSession, + onContinue = routeOnce, + onAllow = pairingReceiver::allowPendingServer, + onDeny = pairingReceiver::denyPendingServer, + onUseAlternate = pairingReceiver::useAlternateAddress, + onRetryAddress = pairingReceiver::retryPushedAddress, + signIn = true, + ) + return@Box + } + + val notices: @Composable () -> Unit = { + if (state.sessionExpired && displayName.isNotBlank()) { + TvMarqueeStatusChip( + stringResource(R.string.tv_signin_session_expired, displayName), + icon = Icons.Outlined.ErrorOutline, + modifier = Modifier.padding(bottom = 15.dp).semantics { liveRegion = LiveRegionMode.Polite }, ) } + // A password refused because password sign-in is off closes the + // form, the only place its error shows: say it here instead. + if (state.passwordTurnedOff) { + TvMarqueeStatusChip( + stringResource(R.string.tv_signin_error_local_login_disabled), + icon = Icons.Outlined.ErrorOutline, + modifier = Modifier.padding(bottom = 15.dp).semantics { liveRegion = LiveRegionMode.Polite }, + ) + } + TvMarqueeServerCard( + name = displayName, + address = server?.hostLabel ?: state.serverHost.orEmpty(), + markUrl = server?.branding?.markUrl, + secure = server?.isSecure ?: false, + ) + } - Spacer(modifier = Modifier.height(Spacing.sm)) - - AuroraEyebrow(text = "Account") - Spacer(modifier = Modifier.height(Spacing.md)) + when { + passwordFormVisible -> PasswordScreen( + state = state, + passwordOnly = device.passwordOnly, + serverName = displayName, + notices = notices, + usernameFocus = usernameFocus, + usernameFocusTracking = trackFocus("username"), + passwordFocus = passwordFocus, + signInFocus = signInFocus, + createAccountFocus = createAccountFocus, + backToPhoneFocus = backToPhoneFocus, + changeServerFocus = formChangeServerFocus, + onUsernameChanged = viewModel::onUsernameChanged, + onPasswordChanged = viewModel::onPasswordChanged, + onLoginClick = viewModel::onLoginClick, + signupEnabled = signupEnabled, + onCreateAccount = onCreateAccount, + onBackToPhone = backToPhone, + onChangeServer = changeServer, + network = network.takeIf { networkInForm }, + ) - if (showPasswordForm) { - CredentialFormCard( - state = state, - usernameFocus = usernameFocus, - passwordFocus = passwordFocus, - signInFocus = signInFocus, - createAccountFocus = createAccountFocus, - backToPhoneFocus = backToPhoneFocus, - changeServerFocus = changeServerFocus, - onUsernameChanged = viewModel::onUsernameChanged, - onPasswordChanged = viewModel::onPasswordChanged, - onLoginClick = viewModel::onLoginClick, - signupEnabled = signupEnabled, - onCreateAccount = onCreateAccount, - onBackToPhone = { showPasswordForm = false }, - onChangeServer = onChangeServer, - // Wider than the old 400dp: the 960dp-wide surface has - // horizontal room to spare, and spending it lets the three - // secondary actions share one row instead of stacking - // three deep down the 540dp axis. - modifier = Modifier.width(520.dp), - ) - } else { - Row( - horizontalArrangement = Arrangement.spacedBy(44.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .widthIn(max = 840.dp) - .fillMaxWidth(), - ) { - PhoneSignInHero( - state = deviceState, - modifier = Modifier.width(430.dp), + networkOnly -> TvMarqueeScreen( + copy = { + notices() + TvMarqueeHeadline(stringResource(R.string.tv_signin_title, displayName), Modifier.padding(top = 22.dp)) + network?.let { NetworkSignInButton(it, below = changeServerFocus, modifier = Modifier.padding(top = 20.dp)) } + TvMarqueeButton( + text = stringResource(R.string.tv_signin_action_change_server), + onClick = changeServer, + kind = TvMarqueeButtonKind.Plain, + focusRequester = changeServerFocus, + modifier = Modifier + .padding(top = 15.dp) + .then(trackFocus("changeServer")) + .focusProperties { network?.let { up = it.focusRequester } }, ) + }, + card = { + TvMarqueeCard { + TvMarqueeCardSymbol(Icons.Outlined.Lan) + Text( + "No code needed", + color = MarqueeColors.Ink, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 17.dp), + ) + TvMarqueeBody( + "This TV reached $displayName through ${network?.provider?.displayName ?: "its network"}, " + + "which knows who it belongs to.", + size = 14.sp, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 7.dp), + ) + } + }, + ) - QrLoginCard( - state = deviceState, - onRetry = viewModel::restartDeviceLogin, - onUsePassword = { showPasswordForm = true }, - onChangeServer = onChangeServer, + else -> TvMarqueeScreen( + copy = { + notices() + TvMarqueeHeadline(stringResource(R.string.tv_signin_title, displayName), Modifier.padding(top = 22.dp)) + if (network != null) { + NetworkSignInButton( + action = network, + // The copy between holds nothing focusable: name the way down. + below = codeActionBelowNetwork(device.status, state.passwordAvailable, actionFocus, usePasswordFocus, changeServerFocus), + modifier = Modifier.padding(top = 16.dp), + ) + Text( + "Or sign in with your phone", + color = MarqueeColors.InkTertiary, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(top = 15.dp), + ) + } + val activate = device.code?.activateText ?: state.serverActivateText.orEmpty() + TvMarqueeBody( + "Scan the code with your phone's camera, or go to " + + (activate.ifBlank { "your server's /activate page" }) + + " and enter it. Approve on your phone and this TV signs in by itself.", + modifier = Modifier.padding(top = if (network == null) 13.dp else 6.dp), + ) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 12.dp), + ) { + Icon(Icons.Outlined.PhoneAndroid, contentDescription = null, tint = MarqueeColors.InkTertiary, modifier = Modifier.size(13.dp)) + Text(stringResource(R.string.tv_signin_nearby_hint), color = MarqueeColors.InkTertiary, fontSize = 14.sp) + } + CodeActions( + status = device.status, + showPasswordAction = state.passwordAvailable, + network = network, + actionFocus = actionFocus, + actionTracking = trackFocus("action"), usePasswordFocus = usePasswordFocus, - modifier = Modifier.width(320.dp), + usePasswordTracking = trackFocus("usePassword"), + changeServerFocus = changeServerFocus, + changeServerTracking = trackFocus("changeServer"), + onAction = viewModel::restartDeviceLogin, + // The form would hide "Signing in…" while "Continue as …" + // waits, and its Sign in would do nothing until then. + onUsePassword = { if (!state.networkBusy) showPasswordForm = true }, + onChangeServer = changeServer, + modifier = Modifier.padding(top = 24.dp), ) - } - } + }, + card = { TvMarqueeCard { CodeCard(device = device, serverHost = state.serverHost.orEmpty()) } }, + ) } - } } +/** Where Down from "Continue as …" lands on the code screen: the first local action. */ +private fun codeActionBelowNetwork( + status: TvSignInStatus, + passwordAvailable: Boolean, + actionFocus: FocusRequester, + usePasswordFocus: FocusRequester, + changeServerFocus: FocusRequester, +): FocusRequester = when { + status.action() != null -> actionFocus + passwordAvailable && status != TvSignInStatus.UpdateRequired -> usePasswordFocus + else -> changeServerFocus +} + +/** The "Try again" / "Show a new code" action a status offers, if any. */ +private fun TvSignInStatus.action(): Int? = when (this) { + TvSignInStatus.CouldntFinish, + TvSignInStatus.Failed, + TvSignInStatus.Unreachable, + TvSignInStatus.TooManyRequests, + -> R.string.tv_signin_action_try_again + TvSignInStatus.Denied, + TvSignInStatus.Paused, + -> R.string.tv_signin_action_new_code + else -> null +} + +/** States that wait for the person, so their action takes focus. */ +private fun TvSignInStatus.actionTakesFocus(): Boolean = when (this) { + TvSignInStatus.CouldntFinish, + TvSignInStatus.Denied, + TvSignInStatus.Paused, + TvSignInStatus.Failed, + -> true + else -> false +} + /** - * Left-hand hero for the phone-first sign-in: eyebrow already sits above; this - * is the headline, the lede, the three numbered steps, and a live "waiting" - * status while the device-login session is pending. Mirrors tvOS - * `TVLoginView.heroColumn`. + * "Continue as " through a network provider, for the code screen and, + * on a server without device sign-in, the password screen. [error] is why the + * last press was refused. + */ +private class NetworkSignInAction( + val provider: SignInProvider, + val busy: Boolean, + val error: String?, + val focusRequester: FocusRequester, + val focusTracking: Modifier, + val onClick: () -> Unit, +) + +/** + * The state's action ("Try again" / "Show a new code"), "Sign in with a + * password" and "Change server" on one row, as on Apple TV. Controls stay + * enabled while work runs so focus doesn't drop; the actions ignore repeats. */ -@OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun PhoneSignInHero( - state: DeviceLoginRepository.DeviceLoginState, +private fun CodeActions( + status: TvSignInStatus, + showPasswordAction: Boolean, + network: NetworkSignInAction?, + actionFocus: FocusRequester, + actionTracking: Modifier, + usePasswordFocus: FocusRequester, + usePasswordTracking: Modifier, + changeServerFocus: FocusRequester, + changeServerTracking: Modifier, + onAction: () -> Unit, + onUsePassword: () -> Unit, + onChangeServer: () -> Unit, modifier: Modifier = Modifier, ) { - Column( - verticalArrangement = Arrangement.spacedBy(Spacing.md), - modifier = modifier, - ) { - Text( - text = "Scan. Confirm.\nStart watching.", - style = TvLoginTextStyles.Hero, - color = MaterialTheme.colorScheme.onBackground, - ) - Text( - text = "Open your phone's Camera and point it at the code. " + - "You won't need to type a password on your TV.", - style = TvLoginTextStyles.Body, - color = MaterialTheme.colorScheme.onSurfaceVariant, + val upTo = network?.focusRequester + // An update requirement hides the password button: no password can help. + val offersPassword = showPasswordAction && status != TvSignInStatus.UpdateRequired + Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(11.dp)) { + status.action()?.let { label -> + TvMarqueeButton( + text = stringResource(label), + onClick = onAction, + icon = Icons.Filled.Refresh, + focusRequester = actionFocus, + modifier = Modifier + .then(actionTracking) + .focusProperties { upTo?.let { up = it } }, + ) + } + if (offersPassword) { + TvMarqueeButton( + text = stringResource(R.string.tv_signin_action_password), + onClick = onUsePassword, + kind = TvMarqueeButtonKind.Glass, + icon = Icons.Outlined.Key, + focusRequester = usePasswordFocus, + modifier = Modifier + .then(usePasswordTracking) + .focusProperties { + right = changeServerFocus + upTo?.let { up = it } + }, + ) + } + TvMarqueeButton( + text = stringResource(R.string.tv_signin_action_change_server), + onClick = onChangeServer, + kind = TvMarqueeButtonKind.Plain, + focusRequester = changeServerFocus, + modifier = Modifier + .then(changeServerTracking) + .focusProperties { + if (offersPassword) left = usePasswordFocus + upTo?.let { up = it } + }, ) - Spacer(Modifier.height(Spacing.sm)) - AuroraStepRow(number = 1, text = "Scan with your phone's camera") - AuroraStepRow(number = 2, text = "Confirm the matching number") - AuroraStepRow(number = 3, text = "Approve on your phone — you're in") + } +} - if (state is DeviceLoginRepository.DeviceLoginState.Awaiting) { - Spacer(Modifier.height(Spacing.sm)) - Text( - text = "Waiting for approval…", - style = TvLoginTextStyles.Body, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) +/** + * "Continue as " with "via " under it, or "Continue with + * " when the provider named nobody: the screen's primary action + * when offered, with its refusal under it. It stays focusable while signing + * in so focus doesn't jump; the view model ignores a second press. + */ +@Composable +private fun NetworkSignInButton(action: NetworkSignInAction, below: FocusRequester, modifier: Modifier = Modifier) { + val providerName = action.provider.displayName + val owner = action.provider.networkIdentity?.label + val title = when { + action.busy -> stringResource(R.string.tv_signin_form_submitting) + owner != null -> stringResource(R.string.tv_signin_network_continue_as, owner) + else -> stringResource(R.string.tv_signin_network_continue_with, providerName) + } + val via = owner?.takeIf { !action.busy }?.let { stringResource(R.string.tv_signin_network_via, providerName) } + Column(modifier, verticalArrangement = Arrangement.spacedBy(7.dp)) { + TvMarqueeButton( + onClick = action.onClick, + isLoading = action.busy, + focusRequester = action.focusRequester, + modifier = Modifier + .height(if (via != null) 52.dp else TvMarqueeMetrics.ButtonHeight) + .then(action.focusTracking) + .focusProperties { down = below }, + ) { ink -> + if (!action.busy) TvProviderMark(action.provider) + Column { + Text(title, color = ink, maxLines = 1) + if (via != null) Text(via, color = ink.copy(alpha = 0.7f), fontSize = 14.sp, fontWeight = FontWeight.Medium, maxLines = 1) + } + } + action.error?.let { error -> + TvMarqueeErrorText(error, Modifier.width(380.dp).semantics { liveRegion = LiveRegionMode.Polite }) } } } -@OptIn(ExperimentalTvMaterial3Api::class) +/** A provider's initial on a warm gradient, beside "Continue as …". */ +@Composable +private fun TvProviderMark(provider: SignInProvider) { + Box( + Modifier + .size(24.dp) + .clip(RoundedCornerShape(6.dp)) + .background(Brush.linearGradient(listOf(Color(0xFFFD4B2D), Color(0xFFA21D5C)))), + contentAlignment = Alignment.Center, + ) { + Text(provider.displayName.firstOrNull()?.lowercase() ?: "s", color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.ExtraBold) + } +} + +/** + * The card: the QR (dark on white, quiet zone), the code as tiles, the + * typed-URL form, and a polite live status line. No countdown: codes renew + * in place. + */ @Composable -private fun BrandHeader() { +private fun CodeCard(device: TvDeviceSignInUi, serverHost: String) { + val code = device.code + when { + code != null -> { + val qrHost = code.activateText.removeSuffix("/activate") + Box( + Modifier + .clip(RoundedCornerShape(12.dp)) + .background(Color.White) + .padding(10.dp), + ) { + QrCodePanel( + content = code.qrContent, + size = QrSize, + description = stringResource(R.string.tv_signin_qr_description, qrHost, code.spokenCode), + ) + } + TvMarqueeCodeTiles(code = code.userCode, modifier = Modifier.padding(top = 17.dp)) + Text( + code.activateText, + color = MarqueeColors.InkSecondary, + fontSize = 14.sp, + maxLines = 2, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 9.dp), + ) + } + device.status == TvSignInStatus.SignedIn -> TvMarqueeCardSymbol(Icons.Filled.Check, tint = MarqueeColors.Live, size = 70.dp) + device.status.isProblem() -> TvMarqueeCardSymbol(Icons.Outlined.ErrorOutline, tint = MarqueeColors.Warning) + else -> Box( + Modifier + .size(QrSize + 20.dp) + .clip(RoundedCornerShape(12.dp)) + .background(Color.White), + contentAlignment = Alignment.Center, + ) { + if (device.status == TvSignInStatus.GettingCode) { + CircularProgressIndicator(color = Color.Black.copy(alpha = 0.5f), strokeWidth = 2.dp, modifier = Modifier.size(28.dp)) + } + } + } Row( + horizontalArrangement = Arrangement.spacedBy(7.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Spacing.sm), + modifier = Modifier + .padding(top = 12.dp) + .semantics(mergeDescendants = true) { liveRegion = LiveRegionMode.Polite }, ) { - Image( - painter = painterResource(id = R.drawable.prairie_wordmark), - contentDescription = "Prairie", - modifier = Modifier - .width(66.dp) - .height(35.dp), - contentScale = ContentScale.Fit, + if (device.status == TvSignInStatus.Waiting || device.status == TvSignInStatus.Opened || device.status == TvSignInStatus.GettingCode) { + CircularProgressIndicator(color = MarqueeColors.InkSecondary, strokeWidth = 2.dp, modifier = Modifier.size(13.dp)) + } + Text( + device.statusText(serverHost), + color = if (device.status.isProblem()) MarqueeColors.Error else MarqueeColors.InkSecondary, + fontSize = 14.sp, + textAlign = TextAlign.Center, ) } } -@OptIn(ExperimentalTvMaterial3Api::class) +private fun TvSignInStatus.isProblem(): Boolean = when (this) { + TvSignInStatus.CouldntFinish, + TvSignInStatus.Denied, + TvSignInStatus.Unreachable, + TvSignInStatus.TooManyRequests, + TvSignInStatus.Failed, + TvSignInStatus.UpdateRequired, + -> true + else -> false +} + +@Composable +private fun TvDeviceSignInUi.statusText(serverHost: String): String = when (status) { + TvSignInStatus.GettingCode -> stringResource(R.string.tv_signin_status_getting_code) + TvSignInStatus.Waiting -> stringResource(R.string.tv_signin_status_waiting) + TvSignInStatus.Opened -> stringResource(R.string.tv_signin_status_opened) + TvSignInStatus.NewCode -> stringResource(R.string.tv_signin_status_new_code) + TvSignInStatus.Unreachable -> stringResource(R.string.tv_signin_status_unreachable, serverHost) + TvSignInStatus.TooManyRequests -> stringResource(R.string.tv_signin_status_too_many) + TvSignInStatus.SignedIn -> accountName?.let { stringResource(R.string.tv_signin_status_signed_in_as, it) } + ?: stringResource(R.string.tv_signin_status_signed_in) + TvSignInStatus.CouldntFinish -> stringResource(R.string.tv_signin_status_couldnt_finish) + TvSignInStatus.Denied -> stringResource(R.string.tv_signin_status_denied) + TvSignInStatus.Paused -> stringResource(R.string.tv_signin_status_paused) + TvSignInStatus.Failed -> stringResource(R.string.tv_signin_status_failed) + TvSignInStatus.UpdateRequired -> stringResource(R.string.tv_signin_status_update_required) +} + +/** + * [providerName] is the server's browser sign-in provider: an account that + * signs in with it has no password here, so a refused password points to the + * phone. [externalName] names the provider the refused attempt went through: + * the directory (LDAP) the form also reaches, or the network provider behind + * "Continue as …". + */ @Composable -private fun CredentialFormCard( +private fun TvLoginError.message(providerName: String?, externalName: String?): String { + val external = externalName ?: stringResource(R.string.tv_signin_provider_fallback) + return when (this) { + TvLoginError.UsernameRequired -> stringResource(R.string.tv_signin_error_username_required) + TvLoginError.PasswordRequired -> stringResource(R.string.tv_signin_error_password_required) + TvLoginError.InvalidCredentials -> if (providerName != null) { + stringResource(R.string.tv_signin_error_invalid_credentials_provider, providerName) + } else { + stringResource(R.string.tv_signin_error_invalid_credentials) + } + TvLoginError.EmailInUse -> stringResource(R.string.tv_signin_error_email_in_use, external) + TvLoginError.IdentityLinkedElsewhere -> stringResource(R.string.tv_signin_error_identity_linked_elsewhere, external) + TvLoginError.RateLimited -> stringResource(R.string.tv_signin_error_rate_limited) + TvLoginError.AccountRequired -> stringResource(R.string.tv_signin_error_account_required) + TvLoginError.AccountDisabled -> stringResource(R.string.tv_signin_error_account_disabled) + TvLoginError.Network -> stringResource(R.string.tv_signin_error_network) + TvLoginError.LocalLoginDisabled -> stringResource(R.string.tv_signin_error_local_login_disabled) + TvLoginError.NotPermitted -> stringResource(R.string.tv_signin_error_not_permitted) + TvLoginError.PasswordExpired -> stringResource(R.string.tv_signin_error_password_expired) + TvLoginError.ProviderUnavailable -> stringResource(R.string.tv_signin_error_provider_unavailable) + TvLoginError.IdentityChanged -> stringResource(R.string.tv_signin_error_identity_changed) + TvLoginError.SaveFailed -> stringResource(R.string.tv_signin_error_save_failed) + TvLoginError.CleanupIncomplete -> stringResource(R.string.tv_signin_error_cleanup_incomplete) + TvLoginError.NetworkIdentityRequired -> stringResource(R.string.tv_signin_error_network_identity_required, external) + TvLoginError.NetworkNotPermitted -> stringResource(R.string.tv_signin_error_network_not_permitted, external) + TvLoginError.NetworkEmailInUse -> stringResource(R.string.tv_signin_error_network_email_in_use, external) + TvLoginError.NetworkProviderGone -> stringResource(R.string.tv_signin_error_network_provider_gone, external) + is TvLoginError.Server -> message ?: stringResource(R.string.tv_signin_error_login_failed) + } +} + +/** + * Username and password on the left, laid out like Apple TV's password + * screen; the card beside them says a nearby phone can sign in instead. + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun PasswordScreen( state: TvLoginUiState, + passwordOnly: Boolean, + serverName: String, + notices: @Composable () -> Unit, usernameFocus: FocusRequester, + usernameFocusTracking: Modifier, passwordFocus: FocusRequester, signInFocus: FocusRequester, createAccountFocus: FocusRequester, @@ -339,483 +792,189 @@ private fun CredentialFormCard( onCreateAccount: () -> Unit, onBackToPhone: () -> Unit, onChangeServer: () -> Unit, - modifier: Modifier = Modifier, + network: NetworkSignInAction? = null, ) { + // Each rejected password shakes the fields and puts focus back on the + // password: the fields were disabled while it was checked, so focus left. + var failures by remember { mutableIntStateOf(0) } + var passwordFocused by remember { mutableStateOf(false) } var passwordVisible by remember { mutableStateOf(false) } - // Height budget, not taste: this card plus the screen chrome above it has - // to measure under 540dp (the 1920x1080 @ 320dpi TV surface) with the 24dp - // overscan inset intact, or the root Column starts scrolling and the header - // chrome leaves the screen unreachably. Current budget with the safe area, - // brand row, eyebrow and this card is ~462dp. Before you add a row here or - // relax a gap, spend that ~78dp of headroom knowingly. - Column( - verticalArrangement = Arrangement.spacedBy(Spacing.sm), - modifier = modifier - .auroraPanel(20.dp) - .padding(horizontal = 24.dp, vertical = 14.dp), - ) { - // Title only — the "ACCOUNT" eyebrow above the card already carries the - // step context, so the explanatory subtitle was a line of height the - // 540dp budget could not afford. - Text( - text = "Sign in", - style = TvLoginTextStyles.Title, - color = MaterialTheme.colorScheme.onBackground, - ) - - // Username — a mono uppercase caption labels each field, matching the - // server-setup card; the Material floating label is dropped so nothing - // floats oversized in the border notch. - Column( - verticalArrangement = Arrangement.spacedBy(Spacing.xs), - modifier = Modifier.tvImeAwareFieldContext(), - ) { - Text( - text = "USERNAME", - style = TvLoginTextStyles.InputLabel, - color = Color.White.copy(alpha = 0.52f), - ) - OutlinedTextField( - value = state.username, - onValueChange = onUsernameChanged, - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Next, - showKeyboardOnFocus = false, - ), - enabled = !state.isLoading, - textStyle = TvLoginTextStyles.Field, - modifier = Modifier - .fillMaxWidth() - .height(TvAuthFormDefaults.FieldHeight) - .tvShowImeOnSelect() - .focusRequester(usernameFocus), - colors = tvOutlinedTextFieldColors(), + LaunchedEffect(state.error) { + if (state.error != null && state.password.isNotEmpty()) { + failures++ + requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = passwordFocus::requestFocus, + isFocused = { passwordFocused }, ) } - - Column( - verticalArrangement = Arrangement.spacedBy(Spacing.xs), - modifier = Modifier.tvImeAwareFieldContext(), - ) { - Text( - text = "PASSWORD", - style = TvLoginTextStyles.InputLabel, - color = Color.White.copy(alpha = 0.52f), - ) - Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { - OutlinedTextField( - value = state.password, - onValueChange = onPasswordChanged, - singleLine = true, - visualTransformation = if (passwordVisible) { - VisualTransformation.None + } + TvMarqueeScreen( + copyScroll = rememberTvImeAwareFormScrollState(), + copy = { + Column { + notices() + TvMarqueeHeadline("Sign in with\na password", Modifier.padding(top = 22.dp)) + TvMarqueeBody( + if (passwordOnly && network == null) { + stringResource(R.string.tv_signin_password_only) } else { - PasswordVisualTransformation() + "Use your $serverName username and password." }, - trailingIcon = { - IconButton( - onClick = { passwordVisible = !passwordVisible }, - enabled = !state.isLoading, - ) { - Icon( - imageVector = if (passwordVisible) { - Icons.Default.VisibilityOff - } else { - Icons.Default.Visibility + modifier = Modifier.padding(top = 11.dp), + ) + val firstSecondary = when { + signupEnabled -> createAccountFocus + !passwordOnly -> backToPhoneFocus + else -> changeServerFocus + } + // While either sign-in waits, the fields and Sign in are disabled and + // can't take focus: "Continue as …" (which keeps focus while it waits) + // and the secondary actions reach each other directly. + val aboveSecondary = if (state.signingIn && network != null) network.focusRequester else signInFocus + if (network != null) { + NetworkSignInButton( + action = network, + below = if (state.signingIn) firstSecondary else usernameFocus, + modifier = Modifier.padding(top = 15.dp), + ) + } + Column( + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier + .width(380.dp) + .padding(top = 20.dp) + .marqueeShake(failures), + ) { + Box(Modifier.tvImeAwareFieldContext()) { + TvMarqueeField( + value = state.username, + onValueChange = onUsernameChanged, + icon = Icons.Outlined.Person, + placeholder = stringResource(R.string.tv_signin_form_username_placeholder), + enabled = !state.signingIn, + focusRequester = usernameFocus, + modifier = Modifier + .semantics { contentType = ContentType.Username } + .then(usernameFocusTracking) + .focusProperties { if (network != null) up = network.focusRequester }, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.weight(1f).tvImeAwareFieldContext()) { + TvMarqueeField( + value = state.password, + onValueChange = onPasswordChanged, + icon = Icons.Outlined.Lock, + placeholder = stringResource(R.string.tv_signin_form_password_placeholder), + isSecure = true, + revealed = passwordVisible, + isError = state.error != null, + imeAction = ImeAction.Done, + onImeAction = { + if (canSubmitTvCredentialLogin(state.username, state.password, state.signingIn)) onLoginClick() }, - contentDescription = if (passwordVisible) "Hide password" else "Show password", + enabled = !state.signingIn, + focusRequester = passwordFocus, + onFocusChange = { passwordFocused = it }, + modifier = Modifier.semantics { contentType = ContentType.Password }, ) } - }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done, - showKeyboardOnFocus = false, - ), - keyboardActions = KeyboardActions( - onDone = { - if (canSubmitTvCredentialLogin(state.username, state.password, state.isLoading)) { - onLoginClick() - } - }, - ), - enabled = !state.isLoading, - textStyle = TvLoginTextStyles.Field, - modifier = Modifier - .weight(1f) - .height(TvAuthFormDefaults.FieldHeight) - .tvShowImeOnSelect() - .focusRequester(passwordFocus), - colors = tvOutlinedTextFieldColors(), - ) - } - } - - if (state.error != null) { - Text( - text = state.error!!, - style = TvLoginTextStyles.Error, - color = MaterialTheme.colorScheme.error, - ) - } - - Box { - AuroraPrimaryButton( - label = if (state.isLoading) "Signing in…" else "Sign In", - icon = Icons.AutoMirrored.Filled.Login, - onClick = onLoginClick, - focusRequester = signInFocus, - focusHalo = false, - filledAtRest = false, - neutralFocusFill = true, - enabled = !state.isLoading, - modifier = Modifier - .focusProperties { - // Explicit chain, so it must name every stop: skipping - // straight to "Back to phone sign-in" left Create - // Account unreachable by remote on signup-enabled - // servers (the intervening label Text is not focusable, - // so there is no default search to fall back on). - down = if (signupEnabled) createAccountFocus else backToPhoneFocus - } - .fillMaxWidth() - .height(TvAuthFormDefaults.PrimaryButtonHeight), - ) - } - - // Secondary actions on ONE row, not stacked. Three full-width ghost - // buttons cost ~120dp of the 540dp viewport; side by side they cost - // ~33dp, which is most of what buys this card its headroom. Create - // Account appears only when the server reports public signup is enabled - // (the ServerSetup probe forwards that flag through the Login route). - // - // Labels are sized to survive an equal-weight third of the 472dp card - // interior without wrapping — a wrapped label grows the row's height - // and puts the budget back over. "Phone sign-in" is the short form of - // "Back to phone sign-in" for that reason. - Row( - horizontalArrangement = Arrangement.spacedBy(Spacing.sm), - modifier = Modifier.fillMaxWidth(), - ) { - if (signupEnabled) { - AuroraGhostButton( - label = "Create Account", - onClick = onCreateAccount, - fontSize = TvLoginSecondaryActionFontSize, - horizontalPadding = TvLoginSecondaryActionPadding, - verticalPadding = 8.dp, - modifier = Modifier - .focusRequester(createAccountFocus) - .focusProperties { - // Explicit chain, matching the primary button's: - // the label Texts around these controls are not - // focusable, so there is no default search to fall - // back on if a link is left implicit. - up = signInFocus - right = backToPhoneFocus + TvMarqueeButton( + onClick = { passwordVisible = !passwordVisible }, + kind = TvMarqueeButtonKind.Glass, + enabled = !state.signingIn, + modifier = Modifier.semantics { + contentDescription = if (passwordVisible) "Hide password" else "Show password" + }, + ) { ink -> + Icon( + if (passwordVisible) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + contentDescription = null, + tint = ink, + modifier = Modifier.size(16.dp), + ) } - .weight(1f), - ) - } - // Return to the phone-first surface (the QR pairing remains live). - AuroraGhostButton( - label = "Phone sign-in", - onClick = onBackToPhone, - fontSize = TvLoginSecondaryActionFontSize, - horizontalPadding = TvLoginSecondaryActionPadding, - verticalPadding = 8.dp, - modifier = Modifier - .focusRequester(backToPhoneFocus) - .focusProperties { - up = signInFocus - if (signupEnabled) left = createAccountFocus - right = changeServerFocus - } - .weight(1f), - ) - // Bail out to server setup to point this TV at a different server — - // mirrors tvOS TVLoginView. - AuroraGhostButton( - label = "Change server", - onClick = onChangeServer, - fontSize = TvLoginSecondaryActionFontSize, - horizontalPadding = TvLoginSecondaryActionPadding, - verticalPadding = 8.dp, - modifier = Modifier - .focusRequester(changeServerFocus) - .focusProperties { - up = signInFocus - left = backToPhoneFocus } - .weight(1f), - ) - } - } -} - -/** - * Type and inset for the sign-in card's side-by-side secondary actions. Branch - * -local on purpose: [TvAuthFormDefaults] is shared with server setup, sign-up - * and first-run setup, and those screens have no reason to shrink. - */ -private val TvLoginSecondaryActionFontSize = 16.sp -private val TvLoginSecondaryActionPadding = 10.dp - -private object TvLoginTextStyles { - val Hero = TextStyle( - fontWeight = FontWeight.Bold, - fontSize = 26.sp, - lineHeight = 30.sp, - letterSpacing = 0.sp, - ) - - val Title = TextStyle( - fontWeight = FontWeight.SemiBold, - fontSize = 26.sp, - lineHeight = 32.sp, - letterSpacing = 0.sp, - ) - - val Body = TextStyle( - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 22.sp, - letterSpacing = 0.sp, - ) - - val Field = TextStyle( - fontWeight = FontWeight.Normal, - fontSize = 17.sp, - lineHeight = 22.sp, - letterSpacing = 0.sp, - color = Color.White, - ) - - /** Mono uppercase caption that labels each input — mirrors server setup. */ - val InputLabel = TextStyle( - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - lineHeight = 19.sp, - letterSpacing = 3.sp, - ) - - val Error = TextStyle( - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - lineHeight = 20.sp, - letterSpacing = 0.sp, - ) - - val Button = TextStyle( - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - lineHeight = 20.sp, - letterSpacing = 0.sp, - ) -} - -/** - * Live QR pane bound to the device-login state machine. Renders one of five - * branches based on [state]: - * - * - Idle / Initiating → spinner copy + empty 320dp box (matches the QR's - * final footprint so the layout doesn't reflow when the matrix lands). - * - Awaiting → the actual QR (encoded `verification_uri_complete`) plus - * the short `user_code` underneath as a typing fallback. - * - Approved → "Signed in!" — short-lived, the screen-level - * `LaunchedEffect(loginSuccess)` navigates away. - * - Failed → message + "Try again" pill that fires [onRetry]. - */ -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun QrLoginCard( - state: DeviceLoginRepository.DeviceLoginState, - onRetry: () -> Unit, - onUsePassword: () -> Unit, - onChangeServer: () -> Unit, - usePasswordFocus: FocusRequester, - modifier: Modifier = Modifier, -) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(Spacing.sm), - modifier = modifier - .auroraGlass(15.dp) - .padding(24.dp), - ) { - Text( - text = "Scan with Camera", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onBackground, - ) - when (state) { - DeviceLoginRepository.DeviceLoginState.Idle, - DeviceLoginRepository.DeviceLoginState.Initiating -> { - Box( - modifier = Modifier - .size(150.dp) - .background( - Color.White.copy(alpha = 0.06f), - RoundedCornerShape(8.dp), - ) - .border( - width = 1.dp, - color = Color.White.copy(alpha = 0.10f), - shape = RoundedCornerShape(8.dp), - ), - ) - Text( - text = "Loading pairing code…", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - is DeviceLoginRepository.DeviceLoginState.Awaiting -> { - QrCodePanel( - content = state.session.verificationUriComplete, - size = 150.dp, - ) - MatchCodeTiles(code = state.session.matchCode) - } - is DeviceLoginRepository.DeviceLoginState.Approved -> { - Text( - text = "Signed in!", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - ) - } - is DeviceLoginRepository.DeviceLoginState.Failed -> { - Text( - text = state.message ?: "Sign-in failed", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - ) - TvHeroActionPill( - label = "Try again", - icon = Icons.Default.Refresh, - variant = TvPillVariant.Hollow, - onClick = onRetry, - ) - } - } - - Box( - modifier = Modifier - .width(150.dp) - .height(1.dp) - .background(Color.White.copy(alpha = 0.10f)), - ) - - // Compact 18sp spec matching the password card's stacked buttons — - // at the default 22sp the longer label wraps and the card outgrows - // the 540dp viewport (see the screen-root padding note). - AuroraGhostButton( - label = "Sign in with a password", - onClick = onUsePassword, - fontSize = 18.sp, - horizontalPadding = 18.dp, - verticalPadding = 8.dp, - modifier = Modifier - .focusRequester(usePasswordFocus) - .fillMaxWidth(), - ) - AuroraGhostButton( - label = "Use another server", - onClick = onChangeServer, - fontSize = 18.sp, - horizontalPadding = 18.dp, - verticalPadding = 8.dp, - modifier = Modifier.fillMaxWidth(), - ) - } -} - -/** - * Match-code confirmation tiles — "CONFIRM THIS CODE" over the server-issued - * code, one monospaced tile per character. Mirrors tvOS - * `TVLoginView.matchCodeTiles`; word/number separators render as a thin dash. - */ -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun MatchCodeTiles(code: String, modifier: Modifier = Modifier) { - if (code.isBlank()) return - val tileWidthDp = matchCodeTileWidthDp(code) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(Spacing.xs), - modifier = modifier, - ) { - Text( - text = "CONFIRM THIS CODE", - style = TextStyle( - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - letterSpacing = 3.sp, - ), - color = Color.White.copy(alpha = 0.6f), - ) - Row(horizontalArrangement = Arrangement.spacedBy(MATCH_CODE_TILE_GAP_DP.dp)) { - code.uppercase().forEach { ch -> - val isSep = ch == '-' || ch == ' ' - if (isSep) { - Text( - text = "–", - style = TextStyle(fontSize = 16.sp, fontWeight = FontWeight.Bold), - color = Color.White.copy(alpha = 0.4f), - modifier = Modifier.width(MATCH_CODE_SEPARATOR_WIDTH_DP.dp), + } + state.error?.let { error -> + TvMarqueeErrorText( + error.message(state.providerName, state.directoryName), + Modifier + .width(380.dp) + .padding(top = 8.dp) + .semantics { liveRegion = LiveRegionMode.Polite }, ) - } else { - Box( - modifier = Modifier - .size(width = tileWidthDp.dp, height = 30.dp) - .background(Color.White.copy(alpha = 0.06f), RoundedCornerShape(6.dp)) - .border(1.dp, Color.White.copy(alpha = 0.16f), RoundedCornerShape(6.dp)), - contentAlignment = Alignment.Center, - ) { - Text( - text = ch.toString(), - style = TextStyle( - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, - fontWeight = FontWeight.Bold, - fontSize = 16.sp, - ), - color = MaterialTheme.colorScheme.onBackground, + } + // An account that signs in with the server's provider has no password + // here (prairie-apple parity). The 401 message says the same, so the hint + // steps aside while that message shows. + state.providerName?.takeIf { state.error != TvLoginError.InvalidCredentials }?.let { provider -> + TvMarqueeBody( + stringResource(R.string.tv_signin_form_provider_hint, provider), + size = 14.sp, + color = MarqueeColors.InkTertiary, + modifier = Modifier.width(380.dp).padding(top = 8.dp), + ) + } + Row( + Modifier + .fillMaxWidth() + .padding(top = 20.dp), + horizontalArrangement = Arrangement.spacedBy(11.dp), + ) { + TvMarqueeButton( + text = stringResource(if (state.isLoading) R.string.tv_signin_form_submitting else R.string.tv_signin_form_submit), + onClick = onLoginClick, + isLoading = state.isLoading, + focusRequester = signInFocus, + enabled = !state.signingIn, + modifier = Modifier.focusProperties { down = firstSecondary }, + ) + if (signupEnabled) { + TvMarqueeButton( + text = stringResource(R.string.tv_signin_form_create_account), + onClick = onCreateAccount, + kind = TvMarqueeButtonKind.Plain, + focusRequester = createAccountFocus, + modifier = Modifier.focusProperties { up = aboveSecondary }, ) } + if (!passwordOnly) { + // Back to the device code (a fresh one if the old one lapsed). + TvMarqueeButton( + text = stringResource(R.string.tv_signin_form_use_phone), + onClick = onBackToPhone, + kind = TvMarqueeButtonKind.Plain, + focusRequester = backToPhoneFocus, + modifier = Modifier.focusProperties { up = aboveSecondary }, + ) + } + TvMarqueeButton( + text = stringResource(R.string.tv_signin_action_change_server), + onClick = onChangeServer, + kind = TvMarqueeButtonKind.Plain, + focusRequester = changeServerFocus, + modifier = Modifier.focusProperties { up = aboveSecondary }, + ) } } - } - } + }, + card = { + TvUsePhoneCard( + if (passwordOnly) { + "Signed in to Prairie on your phone? Open it on the same Wi‑Fi and it can sign this TV in for you." + } else { + "Choose \"Use your phone instead\" to scan a code and approve on your phone. Nothing to type with the remote." + }, + ) + }, + ) } -internal const val MATCH_CODE_TILE_WIDTH_DP = 24 -internal const val MATCH_CODE_SEPARATOR_WIDTH_DP = 10 -internal const val MATCH_CODE_TILE_GAP_DP = 2 -internal const val MATCH_CODE_CONTENT_WIDTH_DP = 252 - -internal fun matchCodeTileWidthDp(code: String): Int { - val tileCount = code.count { ch -> ch != '-' && ch != ' ' } - if (tileCount == 0) return MATCH_CODE_TILE_WIDTH_DP +/** The QR, at least a third of the 540dp reference surface with its frame. */ +private val QrSize: Dp = 170.dp - val separatorCount = code.length - tileCount - val gapWidth = (code.length - 1).coerceAtLeast(0) * MATCH_CODE_TILE_GAP_DP - val availableTileWidth = ( - MATCH_CODE_CONTENT_WIDTH_DP - - separatorCount * MATCH_CODE_SEPARATOR_WIDTH_DP - - gapWidth - ).coerceAtLeast(tileCount) - return minOf(MATCH_CODE_TILE_WIDTH_DP, availableTileWidth / tileCount) -} - -internal fun matchCodeRowWidthDp(code: String): Int { - if (code.isEmpty()) return 0 - val tileWidth = matchCodeTileWidthDp(code) - val characterWidth = code.sumOf { ch -> - if (ch == '-' || ch == ' ') MATCH_CODE_SEPARATOR_WIDTH_DP else tileWidth - } - return characterWidth + (code.length - 1) * MATCH_CODE_TILE_GAP_DP -} +/** How long a finished sign-in waits for a nearby phone's session to wrap up before routing on. */ +private const val NEARBY_RESULT_WAIT_MS = 5_000L diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvLoginViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvLoginViewModel.kt index 0f7d42e7ee..aeb8860c5a 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvLoginViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvLoginViewModel.kt @@ -1,70 +1,322 @@ package org.prairieserver.prairie.tv.ui.screens.auth +import android.os.Build import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import org.prairieserver.prairie.common.pairing.NearbySignInCodeSource +import org.prairieserver.prairie.common.pairing.NearbySignInOutcome +import org.prairieserver.prairie.common.pairing.PairingAdvertisement +import org.prairieserver.prairie.pairing.PairingFailureCode +import org.prairieserver.prairie.model.auth.DeviceCodeFormat import org.prairieserver.prairie.model.auth.DeviceLoginPollResponse +import org.prairieserver.prairie.model.auth.DeviceLoginStartResponse +import org.prairieserver.prairie.model.auth.LoginResponse +import org.prairieserver.prairie.model.auth.NetworkSignInFailure +import org.prairieserver.prairie.model.auth.SignInProvider import org.prairieserver.prairie.network.AccountSessionChangedException import org.prairieserver.prairie.network.AccountSessionExpectation import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.ServerRegistry import org.prairieserver.prairie.network.TokenManager import org.prairieserver.prairie.repository.AuthRepository +import org.prairieserver.prairie.repository.DeviceLoginClock import org.prairieserver.prairie.repository.DeviceLoginRepository +import org.prairieserver.prairie.repository.DeviceSignInMachine +import org.prairieserver.prairie.repository.DeviceSignInState +import org.prairieserver.prairie.repository.ExternalSignInRepository +import org.prairieserver.prairie.model.auth.PasswordLoginFailure +import org.prairieserver.prairie.repository.ServerIdentityRepository import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull + +/** The form's error for a refused password sign-in. */ +internal fun tvLoginError(result: ApiResult.Error): TvLoginError = when { + // AuthRepository's code for a sign-in whose account or server changed underneath it. + result.error == "identity_changed" -> TvLoginError.IdentityChanged + else -> when (PasswordLoginFailure.of(result.code, result.error)) { + PasswordLoginFailure.InvalidCredentials -> TvLoginError.InvalidCredentials + PasswordLoginFailure.LocalLoginDisabled -> TvLoginError.LocalLoginDisabled + PasswordLoginFailure.NotPermitted -> TvLoginError.NotPermitted + PasswordLoginFailure.PasswordExpired -> TvLoginError.PasswordExpired + PasswordLoginFailure.AccountDisabled -> TvLoginError.AccountDisabled + PasswordLoginFailure.ProviderUnavailable -> TvLoginError.ProviderUnavailable + PasswordLoginFailure.EmailInUse -> TvLoginError.EmailInUse + PasswordLoginFailure.IdentityLinkedElsewhere -> TvLoginError.IdentityLinkedElsewhere + PasswordLoginFailure.RateLimited -> TvLoginError.RateLimited + PasswordLoginFailure.AccountRequired -> TvLoginError.AccountRequired + PasswordLoginFailure.Other -> TvLoginError.Server(result.message.takeIf { it.isNotBlank() }) + } +} + +/** The error for a refused network identity sign-in ("Continue as …"). */ +internal fun tvNetworkSignInError(result: ApiResult.Error): TvLoginError = when { + // AuthRepository's code for a sign-in whose account or server changed underneath it. + result.error == "identity_changed" -> TvLoginError.IdentityChanged + else -> when (NetworkSignInFailure.of(result.code, result.error)) { + NetworkSignInFailure.NetworkIdentityRequired -> TvLoginError.NetworkIdentityRequired + NetworkSignInFailure.NotPermitted -> TvLoginError.NetworkNotPermitted + NetworkSignInFailure.EmailInUse -> TvLoginError.NetworkEmailInUse + NetworkSignInFailure.NotFound -> TvLoginError.NetworkProviderGone + NetworkSignInFailure.AccountRequired -> TvLoginError.AccountRequired + NetworkSignInFailure.AccountDisabled -> TvLoginError.AccountDisabled + NetworkSignInFailure.IdentityLinkedElsewhere -> TvLoginError.IdentityLinkedElsewhere + NetworkSignInFailure.ProviderUnavailable -> TvLoginError.ProviderUnavailable + NetworkSignInFailure.RateLimited -> TvLoginError.RateLimited + NetworkSignInFailure.Other -> TvLoginError.Server(result.message.takeIf { it.isNotBlank() }) + } +} + +/** + * Why a sign-in on this screen (the password form, or "Continue as …") can't + * proceed; the screen maps each to `strings.xml`. + */ +sealed interface TvLoginError { + data object UsernameRequired : TvLoginError + data object PasswordRequired : TvLoginError + data object InvalidCredentials : TvLoginError + data object AccountDisabled : TvLoginError + data object Network : TvLoginError + + /** Password sign-in is off on this server (it signs in through a provider). */ + data object LocalLoginDisabled : TvLoginError + + /** The directory accepted the password but doesn't admit this account. */ + data object NotPermitted : TvLoginError + + /** The directory says the password has expired. */ + data object PasswordExpired : TvLoginError + + /** The directory (LDAP) can't be reached. */ + data object ProviderUnavailable : TvLoginError + + /** A first directory sign-in found another account with the same email. */ + data object EmailInUse : TvLoginError + + /** The directory identity is linked to another account. */ + data object IdentityLinkedElsewhere : TvLoginError + data object RateLimited : TvLoginError + + /** No account matches and the server doesn't create one (`account_required`). */ + data object AccountRequired : TvLoginError + data object IdentityChanged : TvLoginError + data object SaveFailed : TvLoginError + data object CleanupIncomplete : TvLoginError + + /** A network sign-in that didn't come through the provider's network (`network_identity_required`). */ + data object NetworkIdentityRequired : TvLoginError + + /** The network provider refuses this TV (`not_permitted`): a tagged device, or one its policy leaves out. */ + data object NetworkNotPermitted : TvLoginError + + /** A first network sign-in found another account with the same email: connect from that account instead. */ + data object NetworkEmailInUse : TvLoginError + + /** The network provider is no longer enabled on the server (404). */ + data object NetworkProviderGone : TvLoginError + + /** The server's own explanation, or null for a generic failure. */ + data class Server(val message: String?) : TvLoginError +} data class TvLoginUiState( val username: String = "", val password: String = "", val isLoading: Boolean = false, - val error: String? = null, + val error: TvLoginError? = null, val loginSuccess: Boolean = false, + /** Display name of the server this TV signs in to, for the title and banner. */ + val serverName: String? = null, + /** Host shown in "Can't reach …" copy. */ + val serverHost: String? = null, + /** `/activate` in typed-URL form, shown before the first code arrives. */ + val serverActivateText: String? = null, + /** Arrived here because the session expired elsewhere: show the banner. */ + val sessionExpired: Boolean = false, + /** + * Whether the server takes a password from this TV: the local provider or + * a directory (LDAP), which the server reaches without a provider field. + * False only when discovery says no listed provider takes one (an OIDC + * server with local passwords off); the TV then offers device sign-in alone. + */ + val passwordAvailable: Boolean = true, + /** + * The server's browser sign-in provider (OIDC), when it lists one: its + * accounts have no password, so a refused password points to the phone. + */ + val providerName: String? = null, + /** The directory (LDAP) provider the password form also reaches, when enabled. */ + val directoryName: String? = null, + /** + * The network provider (such as Tailscale) the server listed for this + * TV, which it does only when the TV reached it through that provider's + * network: "Continue as " signs in with no code and no password. + */ + val networkProvider: SignInProvider? = null, + /** "Continue as …" is waiting for the server. */ + val networkBusy: Boolean = false, + /** Why the last "Continue as …" was refused; shown with that button. */ + val networkError: TvLoginError? = null, +) { + /** + * A password was refused because password sign-in is off, and the form + * closed because of it: the screen says so outside the form, the only + * other place the refusal could show. + */ + val passwordTurnedOff: Boolean get() = error == TvLoginError.LocalLoginDisabled && !passwordAvailable + + /** + * A password or "Continue as …" sign-in is waiting for the server. Neither + * button takes another press until it answers, so the password form's + * fields and Sign in are disabled with it. + */ + val signingIn: Boolean get() = isLoading || networkBusy +} + +/** What the device-code panel shows. */ +enum class TvSignInStatus { + GettingCode, + Waiting, + Opened, + NewCode, + Unreachable, + TooManyRequests, + SignedIn, + CouldntFinish, + Denied, + Paused, + Failed, + UpdateRequired, +} + +data class TvSignInCode( + /** Grouped for display: `4821 7730`. */ + val userCode: String, + /** `verification_uri_complete`, encoded into the QR. */ + val qrContent: String, + /** `prairie.example.com/activate`, the typed-URL form. */ + val activateText: String, + /** One character per word, for the screen reader. */ + val spokenCode: String, +) + +data class TvDeviceSignInUi( + /** Null while there is no live code: the QR slot shows a spinner. */ + val code: TvSignInCode? = null, + val status: TvSignInStatus = TvSignInStatus.GettingCode, + /** Account shown in "Signed in as …". */ + val accountName: String? = null, + /** The server has no device sign-in: go straight to the password form. */ + val passwordOnly: Boolean = false, ) /** * Two parallel sign-in flows share this ViewModel: * - * 1. **Credential** — [AuthRepository.login] returns a [org.prairieserver.prairie.model.auth.User] - * and silently stores the tokens inside [TokenManager]. - * 2. **Device-login (QR)** — [DeviceLoginRepository] runs an OAuth-style poll - * loop; on [DeviceLoginRepository.DeviceLoginState.Approved] we lift the - * returned access/refresh tokens into [TokenManager] so the rest of the - * auth machinery (start-destination, profile gating) sees the same world. + * 1. **Credential** — [AuthRepository.loginForTokens] (or, for "Continue as + * " through a network provider such as Tailscale, the network + * identity sign-in, which answers the same token pair) then an atomic + * [TokenManager.replaceAccountSession]. + * 2. **Device code (QR)** — a [DeviceSignInMachine] shows a code, renews it + * while the screen is visible, pauses after about an hour, and names + * failures. On approval we lift the tokens into [TokenManager]. + * + * Whichever completes first wins ([tryCompleteAuth]); the other is cancelled + * so a late winner can't overwrite tokens just saved by the loser. An approval + * that arrives while a password save is in flight is held, and used if that + * save fails. * - * Both flows run in parallel; whichever completes first explicitly - * cancels the other so a late winner can't overwrite tokens just - * saved by the loser: - * - Device-login `Approved` → [handleDeviceLoginApproved] flips - * `loginSuccess`. The `deviceLoginJob` coroutine terminates - * naturally once [DeviceLoginRepository.begin] returns its terminal - * state, so no explicit cancel is needed on this side. - * - Credential success → [onLoginClick] cancels `deviceLoginJob` - * before flipping `loginSuccess`, so a late-arriving device-login - * `Approved` can't clobber the freshly-saved credential tokens. + * Lifecycle: the screen calls [onStop] / [onStart] so polling stops in the + * background and resumes (polling at once, renewing if expired) on return. + * Stopping never interrupts a poll in flight or the approval handoff: the + * server hands out an approval's tokens once. An abandoned code is withdrawn + * on the server (when it supports cancel) on leaving, retrying, switching + * server or signing in with a password. * - * The screen-side `LaunchedEffect(loginSuccess)` then routes forward. + * In `login` pairing mode this is also the LAN receiver's + * [NearbySignInCodeSource]: a nearby phone approves the code shown here. */ class TvLoginViewModel( private val authRepository: AuthRepository, private val tokenManager: TokenManager, private val deviceLogin: DeviceLoginRepository, -) : ViewModel() { + private val serverRegistry: ServerRegistry? = null, + private val serverIdentities: ServerIdentityRepository? = null, + sessionExpired: Boolean = false, + /** Outlives the screen, for the best-effort cancel call on leaving. */ + private val backgroundScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + private val clock: DeviceLoginClock = DeviceLoginClock.System, + /** Provider discovery, for whether the password form is offered. */ + private val externalSignIn: ExternalSignInRepository? = null, + /** + * Rollout gate for sign-in TVs, mirroring prairie-apple + * `PairingProtocol.advertisesSignInTVs`: whether this screen advertises + * `st=login`. Both phone apps must accept `login` TVs before either TV + * advertises one; a phone that predates them offers a sign-in TV the + * setup chooser and pushes servers without an identity, which this TV + * refuses. The app turns it on for debug builds only; turn it on for + * release once the iOS and Android phone apps that handle `login` ship. + */ + private val advertisesSignIn: Boolean = false, +) : ViewModel(), NearbySignInCodeSource { - private val _uiState = MutableStateFlow(TvLoginUiState()) + private val _uiState = MutableStateFlow(TvLoginUiState(sessionExpired = sessionExpired)) val uiState: StateFlow = _uiState.asStateFlow() - /** Surfaced to the screen so the QR pane can render the live session. */ - val deviceLoginState: StateFlow = deviceLogin.state + private val _deviceSignIn = MutableStateFlow(TvDeviceSignInUi()) + val deviceSignIn: StateFlow = _deviceSignIn.asStateFlow() + + /** + * What this screen advertises to nearby phones: `st=login` with the + * server's verified identity. Null until the identity is known, or when + * it can't be: phones match a signed-out TV only by identity. + * Always null while the [advertisesSignIn] rollout gate is off. + */ + private val _pairingAdvertisement = MutableStateFlow(null) + val pairingAdvertisement: StateFlow = _pairingAdvertisement.asStateFlow() + + private var machine: DeviceSignInMachine? = null + /** + * The sign-in context the machine's codes were started under. A resumed + * run polls only while it is unchanged: an approval for a code started + * before a sign-in, sign-out or server switch must not be saved over it. + */ + private var machineExpectation: AccountSessionExpectation? = null + private var machineObserver: Job? = null private var deviceLoginJob: Job? = null private var credentialLoginJob: Job? = null + private var newCodeJob: Job? = null + private var signInOptionsJob: Job? = null + + /** Saves an approval's tokens and routes on; [onStop] never cancels it. */ + private var approvalJob: Job? = null private var authCompleted = false + private var visible = true + + /** [onStop] ran since the last [onStart]: the next start re-reads the sign-in options. */ + private var lastDeviceCode: String? = null + + /** A device approval that arrived while a password (or network) save held the sign-in. */ + private var heldApproval: Pair? = null + + /** The code on screen now, for a nearby phone; null while none is live. */ + private val liveCode = MutableStateFlow(null) init { + loadServerContext() startDeviceLogin() } @@ -72,28 +324,77 @@ class TvLoginViewModel( fun onPasswordChanged(v: String) = _uiState.update { it.copy(password = v, error = null) } fun onLoginClick() { - if (_uiState.value.isLoading) return + if (_uiState.value.signingIn) return val s = _uiState.value if (s.username.isBlank()) { - _uiState.update { it.copy(error = "Username is required") } + _uiState.update { it.copy(error = TvLoginError.UsernameRequired) } return } if (s.password.isBlank()) { - _uiState.update { it.copy(error = "Password is required") } + _uiState.update { it.copy(error = TvLoginError.PasswordRequired) } return } _uiState.update { it.copy(isLoading = true, error = null) } + signInWithTokens(TokenSignIn.Password) { expected -> authRepository.loginForTokens(s.username, s.password, expected) } + } + + /** + * "Continue as ": sign in as whoever the server's network provider + * (such as Tailscale) says owns this TV, with no code and no password. + * The session is saved exactly like a password sign-in's, and the code on + * screen is withdrawn once it is. + */ + fun onNetworkSignInClick() { + val s = _uiState.value + val discovery = externalSignIn ?: return + val path = s.networkProvider?.networkSignInPath ?: return + if (s.signingIn) return + _uiState.update { it.copy(networkBusy = true, networkError = null) } + signInWithTokens(TokenSignIn.Network) { expected -> + authRepository.tokensFor(expected) { serverUrl -> discovery.signInWithNetworkIdentity(serverUrl, path) } + } + } + + /** Which button started a [signInWithTokens]: its busy state and errors show there. */ + private enum class TokenSignIn { Password, Network } + + /** + * [attempt]'s button is free again, with [error], when given, shown where + * it was pressed. A null [attempt] is the device code's own handoff: its + * approval cancels whichever sign-in was under way, so both buttons are + * free again, and its error uses the form's slot. + */ + private fun TvLoginUiState.settled(attempt: TokenSignIn?, error: TvLoginError? = null): TvLoginUiState = when (attempt) { + TokenSignIn.Password -> copy(isLoading = false, error = error ?: this.error) + TokenSignIn.Network -> copy(networkBusy = false, networkError = error ?: networkError) + null -> copy(isLoading = false, networkBusy = false, error = error ?: this.error) + } + + /** + * A sign-in whose [fetch] answers `login`'s token pair (a password, or the + * network identity): it races the device code for the sign-in, and its + * session is committed atomically, or not at all. + */ + private fun signInWithTokens( + attempt: TokenSignIn, + fetch: suspend (AccountSessionExpectation) -> ApiResult, + ) { credentialLoginJob?.cancel() credentialLoginJob = viewModelScope.launch { - val expected = tokenManager.captureAccountSessionExpectation() ?: return@launch - when (val result = authRepository.loginForTokens(s.username, s.password, expected)) { + val expected = captureExpectation() + if (expected == null) { + _uiState.update { it.settled(attempt, TvLoginError.IdentityChanged) } + return@launch + } + when (val result = fetch(expected)) { is ApiResult.Success -> { if (!tryCompleteAuth()) { - _uiState.update { it.copy(isLoading = false) } + _uiState.update { it.settled(attempt) } return@launch } - deviceLoginJob?.cancel() + // Polling carries on until the password session commits: if + // the save fails, the code on screen still works. try { tokenManager.replaceAccountSession( expectedIdentity = expected, @@ -104,101 +405,278 @@ class TvLoginViewModel( } catch (cancelled: CancellationException) { throw cancelled } catch (_: AccountSessionChangedException) { - handleIdentityChanged() + handleIdentityChanged(attempt) + resumeDeviceSignIn() return@launch } catch (_: Throwable) { handleSessionPersistenceFailure( accessToken = result.data.accessToken, refreshToken = result.data.refreshToken, + attempt = attempt, ) + if (!authCompleted) { + // This screen's own save moved the generation without + // committing anything: the code on screen still belongs + // to this sign-in, so it carries on under the new one. + if (machineExpectation?.serverUrl == expected.serverUrl) machineExpectation = captureExpectation() + resumeDeviceSignIn() + } else { + tokenSignInWon() + } return@launch } // Tokens are committed outside persistSession here, so refresh the // server's v2 contract verdict the same way every other sign-in does. authRepository.onSessionCommitted() if (tokenManager.captureAccountSessionExpectation()?.generation != expected.generation + 1) { - handleIdentityChanged() + handleIdentityChanged(attempt) + resumeDeviceSignIn() return@launch } - _uiState.update { it.copy(isLoading = false, loginSuccess = true) } + tokenSignInWon() + _uiState.update { it.settled(attempt).copy(loginSuccess = true) } } is ApiResult.Error -> { if (authCompleted) { - _uiState.update { it.copy(isLoading = false) } + _uiState.update { it.settled(attempt) } return@launch } - val msg = when (result.code) { - 401 -> "Invalid username or password" - 403 -> "Account is disabled" - else -> result.message.ifBlank { "Login failed" } + val error = when (attempt) { + TokenSignIn.Password -> tvLoginError(result) + TokenSignIn.Network -> tvNetworkSignInError(result) } - _uiState.update { it.copy(isLoading = false, error = msg) } + _uiState.update { it.settled(attempt, error) } + // The server refused passwords: re-read what it offers, so + // the form goes away once password sign-in is off. + if (attempt == TokenSignIn.Password && error == TvLoginError.LocalLoginDisabled) refreshSignInOptions() } is ApiResult.NetworkError -> { if (authCompleted) { - _uiState.update { it.copy(isLoading = false) } + _uiState.update { it.settled(attempt) } return@launch } - _uiState.update { - it.copy(isLoading = false, error = "Network error. Check your connection.") - } + _uiState.update { it.settled(attempt, TvLoginError.Network) } } } } } /** - * Starts (or restarts) the device-login state machine. Cancels any prior - * job so a retry doesn't race two pollers against the same UI. On the - * terminal [DeviceLoginRepository.DeviceLoginState.Approved] state we - * persist tokens and flip `loginSuccess` — the screen then routes forward - * from its `LaunchedEffect`. + * The screen went to the background (or the screensaver started): stop + * polling, keep the code. A poll in flight finishes first and an approval + * being saved completes, since the server hands out its tokens once. */ + fun onStop() { + visible = false + machine?.requestStop() + } + + /** + * The screen is visible again: poll now, renewing the code if it expired + * meanwhile, and re-read the server's sign-in options (an admin may have + * turned password sign-in off while the TV sat in the background). + */ + fun onStart() { + if (!visible && !authCompleted) refreshSignInOptions() + if (visible && deviceLoginJob?.isActive == true) return + visible = true + // A password sign-in already won (possibly while in the background): + // its code was withdrawn, and no new one may be asked for. + if (authCompleted) return + val current = machine + if (current == null) { + // Still reading the sign-in context: that run creates the machine + // and, now that the screen is visible again, starts it. + if (deviceLoginJob?.isActive == true) return + startDeviceLogin() + return + } + // Approved is settled too: the approval job finishes the handoff on its own. + if (current.isSettled) return + current.noteActivity() + runMachine(current) + } + + /** + * "Try again" / "Show a new code": withdraw the old code and start fresh. + * After a failed save the server already consumed the old request, so a + * new code is the only way on (#422). + */ + fun restartDeviceLogin() { + if (authCompleted) return + // Also "Use your phone instead" from the password form: the options + // may have changed while the person was typing. + refreshSignInOptions() + abandonDeviceCode() + machine?.restart() + _deviceSignIn.value = TvDeviceSignInUi() + startDeviceLogin() + } + + /** "Change server": withdraw the code before this screen goes away. */ + fun onChangeServer() { + abandonDeviceCode() + } + private fun startDeviceLogin() { deviceLoginJob?.cancel() deviceLoginJob = viewModelScope.launch { - val expected = tokenManager.captureAccountSessionExpectation() ?: return@launch - deviceLogin.beginAt( - serverUrl = expected.serverUrl, - deviceName = android.os.Build.MODEL, - // Same spelling as the X-Prairie-Device-Platform header this app - // sends, so one device reports one platform string everywhere. - devicePlatform = "android-tv", - ) - val terminal = deviceLogin.state.value - if (terminal is DeviceLoginRepository.DeviceLoginState.Approved) { - handleDeviceLoginApproved(terminal.response, expected) + val expected = captureExpectation() + if (expected == null) { + // No sign-in context to bind the approval to (#421): say so and + // offer "Try again" rather than spin on "Getting a code" forever. + _deviceSignIn.value = TvDeviceSignInUi(status = TvSignInStatus.Failed) + return@launch } + val current = machine?.takeIf { it.serverUrl == expected.serverUrl } + ?: DeviceSignInMachine( + repository = deviceLogin, + serverUrl = expected.serverUrl, + deviceName = deviceName(), + // Same spelling as the X-Prairie-Device-Platform header this app + // sends, so one device reports one platform string everywhere. + devicePlatform = "android-tv", + clock = clock, + ).also { created -> + machine = created + observe(created) + } + machineExpectation = expected + // Stopped before the machine existed (onStop had nothing to stop): + // keep it idle until onStart runs it. + if (!visible) return@launch + runUntilSettled(current, expected) } } - fun restartDeviceLogin() { - deviceLogin.reset() - startDeviceLogin() + private fun runMachine(current: DeviceSignInMachine) { + // A run still finishing its last call after onStop is let finish (its + // answer may carry the tokens), then this run picks up from there. + // Cancelling this run (restart, change server, a password win, leaving) + // cancels the one it waits for too, so no older run outlives it. + val previous = deviceLoginJob + val next = viewModelScope.launch { + previous?.join() + if (current.isSettled) return@launch + val expected = captureExpectation() + val bound = machineExpectation + if (expected == null || expected.serverUrl != current.serverUrl || bound?.isSameSession(expected) != true) { + // "Try again" withdraws the code and starts one under the current context. + _deviceSignIn.value = TvDeviceSignInUi(status = TvSignInStatus.Failed) + return@launch + } + // Stopped again while waiting (onStop, onStart, onStop during one + // call): that onStop's requestStop landed on the previous run, not + // on this one, so check here, with no suspension before run(). + if (!visible) return@launch + runUntilSettled(current, expected) + } + next.invokeOnCompletion { cause -> if (cause is CancellationException) previous?.cancel() } + deviceLoginJob = next + } + + private suspend fun runUntilSettled(current: DeviceSignInMachine, expected: AccountSessionExpectation) { + current.run() + val terminal = current.state.value + if (terminal is DeviceSignInState.Approved) launchApproval(terminal.response, expected) + } + + /** The approval handoff runs in its own job so leaving the foreground can't cut it short. */ + private fun launchApproval(response: DeviceLoginPollResponse, expected: AccountSessionExpectation) { + if (approvalJob?.isActive == true) return + approvalJob = viewModelScope.launch { handleDeviceLoginApproved(response, expected) } + } + + private fun observe(current: DeviceSignInMachine) { + machineObserver?.cancel() + machineObserver = viewModelScope.launch { + current.state.collect { state -> publish(state) } + } + } + + private fun publish(state: DeviceSignInState) { + when (state) { + is DeviceSignInState.ShowingCode -> liveCode.value = state.session + is DeviceSignInState.Unreachable -> liveCode.value = state.session + is DeviceSignInState.TooManyRequests -> liveCode.value = state.session + DeviceSignInState.GettingCode -> liveCode.value = null + // An approval keeps its code until the handoff says how it ended. + else -> Unit + } + val previous = _deviceSignIn.value + // The approval is handled (and its outcome shown) by + // handleDeviceLoginApproved; never claim "Signed in" before the + // session is actually saved (#422). + if (state is DeviceSignInState.Approved) return + if (previous.status == TvSignInStatus.SignedIn || previous.status == TvSignInStatus.CouldntFinish) return + _deviceSignIn.value = when (state) { + DeviceSignInState.GettingCode -> TvDeviceSignInUi(status = TvSignInStatus.GettingCode) + is DeviceSignInState.ShowingCode -> { + val renewedNow = state.renewed && lastDeviceCode != null && lastDeviceCode != state.session.deviceCode + lastDeviceCode = state.session.deviceCode + if (renewedNow) announceNewCode() + val status = when { + state.opened -> TvSignInStatus.Opened + renewedNow || (previous.status == TvSignInStatus.NewCode && newCodeJob?.isActive == true) -> + TvSignInStatus.NewCode + else -> TvSignInStatus.Waiting + } + TvDeviceSignInUi(code = state.session.display(), status = status) + } + DeviceSignInState.Denied -> TvDeviceSignInUi(status = TvSignInStatus.Denied) + DeviceSignInState.Paused -> TvDeviceSignInUi(status = TvSignInStatus.Paused) + is DeviceSignInState.Unreachable -> + TvDeviceSignInUi(code = state.session?.display(), status = TvSignInStatus.Unreachable) + is DeviceSignInState.TooManyRequests -> + TvDeviceSignInUi(code = state.session?.display(), status = TvSignInStatus.TooManyRequests) + DeviceSignInState.NoDeviceSignIn -> TvDeviceSignInUi(status = TvSignInStatus.Failed, passwordOnly = true) + DeviceSignInState.UpdateRequired -> TvDeviceSignInUi(status = TvSignInStatus.UpdateRequired) + DeviceSignInState.Failed -> TvDeviceSignInUi(status = TvSignInStatus.Failed) + is DeviceSignInState.Approved -> previous + } + } + + /** "New code" is a brief, polite announcement, then the status reads "Waiting" again. */ + private fun announceNewCode() { + newCodeJob?.cancel() + newCodeJob = viewModelScope.launch { + delay(NEW_CODE_ANNOUNCEMENT_MS) + _deviceSignIn.update { ui -> + if (ui.status == TvSignInStatus.NewCode) ui.copy(status = TvSignInStatus.Waiting) else ui + } + } } /** - * Mirrors [AuthRepository.login]'s post-API token-save: the repository - * normally calls [TokenManager.saveTokens] for the caller, but the - * device-login flow returns its tokens directly to the repository's - * StateFlow instead — so we lift them into [TokenManager] here so - * everything downstream (MainTvActivity.resolveStartDestination, - * authenticated API calls) sees the same world as a credential login. + * Lift the approved tokens into [TokenManager] so everything downstream + * (start destination, authenticated calls) sees the same world as a + * credential login. On failure the panel says so with "Try again" — never + * "Signed in" (#422). */ private suspend fun handleDeviceLoginApproved(response: DeviceLoginPollResponse, expected: AccountSessionExpectation) { val accessToken = response.accessToken val refreshToken = response.refreshToken - // Repository already guards against null tokens (Failed.MissingTokens), - // but be defensive — we should never silently flip loginSuccess without - // tokens actually landing in storage. if (accessToken.isNullOrBlank() || refreshToken.isNullOrBlank()) { - _uiState.update { it.copy(isLoading = false) } + _deviceSignIn.value = TvDeviceSignInUi(status = TvSignInStatus.CouldntFinish) return } + val current = runCatching { tokenManager.captureAccountSessionExpectation() }.getOrNull() if (!tryCompleteAuth()) { - _uiState.update { it.copy(isLoading = false) } + // A password save holds the sign-in. The server won't hand these + // tokens out again, so keep them in case that save fails. + heldApproval = response to expected return } + // An approval for a sign-in context that has since changed can't be + // saved: release the claim before it cancels a password attempt made + // in the new context. Nothing suspends between this check and the claim. + if (!expected.isSameSession(current)) { + authCompleted = false + _deviceSignIn.value = TvDeviceSignInUi(status = TvSignInStatus.CouldntFinish) + return + } + // A password or network sign-in under way never settles now: this + // handoff frees its button when it ends (see settled). credentialLoginJob?.cancel() try { tokenManager.replaceAccountSession( @@ -211,9 +689,13 @@ class TvLoginViewModel( throw cancelled } catch (_: AccountSessionChangedException) { handleIdentityChanged() + _deviceSignIn.value = TvDeviceSignInUi(status = TvSignInStatus.CouldntFinish) return } catch (_: Throwable) { handleSessionPersistenceFailure(accessToken, refreshToken) + if (!_uiState.value.loginSuccess) { + _deviceSignIn.value = TvDeviceSignInUi(status = TvSignInStatus.CouldntFinish) + } return } // Tokens are committed outside persistSession here, so refresh the @@ -221,23 +703,31 @@ class TvLoginViewModel( authRepository.onSessionCommitted() if (tokenManager.captureAccountSessionExpectation()?.generation != expected.generation + 1) { handleIdentityChanged() + _deviceSignIn.value = TvDeviceSignInUi(status = TvSignInStatus.CouldntFinish) return } - _uiState.update { it.copy(isLoading = false, loginSuccess = true) } + _deviceSignIn.value = TvDeviceSignInUi( + status = TvSignInStatus.SignedIn, + accountName = response.user?.username?.takeIf { it.isNotBlank() }, + ) + // Let "Signed in as …" register before the profile picker replaces it. + delay(SIGNED_IN_DWELL_MS) + _uiState.update { it.settled(null).copy(loginSuccess = true) } } - private fun handleIdentityChanged() { + /** [attempt] null: the device code's approval, see [settled]. */ + private fun handleIdentityChanged(attempt: TokenSignIn? = null) { // Only release this screen's attempt. Credentials now belong to the new identity. authCompleted = false _uiState.update { - it.copy(isLoading = false, loginSuccess = false, - error = "The account or server changed. Start sign-in again.") + it.settled(attempt, TvLoginError.IdentityChanged).copy(loginSuccess = false) } } private suspend fun handleSessionPersistenceFailure( accessToken: String, refreshToken: String, + attempt: TokenSignIn? = null, ) { val committed = runCatching { tokenManager.getAccessToken() == accessToken && @@ -246,24 +736,155 @@ class TvLoginViewModel( if (committed) authRepository.onSessionCommitted() authCompleted = committed _uiState.update { - it.copy( - isLoading = false, - loginSuccess = committed, - error = if (committed) { - "Signed in, but local diagnostics cleanup did not finish." - } else { - "Unable to save this session. Try again." - }, - ) + it.settled(attempt, if (committed) TvLoginError.CleanupIncomplete else TvLoginError.SaveFailed) + .copy(loginSuccess = committed) } } + /** + * The password or network session committed: the code on screen must not + * be approvable any more. + */ + private fun tokenSignInWon() { + heldApproval = null + abandonDeviceCode() + } + + /** The password save failed without committing: carry on with the device code. */ + private fun resumeDeviceSignIn() { + if (authCompleted) return + val held = heldApproval + if (held != null) { + heldApproval = null + launchApproval(held.first, held.second) + return + } + val current = machine ?: return + if (visible && !current.isSettled && deviceLoginJob?.isActive != true) runMachine(current) + } + + override suspend fun codeForNearbyApproval(): DeviceLoginStartResponse? = + withTimeoutOrNull(NEARBY_CODE_WAIT_MS) { + combine(liveCode, _deviceSignIn) { code, ui -> code to ui.status } + .first { (code, status) -> (code != null && status in SHOWING_CODE) || status in ENDS_NEARBY_WAIT } + .let { (code, status) -> code.takeIf { status in SHOWING_CODE } } + } + + override suspend fun nearbyApprovalOutcome(deviceCode: String): NearbySignInOutcome = + combine(liveCode, _deviceSignIn) { code, ui -> nearbyOutcome(ui.status, code, deviceCode) } + .filterNotNull() + .first() + + private fun nearbyOutcome( + status: TvSignInStatus, + live: DeviceLoginStartResponse?, + deviceCode: String, + ): NearbySignInOutcome? = when (status) { + TvSignInStatus.SignedIn -> NearbySignInOutcome.SignedIn + TvSignInStatus.CouldntFinish -> NearbySignInOutcome.Failed(PairingFailureCode.AuthFailed) + TvSignInStatus.Denied -> NearbySignInOutcome.Failed(PairingFailureCode.Denied) + TvSignInStatus.UpdateRequired -> NearbySignInOutcome.Failed(PairingFailureCode.UpdateRequired) + TvSignInStatus.Paused, TvSignInStatus.Failed -> NearbySignInOutcome.Failed(PairingFailureCode.Expired) + TvSignInStatus.GettingCode, + TvSignInStatus.Waiting, + TvSignInStatus.Opened, + TvSignInStatus.NewCode, + TvSignInStatus.Unreachable, + TvSignInStatus.TooManyRequests, + -> if (live?.deviceCode == deviceCode) null else NearbySignInOutcome.Failed(PairingFailureCode.Expired) + } + private fun tryCompleteAuth(): Boolean { if (authCompleted) return false authCompleted = true return true } + /** + * The sign-in context, read again once if the first read races an + * identity transition (sign-out, expiry or server switch landing as this + * screen opens) rather than giving up silently (#421). + */ + private suspend fun captureExpectation(): AccountSessionExpectation? { + repeat(EXPECTATION_ATTEMPTS) { attempt -> + val expected = runCatching { tokenManager.captureAccountSessionExpectation() }.getOrNull() + if (expected != null) return expected + if (attempt < EXPECTATION_ATTEMPTS - 1) delay(EXPECTATION_RETRY_MS) + } + return null + } + + private fun abandonDeviceCode() { + deviceLoginJob?.cancel() + deviceLoginJob = null + liveCode.value = null + machine?.abandon(backgroundScope) + } + + private fun loadServerContext() { + val registry = serverRegistry ?: return + viewModelScope.launch { + val entry = registry.activeEntry.value ?: return@launch + _uiState.update { + it.copy( + serverName = entry.displayName, + serverHost = DeviceCodeFormat.host(entry.url), + serverActivateText = DeviceCodeFormat.activateText(entry.url.trimEnd('/') + "/activate", ""), + ) + } + refreshSignInOptions() + // Advertise st=login only with a verified identity: phones match a + // signed-out TV to their saved servers by identity, never by URL. + if (!advertisesSignIn) return@launch + val identity = serverIdentities?.identityOf(entry, refresh = true) ?: return@launch + _pairingAdvertisement.value = PairingAdvertisement.login( + serverIdentity = identity, + serverUrl = entry.url, + source = this@TvLoginViewModel, + ) + } + } + + /** + * Reads which sign-in methods the active server offers and whether the + * password form stays. Unknown options (a network failure) change nothing. + */ + private fun refreshSignInOptions() { + val discovery = externalSignIn ?: return + val entry = serverRegistry?.activeEntry?.value ?: return + signInOptionsJob?.cancel() + signInOptionsJob = viewModelScope.launch { + val options = discovery.signInOptions(entry.url) ?: return@launch + if (serverRegistry.activeEntry.value?.id != entry.id) return@launch + _uiState.update { + val passwordAvailable = options.showPasswordForm + it.copy( + passwordAvailable = passwordAvailable, + // The form's errors go with it when it closes, except the + // "turned off" refusal the screen then shows outside it; + // that one goes once the form can come back. + error = when { + passwordAvailable == it.passwordAvailable -> it.error + passwordAvailable -> null + else -> it.error.takeIf { error -> error == TvLoginError.LocalLoginDisabled } + }, + providerName = options.oauthProviders.firstOrNull()?.displayName, + directoryName = options.directoryProvider?.displayName, + networkProvider = options.networkProvider, + // A refusal belongs to its button; it goes once the button does. + networkError = it.networkError.takeIf { options.networkProvider != null }, + ) + } + } + } + + private fun DeviceLoginStartResponse.display() = TvSignInCode( + userCode = DeviceCodeFormat.display(userCode), + qrContent = verificationUriComplete, + activateText = DeviceCodeFormat.activateText(verificationUri, verificationUriComplete), + spokenCode = DeviceCodeFormat.spoken(userCode), + ) + /** Called by the screen from `LaunchedEffect(loginSuccess)` after routing. */ fun onLoginSuccessConsumed() { _uiState.update { it.copy(loginSuccess = false) } @@ -272,6 +893,30 @@ class TvLoginViewModel( override fun onCleared() { deviceLoginJob?.cancel() credentialLoginJob?.cancel() + // Leaving the screen: an abandoned code must not stay approvable. + machine?.abandon(backgroundScope) super.onCleared() } + + private companion object { + const val EXPECTATION_ATTEMPTS = 3 + const val EXPECTATION_RETRY_MS = 250L + const val NEW_CODE_ANNOUNCEMENT_MS = 5_000L + const val SIGNED_IN_DWELL_MS = 1_000L + + /** How long a nearby phone waits for a code that is still being fetched. */ + const val NEARBY_CODE_WAIT_MS = 15_000L + + val SHOWING_CODE = setOf(TvSignInStatus.Waiting, TvSignInStatus.Opened, TvSignInStatus.NewCode) + val ENDS_NEARBY_WAIT = setOf( + TvSignInStatus.SignedIn, + TvSignInStatus.CouldntFinish, + TvSignInStatus.Denied, + TvSignInStatus.Paused, + TvSignInStatus.Failed, + TvSignInStatus.UpdateRequired, + ) + + fun deviceName(): String = Build.MODEL?.trim()?.takeIf { it.isNotEmpty() } ?: "Android TV" + } } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvPairDeviceScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvPairDeviceScreen.kt index 393bed7124..b04a39f0b1 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvPairDeviceScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvPairDeviceScreen.kt @@ -21,6 +21,9 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Refresh import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import org.prairieserver.prairie.tv.R +import org.prairieserver.prairie.viewmodel.DevicePairingError import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -170,13 +173,13 @@ fun TvPairDeviceScreen( state.error?.let { error -> Spacer(Modifier.height(18.dp)) Text( - text = error, + text = error.message(), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, textAlign = TextAlign.Center, fontWeight = FontWeight.SemiBold, ) - if (error.startsWith("Sign in")) { + if (error == DevicePairingError.SignInFirst) { Spacer(Modifier.height(12.dp)) Button(onClick = onSignIn) { Text("Sign In") } } @@ -274,7 +277,12 @@ fun TvPairDeviceScreen( @Composable private fun PairingDetails(lookup: DeviceLoginLookupResponse) { Spacer(Modifier.height(18.dp)) - DetailRow(label = "Match", value = lookup.matchCode.orEmpty()) + // The code the other device shows, grouped 4+4; the match words are no + // longer shown anywhere. + DetailRow( + label = "Code", + value = lookup.userCode?.let(org.prairieserver.prairie.model.auth.DeviceCodeFormat::display).orEmpty(), + ) DetailRow(label = "Device", value = lookup.deviceName.orEmpty()) DetailRow(label = "Platform", value = lookup.devicePlatform.orEmpty()) lookup.ipAddressHint?.takeIf { it.isNotBlank() }?.let { DetailRow(label = "IP", value = it) } @@ -306,3 +314,18 @@ private fun DetailRow(label: String, value: String) { ) } } + +@Composable +private fun DevicePairingError.message(): String = when (this) { + DevicePairingError.EnterCode -> stringResource(R.string.tv_pair_error_enter_code) + DevicePairingError.SignInFirst -> stringResource(R.string.tv_pair_error_sign_in_first) + DevicePairingError.NotFound -> stringResource(R.string.tv_pair_error_not_found) + is DevicePairingError.NotFoundOn -> stringResource(R.string.tv_pair_error_not_found_on, serverName) + DevicePairingError.Expired -> stringResource(R.string.tv_pair_error_expired) + DevicePairingError.AlreadySignedIn -> stringResource(R.string.tv_pair_error_already_signed_in) + DevicePairingError.Declined -> stringResource(R.string.tv_pair_error_declined) + DevicePairingError.Canceled -> stringResource(R.string.tv_pair_error_canceled) + DevicePairingError.Network -> stringResource(R.string.tv_pair_error_network) + DevicePairingError.ProviderUnavailable -> stringResource(R.string.tv_pair_error_provider_unavailable) + is DevicePairingError.Server -> message ?: stringResource(R.string.tv_pair_error_generic) +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvPairingPanel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvPairingPanel.kt new file mode 100644 index 0000000000..3d54f592ba --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvPairingPanel.kt @@ -0,0 +1,378 @@ +package org.prairieserver.prairie.tv.ui.screens.auth + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Text +import org.prairieserver.prairie.common.pairing.PairingReceiverStatus +import org.prairieserver.prairie.model.auth.DeviceCodeFormat +import org.prairieserver.prairie.pairing.PairingFailureCode +import org.prairieserver.prairie.tv.R +import org.prairieserver.prairie.common.ui.marquee.MarqueeColors +import org.prairieserver.prairie.common.ui.marquee.rememberReduceMotion +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeBody +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeButton +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeButtonKind +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeCard +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeCardSymbol +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeCodeTiles +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeHeadline +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeScreen +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeStatusChip +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material.icons.outlined.PhoneAndroid +import kotlinx.coroutines.delay +import org.prairieserver.prairie.tv.ui.focus.TvContentInitialFocusMaxAttempts +import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved + +/** Whether the receiver is in a phone session the TV should show in place of its own screen. */ +internal val PairingReceiverStatus.isActivePairing: Boolean + get() = this is PairingReceiverStatus.Connected || + this is PairingReceiverStatus.ConsentRequested || + this is PairingReceiverStatus.Pairing || + this is PairingReceiverStatus.Unreachable || + this is PairingReceiverStatus.AwaitingApproval || + this is PairingReceiverStatus.SignedIn || + this is PairingReceiverStatus.Completed || + this is PairingReceiverStatus.Failed + +/** + * The nearby-phone flow on the TV, shown in place of server setup or the + * sign-in screen while a phone or tablet is connected. [signIn] is true on the + * sign-in screen (`st=login`): the TV already has its server and only the + * account is being signed in. People compare the TV's sign-in code; the match + * words are never shown. + */ +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +internal fun ActivePairingPanel( + status: PairingReceiverStatus, + onCancel: () -> Unit, + onContinue: () -> Unit, + onAllow: () -> Unit, + onDeny: () -> Unit, + onUseAlternate: () -> Unit, + onRetryAddress: () -> Unit, + signIn: Boolean = false, + modifier: Modifier = Modifier, +) { + val primaryFocus = remember { FocusRequester() } + var panelHasFocus by remember { mutableStateOf(false) } + // Every state with a decision puts focus on it; a prompt whose button + // never takes focus can't be answered from a remote at all. Waiting + // states don't, so a stray Select can't cancel the phone's session. + val focusKey = when (status) { + is PairingReceiverStatus.ConsentRequested -> "consent" + is PairingReceiverStatus.Unreachable -> "unreachable" + is PairingReceiverStatus.Failed -> "failed" + is PairingReceiverStatus.Completed -> "completed" + else -> null + } + LaunchedEffect(focusKey) { + if (focusKey != null) { + requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = primaryFocus::requestFocus, + isFocused = { panelHasFocus }, + ) + } + } + val eyebrow = when (status) { + is PairingReceiverStatus.SignedIn, is PairingReceiverStatus.Completed -> R.string.tv_pairing_eyebrow_done + is PairingReceiverStatus.Pairing, is PairingReceiverStatus.Unreachable, is PairingReceiverStatus.AwaitingApproval -> + R.string.tv_pairing_eyebrow_almost + else -> if (signIn) R.string.tv_pairing_eyebrow_signin else R.string.tv_pairing_eyebrow_connect + } + TvMarqueeScreen( + modifier = modifier.onFocusChanged { panelHasFocus = it.hasFocus }, + copy = { + TvMarqueeStatusChip(stringResource(eyebrow), icon = Icons.Outlined.PhoneAndroid, modifier = Modifier.padding(bottom = 18.dp)) + when (status) { + PairingReceiverStatus.Connected -> { + PanelTitle(stringResource(R.string.tv_pairing_connected_title)) + PanelDetail( + stringResource( + if (signIn) R.string.tv_pairing_connected_detail_signin else R.string.tv_pairing_connected_detail, + ), + ) + PanelActions { CancelButton(onCancel, primaryFocus) } + } + is PairingReceiverStatus.ConsentRequested -> { + PanelTitle( + stringResource(if (signIn) R.string.tv_pairing_consent_title_signin else R.string.tv_pairing_consent_title), + ) + PanelDetail(stringResource(R.string.tv_pairing_consent_detail, status.serverName)) + PanelActions { + TvMarqueeButton(text = stringResource(R.string.tv_pairing_allow), onClick = onAllow, focusRequester = primaryFocus) + TvMarqueeButton(text = stringResource(R.string.tv_pairing_dont_allow), onClick = onDeny, kind = TvMarqueeButtonKind.Plain) + } + } + is PairingReceiverStatus.Pairing -> { + PanelTitle( + stringResource(if (signIn) R.string.tv_pairing_starting_title_signin else R.string.tv_pairing_starting_title), + ) + PanelDetail(stringResource(R.string.tv_pairing_starting_detail)) + PanelActions { CancelButton(onCancel, primaryFocus) } + } + is PairingReceiverStatus.Unreachable -> { + PanelTitle(stringResource(R.string.tv_pairing_unreachable_title, status.serverName)) + val providerName = status.providerName + PanelDetail( + if (providerName != null) { + stringResource(R.string.tv_pairing_unreachable_provider, status.serverName, providerName) + } else { + stringResource( + R.string.tv_pairing_unreachable_public, + status.serverName, + DeviceCodeFormat.host(status.serverURL), + ) + }, + ) + PanelActions { + val alternate = status.alternateUrl + if (alternate != null) { + TvMarqueeButton( + text = stringResource(R.string.tv_pairing_use_alternate, DeviceCodeFormat.host(alternate)), + onClick = onUseAlternate, + focusRequester = primaryFocus, + ) + TvMarqueeButton( + text = stringResource(R.string.tv_pairing_try_again), + onClick = onRetryAddress, + kind = TvMarqueeButtonKind.Glass, + ) + } else { + TvMarqueeButton( + text = stringResource(R.string.tv_pairing_try_again), + onClick = onRetryAddress, + focusRequester = primaryFocus, + ) + } + CancelButton(onCancel) + } + } + is PairingReceiverStatus.AwaitingApproval -> { + PanelTitle( + stringResource( + if (status.automatic) R.string.tv_pairing_confirm_title_automatic else R.string.tv_pairing_confirm_title, + ), + ) + PanelDetail( + stringResource( + if (status.automatic) R.string.tv_pairing_confirm_detail_automatic else R.string.tv_pairing_confirm_detail, + ), + ) + PanelActions { CancelButton(onCancel, primaryFocus) } + } + is PairingReceiverStatus.SignedIn -> { + PanelTitle( + if (status.serverCount <= 1) { + stringResource(R.string.tv_pairing_signed_in) + } else { + stringResource(R.string.tv_pairing_signed_in_count, status.serverCount) + }, + ) + PanelDetail(stringResource(R.string.tv_pairing_finishing)) + } + is PairingReceiverStatus.Completed -> { + PanelTitle(stringResource(R.string.tv_pairing_completed_title)) + PanelDetail( + if (status.serverNames.isEmpty()) { + stringResource(R.string.tv_pairing_completed_none) + } else { + stringResource(R.string.tv_pairing_completed_named, status.serverNames.joinToString(", ")) + }, + ) + PanelActions { + TvMarqueeButton( + text = stringResource(R.string.tv_pairing_continue), + icon = Icons.AutoMirrored.Filled.ArrowForward, + onClick = onContinue, + focusRequester = primaryFocus, + ) + } + } + is PairingReceiverStatus.Failed -> { + PanelTitle( + stringResource(if (signIn) R.string.tv_pairing_failed_title_signin else R.string.tv_pairing_failed_title), + ) + Text( + text = stringResource(status.code.messageRes(), status.serverName), + color = MarqueeColors.Error, + fontSize = 14.sp, + lineHeight = 19.sp, + modifier = Modifier.padding(top = 13.dp), + ) + PanelActions { + TvMarqueeButton( + text = stringResource(R.string.tv_pairing_try_again), + onClick = onCancel, + focusRequester = primaryFocus, + ) + } + } + else -> Unit + } + }, + card = { + TvMarqueeCard { + when (status) { + is PairingReceiverStatus.AwaitingApproval -> { + if (status.userCode.isNotBlank()) { + Text( + stringResource(R.string.tv_signin_code_label), + color = MarqueeColors.InkTertiary, + fontSize = 14.sp, + ) + TvMarqueeCodeTiles(DeviceCodeFormat.display(status.userCode), Modifier.padding(top = 11.dp)) + } + olderPhonesMatchWords(status)?.let { words -> + Text( + text = stringResource(R.string.tv_pairing_confirm_older_phones, words), + color = MarqueeColors.InkTertiary, + fontSize = 14.sp, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 11.dp), + ) + } + ServerNameLabel(status.serverName, signIn) + WaitingDots(Modifier.padding(top = 15.dp)) + } + is PairingReceiverStatus.Pairing -> { + ServerNameLabel(status.serverName, signIn) + WaitingDots(Modifier.padding(top = 15.dp)) + } + is PairingReceiverStatus.SignedIn, is PairingReceiverStatus.Completed -> + TvMarqueeCardSymbol(Icons.Filled.Check, tint = MarqueeColors.Live, size = 70.dp) + is PairingReceiverStatus.Failed, is PairingReceiverStatus.Unreachable -> + TvMarqueeCardSymbol(Icons.Outlined.ErrorOutline, tint = MarqueeColors.Warning, size = 70.dp) + else -> { + TvMarqueeCardSymbol(Icons.Outlined.PhoneAndroid, size = 70.dp) + WaitingDots(Modifier.padding(top = 17.dp)) + } + } + } + }, + ) +} + +@Composable +private fun PanelActions(content: @Composable RowScope.() -> Unit) { + Row( + Modifier + .fillMaxWidth() + .padding(top = 24.dp), + horizontalArrangement = Arrangement.spacedBy(11.dp), + content = content, + ) +} + +@Composable +private fun CancelButton(onCancel: () -> Unit, focusRequester: FocusRequester? = null) { + TvMarqueeButton( + text = stringResource(R.string.tv_pairing_cancel), + onClick = onCancel, + kind = TvMarqueeButtonKind.Glass, + focusRequester = focusRequester, + ) +} + +/** + * The match words for the "Older phones show ..." line: only when a person + * compares codes (not an automatic attempt) and the words are known, in + * capitals like the phones show them. Remove with that line. + */ +internal fun olderPhonesMatchWords(status: PairingReceiverStatus.AwaitingApproval): String? = + status.matchCode?.trim()?.takeIf { !status.automatic && it.isNotEmpty() }?.uppercase() + +private fun PairingFailureCode.messageRes(): Int = when (this) { + PairingFailureCode.Denied -> R.string.tv_pairing_failed_denied + PairingFailureCode.Expired -> R.string.tv_pairing_failed_expired + PairingFailureCode.Unreachable -> R.string.tv_pairing_failed_unreachable + PairingFailureCode.IdentityMismatch -> R.string.tv_pairing_failed_identity + PairingFailureCode.UpdateRequired -> R.string.tv_pairing_failed_update + PairingFailureCode.AuthFailed -> R.string.tv_pairing_failed_generic +} + +@Composable +private fun PanelTitle(text: String) { + TvMarqueeHeadline(text, Modifier.semantics { liveRegion = LiveRegionMode.Polite }) +} + +@Composable +private fun PanelDetail(text: String) { + TvMarqueeBody(text, size = 16.sp, modifier = Modifier.padding(top = 13.dp)) +} + +@Composable +private fun ServerNameLabel(serverName: String, signIn: Boolean) { + Text( + text = stringResource(if (signIn) R.string.tv_pairing_server_label_signin else R.string.tv_pairing_server_label), + color = MarqueeColors.InkTertiary, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 1.5.sp, + modifier = Modifier.padding(top = 17.dp), + ) + Text( + text = serverName, + color = MarqueeColors.Ink, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 4.dp), + ) +} + +/** Pulsing dots while something happens on the phone. */ +@Composable +private fun WaitingDots(modifier: Modifier = Modifier, count: Int = 5) { + val reduceMotion = rememberReduceMotion() + var phase by remember { mutableStateOf(if (reduceMotion) count / 2 else 0) } + if (!reduceMotion) { + LaunchedEffect(Unit) { + while (true) { + delay(280) + phase = (phase + 1) % (count + 1) + } + } + } + Row(modifier, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + repeat(count) { index -> + Box( + Modifier + .size(6.dp) + .background(MarqueeColors.Ink.copy(alpha = if (index <= phase) 0.9f else 0.22f), RoundedCornerShape(999.dp)), + ) + } + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvServerSetupScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvServerSetupScreen.kt index 53af5b34e6..3bd00f20fa 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvServerSetupScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvServerSetupScreen.kt @@ -1,43 +1,24 @@ package org.prairieserver.prairie.tv.ui.screens.auth -import androidx.compose.animation.core.EaseOut -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.StartOffset -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.focusable +import android.os.Build +import android.provider.Settings +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.Smartphone +import androidx.compose.material.icons.outlined.Language +import androidx.compose.material.icons.outlined.Tv import androidx.compose.material3.AlertDialog -import androidx.compose.material3.OutlinedTextField import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -45,72 +26,60 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.input.InputMode -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInputModeManager -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.Icon -import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import kotlinx.coroutines.delay import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.common.pairing.PairingAdvertisement import org.prairieserver.prairie.common.pairing.PairingReceiver import org.prairieserver.prairie.common.pairing.PairingReceiverStatus import org.prairieserver.prairie.common.pairing.TvPairingAdvertiser +import org.prairieserver.prairie.common.ui.marquee.MarqueeColors +import org.prairieserver.prairie.common.ui.marquee.MarqueeScene +import org.prairieserver.prairie.common.ui.marquee.ServerBranding +import org.prairieserver.prairie.common.ui.marquee.SignInHandoff import org.prairieserver.prairie.tv.R -import org.prairieserver.prairie.tv.ui.components.AuroraAccent -import org.prairieserver.prairie.tv.ui.components.AuroraEyebrow -import org.prairieserver.prairie.tv.ui.components.AuroraGhostButton -import org.prairieserver.prairie.tv.ui.components.AuroraInk -import org.prairieserver.prairie.tv.ui.components.AuroraJourneyProgress -import org.prairieserver.prairie.tv.ui.components.AuroraPrimaryButton -import org.prairieserver.prairie.tv.ui.components.TvAuroraBackdrop -import org.prairieserver.prairie.tv.ui.components.TvAuroraVariant import org.prairieserver.prairie.tv.ui.components.TvHideStockImeOnDispose -import org.prairieserver.prairie.tv.ui.components.auroraGlass +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeBody +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeButton +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeButtonKind +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeErrorText +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeField +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeHeadline +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeScreen +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeSetupSteps +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeStatusChip +import org.prairieserver.prairie.tv.ui.components.marquee.TvUsePhoneCard import org.prairieserver.prairie.tv.ui.components.rememberTvImeAwareFormScrollState import org.prairieserver.prairie.tv.ui.components.tvImeAwareFieldContext -import org.prairieserver.prairie.tv.ui.components.tvShowImeOnSelect -import org.prairieserver.prairie.tv.ui.components.TvAuthFormDefaults -import org.prairieserver.prairie.tv.ui.components.tvOutlinedTextFieldColors import org.prairieserver.prairie.tv.ui.focus.TvContentInitialFocusMaxAttempts import org.prairieserver.prairie.tv.ui.focus.TvFocusLog import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved -import org.prairieserver.prairie.tv.ui.theme.Spacing /** - * Server setup — connects the app to a Silo server. - * - * While idle, this mirrors tvOS `TVServerSetupView`, scaled for the Shield's - * ~960×540dp canvas (the iOS source is laid out in 1920×1080 points): the - * wordmark sits top-left in normal flow, a centered eyebrow + title header - * block sits below it, and the phone-card / OR / manual-card chooser is - * centered in the remaining vertical space via a `weight(1f)` box. There are - * no fixed row heights and no negative offsets — the weight box is what keeps - * the title clear of the eyebrow and the phone headline clear of its - * description. Once a phone connects, the chooser swaps in-place to the live - * pairing receiver flow. + * First-run server setup on Android TV (prairie-apple `TVServerSetupView`). The + * screen advertises on the LAN the moment it appears, so the lead path is a + * nearby phone or tablet setting this TV up with nothing to type, shown as + * three numbered steps. Typing the address is one button away, under "No + * phone nearby?". When a phone connects, the screen swaps in place to the + * pairing receiver. */ @OptIn(ExperimentalTvMaterial3Api::class) @Composable @@ -118,58 +87,39 @@ fun TvServerSetupScreen( onContinueToLogin: (signupEnabled: Boolean) -> Unit, onNeedsSetup: () -> Unit, onPairedSignIn: () -> Unit = {}, + /** A saved server this TV is still signed in to: straight to its profiles. */ + onSignedIn: () -> Unit = onPairedSignIn, viewModel: TvServerSetupViewModel = koinViewModel(), pairingReceiver: PairingReceiver = koinInject(), pairingAdvertiser: TvPairingAdvertiser = koinInject(), ) { val state by viewModel.uiState.collectAsState() val pairingStatus by pairingReceiver.status.collectAsState() - val focusRequester = remember { FocusRequester() } - val phoneSetupFocus = remember { FocusRequester() } - var phoneCardHasFocus by remember { mutableStateOf(false) } - val formScrollState = rememberTvImeAwareFormScrollState() val isActivePairing = pairingStatus.isActivePairing + var enteringAddress by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(Unit) { MarqueeScene.showGeneric() } // Companion LAN pairing: advertise `_silopair._tcp` while this screen is on // so a phone running Silo can push the server URL + drive device-login, // sparing the viewer from typing a URL on the remote. Advertising stops // when the screen leaves the composition. + var advertiserToken by remember { mutableStateOf(0L) } DisposableEffect(Unit) { - pairingAdvertiser.start() - onDispose { pairingAdvertiser.stop() } + val token = pairingAdvertiser.start(PairingAdvertisement.Setup) + advertiserToken = token + onDispose { pairingAdvertiser.stop(token) } } - // Snapshot-backed: re-keys the claim when the viewer switches between - // pointer and key input. - val inputMode = LocalInputModeManager.current.inputMode - LaunchedEffect(isActivePairing, inputMode) { - // Pointer users click what they want — and in touch mode the claim - // could not land anyway. Re-run on mode flip so the D-pad always has - // somewhere to start. - if (!isActivePairing && inputMode != InputMode.Touch) { - // Land on the phone-pairing card: companion setup is the - // recommended path, so it gets first focus (product call - // 2026-08-14, reversing the 2026-07-10 field-first default). - // Landing on the URL field also popped the IME over the form, - // and the IME resize scrolled the header chrome off-screen. - TvFocusLog.d { "serverSetup: claiming phone card (mode=$inputMode)" } - val result = requestFocusUntilObserved( - maxAttempts = TvContentInitialFocusMaxAttempts, - awaitAttempt = { withFrameNanos { } }, - requestFocus = phoneSetupFocus::requestFocus, - isFocused = { phoneCardHasFocus }, - ) - TvFocusLog.d { "serverSetup: claim result=$result" } - } else { - TvFocusLog.d { - "serverSetup: claim skipped (pairing=$isActivePairing, mode=$inputMode)" - } - } + val finishPairing = { + pairingAdvertiser.stop(advertiserToken) + // A one-profile household with no PIN goes straight to Home. + SignInHandoff.skipsSingleProfilePicker = true + onPairedSignIn() } LaunchedEffect(pairingStatus) { if (pairingStatus is PairingReceiverStatus.Completed) { delay(1_800) - pairingAdvertiser.stop() - onPairedSignIn() + finishPairing() } } @@ -183,6 +133,10 @@ fun TvServerSetupScreen( viewModel.onNavigationConsumed() onContinueToLogin(dest.signupEnabled) } + TvServerSetupDestination.Profiles -> { + viewModel.onNavigationConsumed() + onSignedIn() + } null -> Unit } } @@ -190,398 +144,219 @@ fun TvServerSetupScreen( state.pendingCleartextUrl?.let { origin -> AlertDialog( onDismissRequest = viewModel::cancelCleartextConnection, - title = { Text("Use unencrypted HTTP?") }, + title = { androidx.compose.material3.Text("Connect without encryption?") }, text = { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text(origin, fontWeight = FontWeight.SemiBold) - Text( - "This connection is not encrypted. Anyone on the network may see or change " + - "traffic, including your sign-in. Continue only on a network you trust.", - ) - } + androidx.compose.material3.Text( + "Your password and what you watch will be sent unencrypted to " + + "${ServerBranding.hostLabel(origin)}. Only do this on a network you trust.", + ) }, confirmButton = { - AuroraPrimaryButton( - label = "Use HTTP", + TvMarqueeButton( + text = "Connect", onClick = viewModel::confirmCleartextConnection, - modifier = Modifier.width(180.dp), enabled = !state.isLoading, ) }, dismissButton = { - AuroraGhostButton( - label = "Cancel", + TvMarqueeButton( + text = "Cancel", onClick = viewModel::cancelCleartextConnection, + kind = TvMarqueeButtonKind.Plain, ) - }, + }, containerColor = Color(0xFF1C1C1E), + titleContentColor = MarqueeColors.Ink, + textContentColor = MarqueeColors.InkSecondary, ) } - Box( - modifier = Modifier - .fillMaxSize() - .imePadding(), - ) { - TvAuroraBackdrop(variant = TvAuroraVariant.Server) - - if (isActivePairing) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(Spacing.xl), - contentAlignment = Alignment.Center, - ) { - ActivePairingPanel( + Box(Modifier.fillMaxSize().imePadding()) { + AnimatedContent( + targetState = when { + isActivePairing -> SetupPage.Pairing + enteringAddress -> SetupPage.Address + else -> SetupPage.Phone + }, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "tvServerSetupPage", + ) { page -> + when (page) { + SetupPage.Pairing -> ActivePairingPanel( status = pairingStatus, onCancel = pairingReceiver::cancelActiveSession, - onContinue = { - pairingAdvertiser.stop() - onPairedSignIn() - }, + onContinue = finishPairing, onAllow = pairingReceiver::allowPendingServer, onDeny = pairingReceiver::denyPendingServer, - // tvOS renders pairing states directly on the Aurora backdrop - // in an 880pt content column. Shield uses a 2x density, so - // 440dp produces the same 880px maximum footprint. - modifier = Modifier.widthIn(max = 440.dp), + onUseAlternate = pairingReceiver::useAlternateAddress, + onRetryAddress = pairingReceiver::retryPushedAddress, + ) + SetupPage.Phone -> PhoneFirst(onEnterAddress = { enteringAddress = true }) + SetupPage.Address -> AddressEntry( + state = state, + onServerUrlChanged = viewModel::onServerUrlChanged, + onConnect = viewModel::onConnectClick, + // Leaving mid-probe would let a late connect pull the app on. + onBack = { if (!state.isLoading) enteringAddress = false }, ) - } - } else { - Column( - modifier = Modifier - .align(Alignment.TopCenter) - .fillMaxSize() - .verticalScroll(formScrollState) - .padding(horizontal = 48.dp, vertical = 32.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - BrandHeader() - AuroraJourneyProgress( - currentStep = 1, - modifier = Modifier.width(215.dp), - ) - } - - Spacer(modifier = Modifier.height(Spacing.lg)) - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(Spacing.sm), - modifier = Modifier.fillMaxWidth(), - ) { - AuroraEyebrow(text = "Connect") - Text( - text = "Connect this Android TV", - style = TvServerSetupTextStyles.Title, - color = Color.White, - ) - Text( - text = "Use your phone, or enter the server address with the remote.", - style = TvServerSetupTextStyles.PairingDetail, - color = Color.White.copy(alpha = 0.72f), - textAlign = TextAlign.Center, - ) - } - - // Keep the chooser at a real card height even while the - // platform IME resizes the activity. Without the scrollable, - // top-anchored container, Android TV can squeeze the field's - // editable text line to a few pixels and make typed input - // appear blank. - Box( - modifier = Modifier - .fillMaxWidth() - .padding(top = Spacing.lg), - contentAlignment = Alignment.Center, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .widthIn(max = 642.dp) - .fillMaxWidth() - // Intrinsic-min, floored — not a bare heightIn and - // not an exact height. Both of those fail, in - // opposite directions: - // - a loose max makes the cards' fillMaxHeight a - // no-op, so the phone card collapses to its pill - // and the weight(1f) beacon box measures zero; - // - an exact height clips the taller card, which - // at 300dp squeezed "Connect to server" down to - // a blank pill (label measured 6px in a 96px - // button). - // Resolving the intrinsic first hands the Row a - // tight height, so fillMaxHeight still resolves, - // while the floor keeps the chooser at a real card - // height when content is short. tvOS pins 580pt; - // here the content decides above that floor. - .height(IntrinsicSize.Min) - .heightIn(min = SERVER_SETUP_CHOOSER_MIN_HEIGHT), - ) { - PhoneSetupCard( - focusRequester = phoneSetupFocus, - modifier = Modifier - .onFocusChanged { phoneCardHasFocus = it.hasFocus } - .weight(1f) - .fillMaxHeight(), - ) - OrDivider( - modifier = Modifier - .width(42.dp) - .fillMaxHeight(), - ) - ManualEntryCard( - state = state, - onServerUrlChanged = viewModel::onServerUrlChanged, - onConnectClick = viewModel::onConnectClick, - focusRequester = focusRequester, - modifier = Modifier - .weight(1f) - .fillMaxHeight(), - ) - } - } } } } } -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun PhoneSetupCard( - modifier: Modifier = Modifier, - focusRequester: FocusRequester? = null, -) { - // Focusable so FIRST sign-in can land here instead of committing the user - // to the manual URL path — the card itself needs no click action (the TV - // is already advertising; the phone drives the rest). - var focused by remember { mutableStateOf(false) } - Column( - verticalArrangement = Arrangement.spacedBy(Spacing.sm), - modifier = modifier - .let { m -> focusRequester?.let { m.focusRequester(it) } ?: m } - .onFocusChanged { focused = it.isFocused } - .focusable() - .auroraGlass(16.dp) - .border( - width = 2.dp, - color = if (focused) Color.White.copy(alpha = 0.55f) else Color.Transparent, - shape = RoundedCornerShape(16.dp), - ) - .padding(24.dp), - ) { - // Top-leading pill, matching tvOS TVServerSetupView.phoneCard. - Text( - text = "RECOMMENDED · USE PHONE", - style = TvServerSetupTextStyles.Pill, - color = Color.White.copy(alpha = 0.70f), - modifier = Modifier - .background(Color.White.copy(alpha = 0.08f), RoundedCornerShape(50)) - .border(1.dp, Color.White.copy(alpha = 0.14f), RoundedCornerShape(50)) - .padding(horizontal = 14.dp, vertical = 7.dp), - ) +private enum class SetupPage { Phone, Address, Pairing } - Box( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - contentAlignment = Alignment.Center, - ) { - PhoneSetupBody() +/** This TV's name, as the person named it in Android TV settings. */ +@Composable +private fun rememberTvName(): String { + val context = LocalContext.current + return remember(context) { + // DEVICE_NAME arrived in API 25; older TVs show the generic name. + val named = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { + Settings.Global.getString(context.contentResolver, Settings.Global.DEVICE_NAME) + } else { + null } + named?.takeIf { it.isNotBlank() } ?: "Android TV" } } +/** + * Lands focus on [requester] for D-pad users once the page is up, and keeps + * asking for a moment: a request made while the previous page is still + * fading out can be dropped. + */ @Composable -private fun PhoneSetupBody(modifier: Modifier = Modifier) { - // Beacon centered, copy left-aligned beneath it — mirrors tvOS - // TVServerSetupView.phoneCard (iPhone → phone). - Column( - verticalArrangement = Arrangement.spacedBy(10.dp), - modifier = modifier.fillMaxWidth(), - ) { - SearchingBeacon( - modifier = Modifier - .size(PHONE_SETUP_BEACON_SIZE) - .align(Alignment.CenterHorizontally), - ) - - Text( - text = "Looking for a phone…", - style = TvServerSetupTextStyles.Headline, - color = Color.White, - ) - Text( - text = "Open Prairie on a phone connected to the same Wi-Fi. Accept the " + - "setup card and Prairie will securely bring over the server and account.", - style = TvServerSetupTextStyles.PairingDetail, - color = Color.White.copy(alpha = 0.72f), - maxLines = 4, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(), +internal fun ClaimInitialFocus(requester: FocusRequester, isFocused: () -> Boolean, tag: String) { + val inputMode = LocalInputModeManager.current.inputMode + LaunchedEffect(inputMode) { + // Pointer users click what they want, and in touch mode the claim + // could not land anyway. + if (inputMode == InputMode.Touch) return@LaunchedEffect + val result = requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = requester::requestFocus, + isFocused = isFocused, ) + TvFocusLog.d { "$tag: claim result=$result" } } } -private val PHONE_SETUP_BEACON_SIZE = 96.dp - -/** - * Floor for the phone/manual chooser. The manual card's intrinsic height - * normally exceeds this; the floor only matters when it doesn't, keeping the - * phone card from collapsing to its pill. - */ -private val SERVER_SETUP_CHOOSER_MIN_HEIGHT = 300.dp +@Composable +private fun PhoneFirst(onEnterAddress: () -> Unit) { + val enterAddressFocus = remember { FocusRequester() } + var enterAddressFocused by remember { mutableStateOf(false) } + ClaimInitialFocus(enterAddressFocus, { enterAddressFocused }, "serverSetup.phone") + TvMarqueeScreen( + accessory = { TvMarqueeStatusChip(rememberTvName(), icon = Icons.Outlined.Tv) }, + copy = { + TvMarqueeStatusChip( + stringResource(R.string.tv_setup_phone_looking), + showsSpinner = true, + modifier = Modifier.padding(bottom = 18.dp), + ) + TvMarqueeHeadline("Set up with\nyour phone") + TvMarqueeBody( + "It's the easiest way. There's nothing to type with the remote.", + size = 16.sp, + modifier = Modifier.padding(top = 14.dp), + ) + Text( + "No phone nearby?", + color = MarqueeColors.InkTertiary, + fontSize = 14.sp, + modifier = Modifier.padding(top = 32.dp), + ) + Row(Modifier.fillMaxWidth().padding(top = 9.dp)) { + TvMarqueeButton( + text = "Enter server address", + onClick = onEnterAddress, + kind = TvMarqueeButtonKind.Glass, + icon = Icons.Outlined.Language, + focusRequester = enterAddressFocus, + modifier = Modifier.onFocusChanged { enterAddressFocused = it.isFocused }, + ) + } + }, + card = { TvMarqueeSetupSteps() }, + ) +} -@OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun ManualEntryCard( +private fun AddressEntry( state: TvServerSetupUiState, onServerUrlChanged: (String) -> Unit, - onConnectClick: () -> Unit, - focusRequester: FocusRequester, - modifier: Modifier = Modifier, + onConnect: () -> Unit, + onBack: () -> Unit, ) { TvHideStockImeOnDispose() - Column( - verticalArrangement = Arrangement.spacedBy(Spacing.sm), - modifier = modifier - .auroraGlass(16.dp, emphasized = true) - .padding(24.dp), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .tvImeAwareFieldContext(), - verticalArrangement = Arrangement.spacedBy(Spacing.sm), - ) { - Text( - text = "Enter the server address", - style = TvServerSetupTextStyles.Headline, - color = Color.White, - ) - - Text( - text = "SERVER ADDRESS", - style = TvServerSetupTextStyles.InputLabel, - color = Color.White.copy(alpha = 0.52f), - ) - - OutlinedTextField( - value = state.serverUrl, - onValueChange = onServerUrlChanged, - placeholder = { - Text( - text = "silo.example.com", - style = TvServerSetupTextStyles.FieldText, - ) - }, - singleLine = true, - textStyle = TvServerSetupTextStyles.FieldText, - keyboardOptions = KeyboardOptions( + BackHandler(onBack = onBack) + val fieldFocus = remember { FocusRequester() } + var fieldFocused by remember { mutableStateOf(false) } + ClaimInitialFocus(fieldFocus, { fieldFocused }, "serverSetup.address") + val connect = { if (canSubmitTvServerUrl(state.serverUrl, state.isLoading)) onConnect() } + TvMarqueeScreen( + copyScroll = rememberTvImeAwareFormScrollState(), + accessory = { TvMarqueeStatusChip(rememberTvName(), icon = Icons.Outlined.Tv) }, + copy = { + TvMarqueeHeadline("Enter your\nserver address") + TvMarqueeBody("Type the address you use for Prairie.", modifier = Modifier.padding(top = 11.dp)) + Box(Modifier.width(380.dp).padding(top = 20.dp).tvImeAwareFieldContext()) { + TvMarqueeField( + value = state.serverUrl, + onValueChange = onServerUrlChanged, + icon = Icons.Outlined.Language, + placeholder = "media.example.com", keyboardType = KeyboardType.Uri, imeAction = ImeAction.Go, - showKeyboardOnFocus = false, - ), - keyboardActions = KeyboardActions( - onGo = { - if (canSubmitTvServerUrl(state.serverUrl, state.isLoading)) { - onConnectClick() - } - }, - ), - enabled = !state.isLoading, - modifier = Modifier - .fillMaxWidth() - .height(TvAuthFormDefaults.FieldHeight) - .tvShowImeOnSelect() - .focusRequester(focusRequester), - colors = tvOutlinedTextFieldColors(), - ) - } - - UrlShortcutRow( - enabled = !state.isLoading, - onShortcut = { shortcut -> - onServerUrlChanged(applyServerUrlShortcut(state.serverUrl, shortcut)) - }, - ) - - if (state.error != null) { - Text( - text = state.error, - style = TvServerSetupTextStyles.Error, - color = MaterialTheme.colorScheme.error, - ) - } else if (state.usesCleartext) { + onImeAction = connect, + isError = state.error != null, + // Fixed while the address is probed, so what's shown is + // what's connecting. + enabled = !state.isLoading, + focusRequester = fieldFocus, + onFocusChange = { fieldFocused = it }, + ) + } + state.error?.let { TvMarqueeErrorText(it, Modifier.width(380.dp).padding(top = 8.dp)) } + // Typing aids for the remote's keyboard. + Row(Modifier.fillMaxWidth().padding(top = 11.dp), horizontalArrangement = Arrangement.spacedBy(7.dp)) { + ServerUrlShortcuts.forEach { shortcut -> + TvMarqueeButton( + text = shortcut, + onClick = { if (!state.isLoading) onServerUrlChanged(applyServerUrlShortcut(state.serverUrl, shortcut)) }, + kind = TvMarqueeButtonKind.Glass, + compact = true, + ) + } + } Text( - text = "This server uses unencrypted HTTP. Fine on a trusted home network; " + - "avoid it on public Wi-Fi.", - style = TvServerSetupTextStyles.Error, - color = MaterialTheme.colorScheme.onSurfaceVariant, + if (state.usesCleartext) { + "This address uses unencrypted HTTP. Fine on a trusted home network; avoid it on public Wi-Fi." + } else { + "Secure HTTPS is tried automatically." + }, + color = if (state.usesCleartext) MarqueeColors.Warning else MarqueeColors.InkTertiary, + fontSize = 14.sp, + modifier = Modifier.width(380.dp).padding(top = 9.dp), ) - } else { - // Mirrors tvOS's lock.shield reassurance line. Truthful here too: - // bare hosts probe https:// first and fall to http:// only when - // the viewer typed it (probeTvServerSetupCandidates). - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Spacing.sm), - ) { - Icon( - imageVector = Icons.Filled.Lock, - contentDescription = null, - tint = Color.White.copy(alpha = 0.72f), - modifier = Modifier.size(14.dp), + Row(Modifier.fillMaxWidth().padding(top = 20.dp), horizontalArrangement = Arrangement.spacedBy(11.dp)) { + TvMarqueeButton( + text = if (state.isLoading) "Connecting…" else "Connect", + onClick = connect, + isLoading = state.isLoading, ) - Text( - text = "Secure HTTPS is tried automatically.", - style = TvServerSetupTextStyles.PairingDetail, - color = Color.White.copy(alpha = 0.72f), + TvMarqueeButton( + text = "Back", + onClick = onBack, + kind = TvMarqueeButtonKind.Plain, ) } - } - - Box { - AuroraPrimaryButton( - label = if (state.isLoading) "Connecting…" else "Connect to server", - icon = null, - enabled = canSubmitTvServerUrl(state.serverUrl, state.isLoading), - onClick = { - if (canSubmitTvServerUrl(state.serverUrl, state.isLoading)) { - onConnectClick() - } - }, - modifier = Modifier - .fillMaxWidth() - .height(TvAuthFormDefaults.PrimaryButtonHeight), - ) - } - } -} - -@Composable -private fun UrlShortcutRow( - enabled: Boolean, - onShortcut: (String) -> Unit, - modifier: Modifier = Modifier, -) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier.fillMaxWidth(), - ) { - ServerUrlShortcuts.forEach { shortcut -> - AuroraGhostButton( - label = shortcut, - onClick = { if (enabled) onShortcut(shortcut) }, - fontSize = 16.sp, - horizontalPadding = 12.dp, - verticalPadding = 6.dp, - ) - } - } + }, + card = { + TvUsePhoneCard("Open Prairie on a phone or tablet on the same Wi‑Fi and tap Set up. It finishes this for you.") + }, + ) } private val ServerUrlShortcuts = listOf("https://", "http://", ".com") @@ -593,485 +368,3 @@ private fun applyServerUrlShortcut(value: String, shortcut: String): String = shortcut.endsWith("://") -> shortcut + value.trimStart() else -> value + shortcut } - -@Composable -private fun OrDivider(modifier: Modifier = Modifier) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = modifier, - ) { - // Hairlines fade toward the screen edges, matching tvOS orDivider. - Box( - modifier = Modifier - .width(1.dp) - .weight(1f) - .background( - Brush.verticalGradient( - listOf(Color.Transparent, Color.White.copy(alpha = 0.16f)), - ), - ), - ) - Text( - text = "OR", - style = TvServerSetupTextStyles.Pill, - color = Color.White.copy(alpha = 0.50f), - modifier = Modifier.padding(vertical = 14.dp), - ) - Box( - modifier = Modifier - .width(1.dp) - .weight(1f) - .background( - Brush.verticalGradient( - listOf(Color.White.copy(alpha = 0.16f), Color.Transparent), - ), - ), - ) - } -} - -/** - * Pulsing "searching for a phone" beacon — three gold rings expanding outward - * behind a phone glyph. Mirrors tvOS `SearchingBeacon`, scaled by the caller. - */ -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun SearchingBeacon(modifier: Modifier = Modifier) { - val transition = rememberInfiniteTransition(label = "phoneBeacon") - Box( - contentAlignment = Alignment.Center, - modifier = modifier, - ) { - repeat(3) { index -> - val progress by transition.animateFloat( - initialValue = 0f, - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 2400, easing = EaseOut), - repeatMode = RepeatMode.Restart, - initialStartOffset = StartOffset(index * 800), - ), - label = "phoneBeaconRing$index", - ) - val ringScale = 0.6f + (1.7f - 0.6f) * progress - val ringAlpha = (1f - progress) * 0.55f - Box( - modifier = Modifier - .size(54.dp) - .graphicsLayer { - scaleX = ringScale - scaleY = ringScale - alpha = ringAlpha - } - .border(2.dp, AuroraAccent, CircleShape), - ) - } - Icon( - imageVector = Icons.Default.Smartphone, - contentDescription = null, - tint = AuroraInk, - modifier = Modifier.size(36.dp), - ) - } -} - -private val PairingReceiverStatus.isActivePairing: Boolean - get() = this is PairingReceiverStatus.Connected || - this is PairingReceiverStatus.ConsentRequested || - this is PairingReceiverStatus.Pairing || - this is PairingReceiverStatus.AwaitingApproval || - this is PairingReceiverStatus.SignedIn || - this is PairingReceiverStatus.Completed || - this is PairingReceiverStatus.Failed - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun ActivePairingPanel( - status: PairingReceiverStatus, - onCancel: () -> Unit, - onContinue: () -> Unit, - onAllow: () -> Unit = {}, - onDeny: () -> Unit = {}, - modifier: Modifier = Modifier, -) { - val allowFocusRequester = remember { FocusRequester() } - var consentHasFocus by remember { mutableStateOf(false) } - LaunchedEffect(status) { - if (status is PairingReceiverStatus.ConsentRequested) { - // A consent prompt whose Allow button never takes focus cannot be - // answered from a remote at all. - requestFocusUntilObserved( - maxAttempts = TvContentInitialFocusMaxAttempts, - awaitAttempt = { withFrameNanos { } }, - requestFocus = allowFocusRequester::requestFocus, - isFocused = { consentHasFocus }, - ) - } - } - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(Spacing.md), - modifier = modifier - .onFocusChanged { consentHasFocus = it.hasFocus } - .fillMaxWidth(), - ) { - when (status) { - PairingReceiverStatus.Connected -> { - AuroraEyebrow(text = "Step 01 — Connect") - Text( - text = "Phone connected", - style = TvServerSetupTextStyles.PairingTitle, - color = Color.White, - ) - WaitingDots() - Text( - text = "On your phone, choose which servers this TV should sign in to.", - style = TvServerSetupTextStyles.PairingDetail, - color = Color.White.copy(alpha = 0.72f), - ) - AuroraGhostButton(label = "Cancel", onClick = onCancel) - } - is PairingReceiverStatus.ConsentRequested -> { - AuroraEyebrow(text = "Step 01 — Connect") - Text( - text = "Allow this setup?", - style = TvServerSetupTextStyles.PairingTitle, - color = Color.White, - ) - Text( - text = "A nearby phone wants to sign this TV in to ${status.serverName}.", - style = TvServerSetupTextStyles.PairingDetail, - color = Color.White.copy(alpha = 0.72f), - ) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(Spacing.sm), - modifier = Modifier.padding(top = Spacing.sm), - ) { - AuroraPrimaryButton( - label = "Allow", - onClick = onAllow, - focusRequester = allowFocusRequester, - modifier = Modifier - .width(180.dp) - .height(60.dp), - ) - AuroraGhostButton( - label = "Don\u2019t allow", - onClick = onDeny, - modifier = Modifier - .width(180.dp) - .height(60.dp), - ) - } - } - is PairingReceiverStatus.Pairing -> { - AuroraEyebrow(text = "Almost there") - Text( - text = "Setting up this TV", - style = TvServerSetupTextStyles.PairingTitle, - color = Color.White, - ) - ServerNameLabel(status.serverName) - WaitingDots(compact = true) - Text( - text = "Starting secure sign-in…", - style = TvServerSetupTextStyles.PairingDetail, - color = Color.White.copy(alpha = 0.72f), - ) - AuroraGhostButton(label = "Cancel", onClick = onCancel) - } - is PairingReceiverStatus.AwaitingApproval -> { - AuroraEyebrow(text = "Almost there") - Text( - text = "Confirm on your phone", - style = TvServerSetupTextStyles.PairingTitle, - color = Color.White, - ) - MatchCodeCard(code = status.matchCode) - ServerNameLabel(status.serverName) - WaitingDots(compact = true) - Text( - text = "Make sure your phone shows this same code before approving sign-in.", - style = TvServerSetupTextStyles.PairingDetail, - color = Color.White.copy(alpha = 0.72f), - ) - AuroraGhostButton(label = "Cancel", onClick = onCancel) - } - is PairingReceiverStatus.SignedIn -> { - AuroraEyebrow(text = "All set") - SuccessMark() - Text( - text = if (status.serverCount <= 1) "Signed in" else "Signed in to ${status.serverCount} servers", - style = TvServerSetupTextStyles.PairingTitle, - color = Color.White, - ) - WaitingDots(compact = true) - Text( - text = "Finishing up on your phone…", - style = TvServerSetupTextStyles.PairingDetail, - color = Color.White.copy(alpha = 0.72f), - ) - } - is PairingReceiverStatus.Completed -> { - AuroraEyebrow(text = "All set") - SuccessMark() - Text( - text = "You’re all set", - style = TvServerSetupTextStyles.PairingTitle, - color = Color.White, - ) - Text( - text = completedSummary(status.serverNames), - style = TvServerSetupTextStyles.PairingDetail, - color = Color.White.copy(alpha = 0.72f), - ) - AuroraPrimaryButton( - label = "Continue", - icon = Icons.AutoMirrored.Filled.ArrowForward, - onClick = onContinue, - modifier = Modifier.width(320.dp), - ) - } - is PairingReceiverStatus.Failed -> { - AuroraEyebrow(text = "Step 01 — Connect") - Text( - text = "Setup didn’t finish", - style = TvServerSetupTextStyles.PairingTitle, - color = Color.White, - ) - Text( - text = "Something went wrong setting up ${status.serverName}. Try again from your phone, or set up your server manually.", - style = TvServerSetupTextStyles.PairingDetail, - color = MaterialTheme.colorScheme.error, - ) - AuroraPrimaryButton( - label = "Try again", - onClick = onCancel, - modifier = Modifier.width(320.dp), - ) - } - else -> Unit - } - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun ServerNameLabel(serverName: String) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(Spacing.xs), - ) { - Text( - text = "SETTING UP", - style = TvServerSetupTextStyles.CodeLabel, - color = Color.White.copy(alpha = 0.48f), - ) - Text( - text = serverName, - style = TvServerSetupTextStyles.Headline, - color = Color.White, - ) - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun WaitingDots(compact: Boolean = false) { - val dotSize = if (compact) 8.dp else 12.dp - Row(horizontalArrangement = Arrangement.spacedBy(if (compact) 8.dp else 12.dp)) { - repeat(3) { - Box( - modifier = Modifier - .size(dotSize) - .background(Color.White.copy(alpha = 0.82f), RoundedCornerShape(999.dp)), - ) - } - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun SuccessMark() { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .size(74.dp) - .background(Color(0xFF22C55E).copy(alpha = 0.18f), RoundedCornerShape(999.dp)) - .border(2.dp, Color(0xFF22C55E).copy(alpha = 0.68f), RoundedCornerShape(999.dp)), - ) { - Text( - text = "✓", - style = TvServerSetupTextStyles.SuccessMark, - color = Color(0xFF86EFAC), - ) - } -} - -private fun completedSummary(names: List): String = - when (names.size) { - 0 -> "Taking you to your profiles…" - 1 -> "Signed in to ${names[0]}. Taking you to your profiles…" - else -> "Signed in to ${names.joinToString(", ")}. Taking you to your profiles…" - } - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun MatchCodeCard(code: String) { - if (code.isBlank()) return - val tileWidthDp = matchCodeTileWidthDp(code) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(Spacing.sm), - modifier = Modifier - .fillMaxWidth() - .auroraGlass(12.dp) - .padding(horizontal = Spacing.md, vertical = Spacing.md), - ) { - Text( - text = "CONFIRM THIS CODE", - style = TvServerSetupTextStyles.CodeLabel, - color = Color.White.copy(alpha = 0.62f), - ) - Row(horizontalArrangement = Arrangement.spacedBy(MATCH_CODE_TILE_GAP_DP.dp)) { - code.uppercase().forEach { ch -> - if (ch == '-' || ch == ' ') { - Text( - text = "–", - style = TvServerSetupTextStyles.CodeSeparator, - color = Color.White.copy(alpha = 0.42f), - modifier = Modifier.width(MATCH_CODE_SEPARATOR_WIDTH_DP.dp), - ) - } else { - Box( - modifier = Modifier - .size(width = tileWidthDp.dp, height = 42.dp) - .background(Color.White.copy(alpha = 0.08f), RoundedCornerShape(7.dp)) - .border(1.dp, Color.White.copy(alpha = 0.20f), RoundedCornerShape(7.dp)), - contentAlignment = Alignment.Center, - ) { - Text( - text = ch.toString(), - style = TvServerSetupTextStyles.CodeDigit, - color = Color.White, - ) - } - } - } - } - } -} - -private object TvServerSetupTextStyles { - val Title = TextStyle( - fontWeight = FontWeight.SemiBold, - fontSize = 28.sp, - lineHeight = 34.sp, - letterSpacing = 0.sp, - ) - - val FieldText = TextStyle( - fontWeight = FontWeight.Normal, - fontSize = 17.sp, - lineHeight = 22.sp, - letterSpacing = 0.sp, - color = Color.White, - ) - - /** tvOS continuumHeadline (36pt → 18dp at the 0.5x map, +2 readability). */ - val Headline = TextStyle( - fontWeight = FontWeight.SemiBold, - fontSize = 20.sp, - lineHeight = 26.sp, - letterSpacing = 0.sp, - ) - - val InputLabel = TextStyle( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - lineHeight = 19.sp, - letterSpacing = 3.sp, - ) - - val Pill = TextStyle( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - lineHeight = 19.sp, - letterSpacing = 2.sp, - ) - - val Error = TextStyle( - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - lineHeight = 20.sp, - letterSpacing = 0.sp, - ) - - val PairingDetail = TextStyle( - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 22.sp, - letterSpacing = 0.sp, - ) - - val PairingTitle = TextStyle( - fontWeight = FontWeight.SemiBold, - fontSize = 24.sp, - lineHeight = 30.sp, - letterSpacing = 0.sp, - ) - - val CodeLabel = TextStyle( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - lineHeight = 20.sp, - letterSpacing = 2.sp, - ) - - val CodeDigit = TextStyle( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - fontSize = 24.sp, - lineHeight = 28.sp, - letterSpacing = 0.sp, - ) - - val CodeSeparator = TextStyle( - fontWeight = FontWeight.Bold, - fontSize = 24.sp, - lineHeight = 28.sp, - letterSpacing = 0.sp, - ) - - val SuccessMark = TextStyle( - fontWeight = FontWeight.Bold, - fontSize = 38.sp, - lineHeight = 38.sp, - letterSpacing = 0.sp, - ) -} - -/** Compact horizontal brand row — replaces the oversized centered logo/title pair. */ -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun BrandHeader(modifier: Modifier = Modifier) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Spacing.sm), - modifier = modifier, - ) { - Image( - painter = painterResource(id = R.drawable.prairie_wordmark), - contentDescription = "Prairie", - modifier = Modifier - .width(66.dp) - .height(35.dp), - contentScale = ContentScale.Fit, - ) - } -} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvServerSetupViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvServerSetupViewModel.kt index c1931171e8..e51e56d952 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvServerSetupViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvServerSetupViewModel.kt @@ -42,6 +42,9 @@ data class TvServerSetupUiState( sealed class TvServerSetupDestination { data object Setup : TvServerSetupDestination() data class Login(val signupEnabled: Boolean) : TvServerSetupDestination() + + /** A saved server this TV is still signed in to: straight to its profiles. */ + data object Profiles : TvServerSetupDestination() } internal sealed class TvServerSetupProbeResult { @@ -83,6 +86,8 @@ class TvServerSetupViewModel( private val probeContract: suspend (String) -> ApiV2ProbeResult? = { authRepository.probeServerContract(it) }, + /** Whether the now-active server already has a signed-in session. */ + private val hasSession: suspend () -> Boolean = { false }, ) : ViewModel() { private val _uiState = MutableStateFlow(TvServerSetupUiState()) @@ -216,12 +221,19 @@ class TvServerSetupViewModel( contract: ServerContract?, ) { authRepository.setServerUrl(serverUrl, contract) + // A saved server that is still signed in goes straight to its + // profiles; only a server without a session needs sign-in. + val next = if (destination is TvServerSetupDestination.Login && hasSession()) { + TvServerSetupDestination.Profiles + } else { + destination + } _uiState.update { it.copy( serverUrl = serverUrl, isLoading = false, pendingCleartextUrl = null, - navigateTo = destination, + navigateTo = next, ) } } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvSetupScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvSetupScreen.kt index 5fe37cc217..c7415237cf 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvSetupScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvSetupScreen.kt @@ -1,72 +1,51 @@ package org.prairieserver.prairie.tv.ui.screens.auth -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material3.OutlinedTextField +import androidx.compose.material.icons.outlined.Email +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.Person import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import org.prairieserver.prairie.tv.ui.focus.rememberTvContentInitialFocus import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.InputMode -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalInputModeManager -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp +import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeBody +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeButton +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeErrorText +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeField +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeHeadline +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeScreen +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeCard +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeCardSymbol +import org.prairieserver.prairie.common.ui.marquee.MarqueeColors +import androidx.compose.material.icons.outlined.AdminPanelSettings +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.sp -import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -import org.prairieserver.prairie.tv.R -import org.prairieserver.prairie.tv.ui.components.TvAuroraBackdrop -import org.prairieserver.prairie.tv.ui.components.TvAuroraVariant -import org.prairieserver.prairie.tv.ui.components.AuroraPrimaryButton import org.prairieserver.prairie.tv.ui.components.rememberTvImeAwareFormScrollState import org.prairieserver.prairie.tv.ui.components.tvImeAwareFieldContext -import org.prairieserver.prairie.tv.ui.components.tvShowImeOnSelect -import org.prairieserver.prairie.tv.ui.components.TvAuthFormDefaults -import org.prairieserver.prairie.tv.ui.components.tvOutlinedTextFieldColors -import org.prairieserver.prairie.tv.ui.theme.Spacing -import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.tv.ui.focus.rememberTvContentInitialFocus /** - * First-time server setup — creates the initial admin account on a freshly - * installed Silo server. Mirrors the phone's `SetupScreen` logic via - * [TvSetupViewModel]; on success the user is already signed in (tokens - * persisted by [org.prairieserver.prairie.repository.AuthRepository.setup]) so the - * flow advances to profile selection. - * - * Layout follows [TvServerSetupScreen]: TOP-anchored + `imePadding` + - * `bringIntoView` so the focused field stays above the soft IME on Android TV. + * First-admin account creation on a server that has none yet, laid out like + * the other TV first-run screens: the form on the left, a card on the right. */ -@OptIn(ExperimentalTvMaterial3Api::class) @Composable fun TvSetupScreen( onSetupComplete: () -> Unit, @@ -74,7 +53,6 @@ fun TvSetupScreen( ) { val state by viewModel.uiState.collectAsState() val usernameFocus = remember { FocusRequester() } - val formScrollState = rememberTvImeAwareFormScrollState() LaunchedEffect(state.setupSuccess) { if (state.setupSuccess) { @@ -83,223 +61,88 @@ fun TvSetupScreen( } } // A text field on a first-run screen: if this claim is dropped the - // remote has nothing to act on and no touch fallback exists. - // Snapshot-backed input mode drives the claim: null contentKey while the - // viewer is in touch mode (a programmatic claim on a text field pops the - // IME; pointer users click the field themselves), and the key change on - // flipping back to key input re-runs the claim so the D-pad always has - // somewhere to land. + // remote has nothing to act on and no touch fallback exists. Null + // contentKey in touch mode (a programmatic claim on a text field pops the + // IME); flipping back to key input re-runs the claim. val inputMode = LocalInputModeManager.current.inputMode val usernameFocusModifier = rememberTvContentInitialFocus( target = usernameFocus, contentKey = if (inputMode == InputMode.Touch) null else inputMode, ) - Box( - modifier = Modifier - .fillMaxSize() - .then(usernameFocusModifier) - .imePadding(), - ) { - TvAuroraBackdrop(variant = TvAuroraVariant.Welcome) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(Spacing.md), - modifier = Modifier - .align(Alignment.TopCenter) - .width(420.dp) - .fillMaxSize() - .verticalScroll(formScrollState) - .padding(top = 24.dp, bottom = Spacing.lg, start = Spacing.xl, end = Spacing.xl), - ) { - BrandHeader() - - Spacer(modifier = Modifier.height(Spacing.xs)) - - Text( - text = "Welcome to Prairie", - style = TvAuthFormTextStyles.Title, - color = Color.White, - ) - Text( - text = "Create your first admin account.", - style = TvAuthFormTextStyles.Body, - color = Color.White.copy(alpha = 0.72f), - ) - - Column( - verticalArrangement = Arrangement.spacedBy(Spacing.xs), - modifier = Modifier - .fillMaxWidth() - .tvImeAwareFieldContext(), - ) { - Text( - text = "USERNAME", - style = TvAuthFormTextStyles.InputLabel, - color = Color.White.copy(alpha = 0.52f), - ) - OutlinedTextField( - value = state.username, - onValueChange = viewModel::onUsernameChanged, - singleLine = true, - textStyle = TvAuthFormTextStyles.FieldText, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Next, - showKeyboardOnFocus = false, - ), - enabled = !state.isLoading, - modifier = Modifier - .fillMaxWidth() - .height(TvAuthFormDefaults.FieldHeight) - .tvShowImeOnSelect() - .focusRequester(usernameFocus), - colors = tvOutlinedTextFieldColors(), - ) - } - - Column( - verticalArrangement = Arrangement.spacedBy(Spacing.xs), - modifier = Modifier - .fillMaxWidth() - .tvImeAwareFieldContext(), - ) { - Text( - text = "EMAIL", - style = TvAuthFormTextStyles.InputLabel, - color = Color.White.copy(alpha = 0.52f), - ) - OutlinedTextField( - value = state.email, - onValueChange = viewModel::onEmailChanged, - singleLine = true, - textStyle = TvAuthFormTextStyles.FieldText, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Email, - imeAction = ImeAction.Next, - showKeyboardOnFocus = false, - ), - enabled = !state.isLoading, - modifier = Modifier - .fillMaxWidth() - .height(TvAuthFormDefaults.FieldHeight) - .tvShowImeOnSelect(), - colors = tvOutlinedTextFieldColors(), - ) - } - - Column( - verticalArrangement = Arrangement.spacedBy(Spacing.xs), - modifier = Modifier - .fillMaxWidth() - .tvImeAwareFieldContext(), - ) { - Text( - text = "PASSWORD", - style = TvAuthFormTextStyles.InputLabel, - color = Color.White.copy(alpha = 0.52f), - ) - OutlinedTextField( - value = state.password, - onValueChange = viewModel::onPasswordChanged, - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - textStyle = TvAuthFormTextStyles.FieldText, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done, - showKeyboardOnFocus = false, - ), - keyboardActions = KeyboardActions( - onDone = { if (!state.isLoading) viewModel.onCreateAccountClick() }, - ), - enabled = !state.isLoading, - modifier = Modifier - .fillMaxWidth() - .height(TvAuthFormDefaults.FieldHeight) - .tvShowImeOnSelect(), - colors = tvOutlinedTextFieldColors(), + Box(Modifier.fillMaxSize().then(usernameFocusModifier).imePadding()) { + TvMarqueeScreen( + copyScroll = rememberTvImeAwareFormScrollState(), + copy = { + TvMarqueeHeadline("Set up\nyour server") + TvMarqueeBody( + "This server has no accounts yet. Create the first one; it becomes the admin.", + modifier = Modifier.padding(top = 11.dp), ) - } - - if (state.error != null) { - Text( - text = state.error!!, - style = TvAuthFormTextStyles.Error, - color = MaterialTheme.colorScheme.error, - ) - } - - Box { - AuroraPrimaryButton( - label = if (state.isLoading) "Creating account…" else "Create Account", - icon = Icons.AutoMirrored.Filled.ArrowForward, - enabled = !state.isLoading, + Column( + Modifier.width(380.dp).padding(top = 20.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box(Modifier.tvImeAwareFieldContext()) { + TvMarqueeField( + value = state.username, + onValueChange = viewModel::onUsernameChanged, + icon = Icons.Outlined.Person, + placeholder = "Username", + enabled = !state.isLoading, + focusRequester = usernameFocus, + ) + } + Box(Modifier.tvImeAwareFieldContext()) { + TvMarqueeField( + value = state.email, + onValueChange = viewModel::onEmailChanged, + icon = Icons.Outlined.Email, + placeholder = "Email", + keyboardType = KeyboardType.Email, + enabled = !state.isLoading, + ) + } + Box(Modifier.tvImeAwareFieldContext()) { + TvMarqueeField( + value = state.password, + onValueChange = viewModel::onPasswordChanged, + icon = Icons.Outlined.Lock, + placeholder = "Password", + isSecure = true, + imeAction = ImeAction.Done, + onImeAction = { if (!state.isLoading) viewModel.onCreateAccountClick() }, + isError = state.error != null, + enabled = !state.isLoading, + ) + } + } + state.error?.let { TvMarqueeErrorText(it, Modifier.width(380.dp).padding(top = 8.dp)) } + TvMarqueeButton( + text = if (state.isLoading) "Creating account…" else "Create account", onClick = viewModel::onCreateAccountClick, - modifier = Modifier - .fillMaxWidth() - .height(TvAuthFormDefaults.PrimaryButtonHeight), + isLoading = state.isLoading, + enabled = !state.isLoading, + modifier = Modifier.padding(top = 20.dp), ) - } - } - - } -} - -/** Compact horizontal brand row, matching the other auth screens. */ -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun BrandHeader() { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Spacing.sm), - ) { - Image( - painter = painterResource(id = R.drawable.prairie_wordmark), - contentDescription = "Prairie", - modifier = Modifier - .width(66.dp) - .height(35.dp), - contentScale = ContentScale.Fit, + }, + card = { + TvMarqueeCard { + TvMarqueeCardSymbol(Icons.Outlined.AdminPanelSettings) + Text( + "The first account", + color = MarqueeColors.Ink, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 17.dp), + ) + TvMarqueeBody( + "It runs the server. Add more accounts later from the web admin.", + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 7.dp), + ) + } + }, ) } } - -/** Shared text styles for the setup + signup forms (mirrors TvServerSetup styling). */ -internal object TvAuthFormTextStyles { - val Title = TextStyle( - fontWeight = FontWeight.SemiBold, - fontSize = 17.sp, - lineHeight = 20.sp, - letterSpacing = 0.sp, - ) - val Body = TextStyle( - fontWeight = FontWeight.Normal, - fontSize = 18.sp, - lineHeight = 24.sp, - letterSpacing = 0.sp, - ) - /** Mono uppercase caption above each input — the auth-flow field idiom - * (server setup and sign-in); Material's floating label renders oversized - * in the border notch at TV type scale. */ - val InputLabel = TextStyle( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - lineHeight = 19.sp, - letterSpacing = 3.sp, - ) - val FieldText = TextStyle( - fontWeight = FontWeight.Normal, - fontSize = 17.sp, - lineHeight = 22.sp, - letterSpacing = 0.sp, - color = Color.White, - ) - val Error = TextStyle( - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - lineHeight = 16.sp, - letterSpacing = 0.sp, - ) -} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvSignupScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvSignupScreen.kt index 1a5b580c10..8b92ac5228 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvSignupScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvSignupScreen.kt @@ -1,70 +1,49 @@ package org.prairieserver.prairie.tv.ui.screens.auth -import androidx.compose.foundation.Image +import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material3.OutlinedTextField +import androidx.compose.material.icons.outlined.ConfirmationNumber +import androidx.compose.material.icons.outlined.Email +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.Person import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import org.prairieserver.prairie.tv.ui.focus.rememberTvContentInitialFocus import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.InputMode -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalInputModeManager -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import org.prairieserver.prairie.tv.R -import org.prairieserver.prairie.tv.ui.components.TvAuroraBackdrop -import org.prairieserver.prairie.tv.ui.components.TvAuroraVariant -import org.prairieserver.prairie.tv.ui.components.AuroraGhostButton -import org.prairieserver.prairie.tv.ui.components.AuroraPrimaryButton +import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeBody +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeButton +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeButtonKind +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeErrorText +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeField +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeHeadline +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeScreen +import org.prairieserver.prairie.tv.ui.components.marquee.TvUsePhoneCard import org.prairieserver.prairie.tv.ui.components.rememberTvImeAwareFormScrollState import org.prairieserver.prairie.tv.ui.components.tvImeAwareFieldContext -import org.prairieserver.prairie.tv.ui.components.tvShowImeOnSelect -import org.prairieserver.prairie.tv.ui.components.TvAuthFormDefaults -import org.prairieserver.prairie.tv.ui.components.tvOutlinedTextFieldColors -import org.prairieserver.prairie.tv.ui.theme.Spacing -import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.tv.ui.focus.rememberTvContentInitialFocus /** - * New-user registration with an invite code. Mirrors the phone's `SignupScreen` - * via [TvSignupViewModel]; reachable only when the server reports signup is - * enabled (the login screen surfaces the entry point). On success the user is - * signed in (tokens persisted by [org.prairieserver.prairie.repository.AuthRepository.signup]) - * so the flow advances to profile selection. - * - * Layout follows [TvServerSetupScreen]/[TvSetupScreen]: TOP-anchored + - * `imePadding` + `bringIntoView` so the focused field stays above the IME. + * Account registration with an invite code, laid out like the other TV + * first-run screens. Back returns to sign-in. */ -@OptIn(ExperimentalTvMaterial3Api::class) @Composable fun TvSignupScreen( onSignupComplete: () -> Unit, @@ -73,7 +52,6 @@ fun TvSignupScreen( ) { val state by viewModel.uiState.collectAsState() val usernameFocus = remember { FocusRequester() } - val formScrollState = rememberTvImeAwareFormScrollState() LaunchedEffect(state.signupSuccess) { if (state.signupSuccess) { @@ -81,223 +59,89 @@ fun TvSignupScreen( onSignupComplete() } } + BackHandler(enabled = state.isLoading) {} // A text field on a first-run screen: if this claim is dropped the - // remote has nothing to act on and no touch fallback exists. - // Snapshot-backed input mode drives the claim: null contentKey while the - // viewer is in touch mode (a programmatic claim on a text field pops the - // IME; pointer users click the field themselves), and the key change on - // flipping back to key input re-runs the claim so the D-pad always has - // somewhere to land. + // remote has nothing to act on and no touch fallback exists. Null + // contentKey in touch mode (a programmatic claim on a text field pops the + // IME); flipping back to key input re-runs the claim. val inputMode = LocalInputModeManager.current.inputMode val usernameFocusModifier = rememberTvContentInitialFocus( target = usernameFocus, contentKey = if (inputMode == InputMode.Touch) null else inputMode, ) - Box( - modifier = Modifier - .fillMaxSize() - .then(usernameFocusModifier) - .imePadding(), - ) { - TvAuroraBackdrop(variant = TvAuroraVariant.SignIn) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(Spacing.md), - modifier = Modifier - .align(Alignment.TopCenter) - .width(420.dp) - .fillMaxSize() - .verticalScroll(formScrollState) - .padding(top = 24.dp, bottom = Spacing.lg, start = Spacing.xl, end = Spacing.xl), - ) { - BrandHeader() - - Spacer(modifier = Modifier.height(Spacing.xs)) - - Text( - text = "Create Account", - style = TvAuthFormTextStyles.Title, - color = Color.White, - ) - Text( - text = "Join this Prairie server with your invite.", - style = TvAuthFormTextStyles.Body, - color = Color.White.copy(alpha = 0.72f), - ) - - Column( - verticalArrangement = Arrangement.spacedBy(Spacing.xs), - modifier = Modifier - .fillMaxWidth() - .tvImeAwareFieldContext(), - ) { - Text( - text = "USERNAME", - style = TvAuthFormTextStyles.InputLabel, - color = Color.White.copy(alpha = 0.52f), - ) - OutlinedTextField( - value = state.username, - onValueChange = viewModel::onUsernameChanged, - singleLine = true, - textStyle = TvAuthFormTextStyles.FieldText, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Next, - showKeyboardOnFocus = false, - ), - enabled = !state.isLoading, - modifier = Modifier - .fillMaxWidth() - .height(TvAuthFormDefaults.FieldHeight) - .tvShowImeOnSelect() - .focusRequester(usernameFocus), - colors = tvOutlinedTextFieldColors(), - ) - } - - Column( - verticalArrangement = Arrangement.spacedBy(Spacing.xs), - modifier = Modifier - .fillMaxWidth() - .tvImeAwareFieldContext(), - ) { - Text( - text = "EMAIL", - style = TvAuthFormTextStyles.InputLabel, - color = Color.White.copy(alpha = 0.52f), - ) - OutlinedTextField( - value = state.email, - onValueChange = viewModel::onEmailChanged, - singleLine = true, - textStyle = TvAuthFormTextStyles.FieldText, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Email, - imeAction = ImeAction.Next, - showKeyboardOnFocus = false, - ), - enabled = !state.isLoading, - modifier = Modifier - .fillMaxWidth() - .height(TvAuthFormDefaults.FieldHeight) - .tvShowImeOnSelect(), - colors = tvOutlinedTextFieldColors(), - ) - } - - Column( - verticalArrangement = Arrangement.spacedBy(Spacing.xs), - modifier = Modifier - .fillMaxWidth() - .tvImeAwareFieldContext(), - ) { - Text( - text = "PASSWORD", - style = TvAuthFormTextStyles.InputLabel, - color = Color.White.copy(alpha = 0.52f), - ) - OutlinedTextField( - value = state.password, - onValueChange = viewModel::onPasswordChanged, - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - textStyle = TvAuthFormTextStyles.FieldText, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Next, - showKeyboardOnFocus = false, - ), - enabled = !state.isLoading, - modifier = Modifier - .fillMaxWidth() - .height(TvAuthFormDefaults.FieldHeight) - .tvShowImeOnSelect(), - colors = tvOutlinedTextFieldColors(), - ) - } - - Column( - verticalArrangement = Arrangement.spacedBy(Spacing.xs), - modifier = Modifier - .fillMaxWidth() - .tvImeAwareFieldContext(), - ) { - Text( - text = "INVITE CODE", - style = TvAuthFormTextStyles.InputLabel, - color = Color.White.copy(alpha = 0.52f), - ) - OutlinedTextField( - value = state.inviteCode, - onValueChange = viewModel::onInviteCodeChanged, - singleLine = true, - textStyle = TvAuthFormTextStyles.FieldText, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Done, - showKeyboardOnFocus = false, - ), - keyboardActions = KeyboardActions( - onDone = { if (!state.isLoading) viewModel.onSignupClick() }, - ), - enabled = !state.isLoading, - modifier = Modifier - .fillMaxWidth() - .height(TvAuthFormDefaults.FieldHeight) - .tvShowImeOnSelect(), - colors = tvOutlinedTextFieldColors(), - ) - } - - if (state.error != null) { - Text( - text = state.error!!, - style = TvAuthFormTextStyles.Error, - color = MaterialTheme.colorScheme.error, - ) - } - - Box { - AuroraPrimaryButton( - label = if (state.isLoading) "Signing up…" else "Sign Up", - icon = Icons.AutoMirrored.Filled.ArrowForward, - enabled = !state.isLoading, - onClick = viewModel::onSignupClick, - modifier = Modifier - .fillMaxWidth() - .height(TvAuthFormDefaults.PrimaryButtonHeight), - ) - } - AuroraGhostButton( - label = "Sign In Instead", - onClick = onBackToLogin, - fontSize = 18.sp, - horizontalPadding = 18.dp, - verticalPadding = 8.dp, - modifier = Modifier.fillMaxWidth(), - ) - } - - } -} - -/** Compact horizontal brand row, matching the other auth screens. */ -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun BrandHeader() { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Spacing.sm), - ) { - Image( - painter = painterResource(id = R.drawable.prairie_wordmark), - contentDescription = "Prairie", - modifier = Modifier - .width(66.dp) - .height(35.dp), - contentScale = ContentScale.Fit, + Box(Modifier.fillMaxSize().then(usernameFocusModifier).imePadding()) { + TvMarqueeScreen( + copyScroll = rememberTvImeAwareFormScrollState(), + copy = { + TvMarqueeHeadline("Create your\naccount") + TvMarqueeBody("Join this Prairie server with your invite.", modifier = Modifier.padding(top = 11.dp)) + Column( + Modifier.width(380.dp).padding(top = 20.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box(Modifier.tvImeAwareFieldContext()) { + TvMarqueeField( + value = state.username, + onValueChange = viewModel::onUsernameChanged, + icon = Icons.Outlined.Person, + placeholder = "Username", + enabled = !state.isLoading, + focusRequester = usernameFocus, + ) + } + Box(Modifier.tvImeAwareFieldContext()) { + TvMarqueeField( + value = state.email, + onValueChange = viewModel::onEmailChanged, + icon = Icons.Outlined.Email, + placeholder = "Email", + keyboardType = KeyboardType.Email, + enabled = !state.isLoading, + ) + } + Box(Modifier.tvImeAwareFieldContext()) { + TvMarqueeField( + value = state.password, + onValueChange = viewModel::onPasswordChanged, + icon = Icons.Outlined.Lock, + placeholder = "Password", + isSecure = true, + enabled = !state.isLoading, + ) + } + Box(Modifier.tvImeAwareFieldContext()) { + TvMarqueeField( + value = state.inviteCode, + onValueChange = viewModel::onInviteCodeChanged, + icon = Icons.Outlined.ConfirmationNumber, + placeholder = "Invite code", + imeAction = ImeAction.Done, + onImeAction = { if (!state.isLoading) viewModel.onSignupClick() }, + isError = state.error != null, + enabled = !state.isLoading, + ) + } + } + state.error?.let { TvMarqueeErrorText(it, Modifier.width(380.dp).padding(top = 8.dp)) } + Row(Modifier.fillMaxWidth().padding(top = 20.dp), horizontalArrangement = Arrangement.spacedBy(11.dp)) { + TvMarqueeButton( + text = if (state.isLoading) "Creating account…" else "Create account", + onClick = viewModel::onSignupClick, + isLoading = state.isLoading, + enabled = !state.isLoading, + ) + TvMarqueeButton( + text = "Sign in instead", + onClick = onBackToLogin, + kind = TvMarqueeButtonKind.Plain, + enabled = !state.isLoading, + ) + } + }, + card = { + TvUsePhoneCard("Already have an account? Open Prairie on a phone or tablet on the same Wi‑Fi and it can sign this TV in for you.") + }, ) } } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/calendar/TvCalendarScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/calendar/TvCalendarScreen.kt index fc64c1f5dd..145c73fb8d 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/calendar/TvCalendarScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/calendar/TvCalendarScreen.kt @@ -89,6 +89,7 @@ import kotlinx.coroutines.withTimeoutOrNull import org.koin.compose.viewmodel.koinViewModel import org.prairieserver.prairie.common.calendar.localDisplayAirTime import org.prairieserver.prairie.common.cards.LocalCardPresentation +import org.prairieserver.prairie.common.ui.components.DefaultArtworkKind import org.prairieserver.prairie.common.ui.components.ThumbhashImage import org.prairieserver.prairie.model.calendar.CalendarBadge import org.prairieserver.prairie.model.calendar.CalendarFilter @@ -1453,6 +1454,7 @@ private fun CalendarEventCard( contentDescription = item.title, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), + defaultArtwork = DefaultArtworkKind.forItemType(item.type), ) // Badge pills (top-leading) — tvOS CalendarBadgePill. diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/cast/TvPrairieCastStandbyView.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/cast/TvPrairieCastStandbyView.kt index c7ed79b2ec..3350c3b301 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/cast/TvPrairieCastStandbyView.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/cast/TvPrairieCastStandbyView.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Tv +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember @@ -20,6 +21,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.Button @@ -31,11 +33,17 @@ import androidx.compose.material.icons.filled.LinkOff import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.Spacer +/** + * Full-screen takeover while a phone controls the TV with nothing playing: + * "Ready for ", or, while a profile handoff prepares a title the phone + * is about to play, "Starting " with a spinner. + */ @Composable fun TvPrairieCastStandbyView( state: TvPrairieCastReceiver.StandbyState, onDisconnect: () -> Unit, ) { + val preparing = state.preparing val disconnectFocus = remember { FocusRequester() } LaunchedEffect(Unit) { disconnectFocus.requestFocus() } @@ -56,27 +64,45 @@ fun TvPrairieCastStandbyView( .background(Color.White.copy(alpha = 0.08f), CircleShape), contentAlignment = Alignment.Center, ) { - Icon( - imageVector = Icons.Outlined.Tv, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(76.dp), - ) + if (preparing != null) { + CircularProgressIndicator( + color = Color.White, + strokeWidth = 5.dp, + modifier = Modifier.size(76.dp), + ) + } else { + Icon( + imageVector = Icons.Outlined.Tv, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(76.dp), + ) + } } Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp), ) { Text( - text = "Ready for ${state.controllerLabel}", + text = when { + preparing == null -> "Ready for ${state.controllerLabel}" + preparing.title != null -> "Starting ${preparing.title}" + else -> "Starting playback" + }, color = Color.White, fontSize = 52.sp, lineHeight = 58.sp, fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center, + maxLines = 3, + overflow = TextOverflow.Ellipsis, ) Text( - text = state.serverName ?: "Remote control active", + text = if (preparing != null) { + "From ${state.controllerName?.takeIf { it.isNotBlank() } ?: "your phone"}" + } else { + state.serverName ?: "Remote control active" + }, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center, diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/collections/TvCollectionDetailScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/collections/TvCollectionDetailScreen.kt index 1b55bd7df4..a04f682cb3 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/collections/TvCollectionDetailScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/collections/TvCollectionDetailScreen.kt @@ -38,6 +38,8 @@ fun TvCollectionDetailScreen( title: String, onItemClick: (contentId: String) -> Unit, onBack: () -> Unit, + // Plays the first pick of a shuffle started from the header. + onShuffleStarted: (org.prairieserver.prairie.model.shuffle.Shuffle) -> Unit = {}, viewModel: TvCollectionDetailViewModel = koinViewModel( key = "collection-$collectionId", parameters = { parametersOf(collectionId, title) }, @@ -45,6 +47,10 @@ fun TvCollectionDetailScreen( ) { val state by viewModel.uiState.collectAsState() val gridState = rememberLazyGridState() + val shuffleLauncher = org.prairieserver.prairie.common.ui.rememberShuffleLauncher( + org.koin.compose.koinInject(), + onShuffleStarted, + ) BackHandler(enabled = true) { onBack() } @@ -76,14 +82,32 @@ fun TvCollectionDetailScreen( .fillMaxSize() .background(MaterialTheme.colorScheme.background), ) { - Text( - text = state.name.ifBlank { title }, - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onBackground, + androidx.compose.foundation.layout.Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp, vertical = 12.dp), - ) + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + Text( + text = state.name.ifBlank { title }, + style = MaterialTheme.typography.displaySmall, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.weight(1f), + ) + if ( + state.items.isNotEmpty() && + shuffleLauncher.supports(org.prairieserver.prairie.model.shuffle.ShuffleScopeKind.USER_COLLECTION) + ) { + org.prairieserver.prairie.tv.ui.screens.library.TvShufflePill( + onClick = { + shuffleLauncher.start( + org.prairieserver.prairie.model.shuffle.ShuffleScopeKind.USER_COLLECTION, + collectionId, + ) + }, + ) + } + } when { state.isLoading && state.items.isEmpty() -> TvLoadingScreen() diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/collections/TvCollectionsScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/collections/TvCollectionsScreen.kt index 43ed7c41e5..0d02d24c5a 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/collections/TvCollectionsScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/collections/TvCollectionsScreen.kt @@ -41,7 +41,10 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.common.ui.OnViewerAccessChanged +import org.prairieserver.prairie.network.AccessChangeSignals import org.prairieserver.prairie.model.personal.Collection import org.prairieserver.prairie.tv.ui.focus.rememberTvContentInitialFocus import org.prairieserver.prairie.tv.ui.components.TvErrorScreen @@ -60,6 +63,7 @@ fun TvCollectionsScreen( viewModel: CollectionsViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() + OnViewerAccessChanged(koinInject<AccessChangeSignals>(), viewModel.accessChanges) { viewModel.refresh() } val firstCollectionFocusRequester = remember { FocusRequester() } val firstCollectionId = state.sections.firstNotNullOfOrNull { it.collections.firstOrNull()?.id } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvAudiobookDetailHero.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvAudiobookDetailHero.kt index b16187b1e8..5a082ea194 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvAudiobookDetailHero.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvAudiobookDetailHero.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import org.prairieserver.prairie.common.ui.components.DefaultArtworkKind import org.prairieserver.prairie.common.ui.components.ThumbhashImage import org.prairieserver.prairie.model.catalog.ItemDetail import org.prairieserver.prairie.tv.ui.navigation.TvSubtitleLaunchSelection @@ -109,6 +110,7 @@ internal fun TvAudiobookDetailHero( .size(180.dp) .shadow(20.dp, RoundedCornerShape(14.dp)), cornerRadius = 14.dp, + defaultArtwork = DefaultArtworkKind.Audiobook, ) Column( diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDescriptionTranslationSection.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDescriptionTranslationSection.kt index a7c6020fd1..2c51717975 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDescriptionTranslationSection.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDescriptionTranslationSection.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.compose.material3.CircularProgressIndicator +import org.prairieserver.prairie.common.ui.components.MachineTranslatedLabel import org.prairieserver.prairie.metadata.DescriptionTranslationPhase import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Refresh @@ -18,6 +19,8 @@ import androidx.compose.material.icons.filled.Translate import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.width import androidx.tv.material3.Icon +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.sp /** * TV description-translation affordance (Apple tvOS parity): a focusable @@ -79,3 +82,23 @@ internal fun TvDescriptionTranslationSection( } } } + +/** + * "Translated by AI" under a synopsis the server reports as machine-translated. + * Passive text, never focusable; [compact] is the icon-only form for episode + * cards, which still speaks the words to accessibility services. + */ +@Composable +internal fun TvMachineTranslatedLabel( + modifier: Modifier = Modifier, + compact: Boolean = false, + iconSize: Dp = 14.dp, +) { + MachineTranslatedLabel( + color = Color.White.copy(alpha = 0.6f), + compact = compact, + fontSize = 14.sp, + iconSize = iconSize, + modifier = modifier, + ) +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailEpisodeRail.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailEpisodeRail.kt index 1ee34abe3a..9c20b7b055 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailEpisodeRail.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailEpisodeRail.kt @@ -29,7 +29,6 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Movie import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -66,8 +65,13 @@ import androidx.compose.ui.unit.sp import androidx.tv.material3.Icon import androidx.tv.material3.Text import org.prairieserver.prairie.common.cards.LocalCardPresentation +import org.prairieserver.prairie.common.cards.LocalEpisodeSpoilerPrefs +import org.prairieserver.prairie.common.ui.components.SpoilerImage +import org.prairieserver.prairie.common.ui.components.DefaultArtworkKind import org.prairieserver.prairie.common.ui.components.ThumbhashImage import org.prairieserver.prairie.model.catalog.EpisodeListItem +import org.prairieserver.prairie.model.catalog.hasMachineTranslatedOverview +import org.prairieserver.prairie.model.settings.EpisodeSpoilers import org.prairieserver.prairie.tv.ui.components.TvMediaCardActions import org.prairieserver.prairie.tv.ui.components.TvMediaCardContextMenu import org.prairieserver.prairie.tv.ui.components.tvEpisodeCardWidth @@ -358,6 +362,10 @@ private fun TvDetailEpisodeCard( // Continue Watching rail. Legacy season/episode routes keep their existing // geometry so this targeted parity change cannot disturb those layouts. val cardWidth = if (usesSeriesGeometry) tvEpisodeCardWidth() else 230.dp.cardScaled() + val spoilers = LocalEpisodeSpoilerPrefs.current + val unwatched = EpisodeSpoilers.isUnwatched(episode.userData) + val visibleOverview = episode.overview + ?.takeIf { it.isNotBlank() && !spoilers.hidesOverview(unwatched) } val stillHeight = if (usesSeriesGeometry) cardWidth * (9f / 16f) else 130.dp.cardScaled() val eyebrowFontSize = if (usesSeriesGeometry) 11.sp else 14.sp val eyebrowLineHeight = if (usesSeriesGeometry) 14.sp else 17.sp @@ -429,24 +437,15 @@ private fun TvDetailEpisodeCard( }, ), ) { - if (!episode.stillUrl.isNullOrBlank()) { - ThumbhashImage( - url = episode.stillUrl, - thumbhash = episode.stillThumbhash, - contentDescription = episode.title, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) - } else { - Icon( - imageVector = Icons.Filled.Movie, - contentDescription = null, - tint = PrairieSecondaryText, - modifier = Modifier - .size(24.dp) - .align(Alignment.Center), - ) - } + SpoilerImage( + url = episode.stillUrl, + thumbhash = episode.stillThumbhash, + hidden = spoilers.hidesImage(unwatched, episode.stillIsEpisodeStill), + contentDescription = episode.title, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + defaultArtwork = DefaultArtworkKind.Tv, + ) if (episode.userData?.played == true) { Box( @@ -532,6 +531,12 @@ private fun TvDetailEpisodeCard( } else if (isCurrent) { NowViewingTag() } + if (!hidesEpisodeTitle && caption.showsMetadata && + visibleOverview != null && + hasMachineTranslatedOverview(episode.machineTranslatedFields) + ) { + TvMachineTranslatedLabel(compact = true, iconSize = 12.dp) + } } if (!hidesEpisodeTitle) { @@ -566,9 +571,10 @@ private fun TvDetailEpisodeCard( // exactly 3 text lines (minLines/maxLines rather than a fixed dp // clamp so accessibility text scaling can't clip glyphs), and // render even when there is no overview so every card keeps - // identical vertical metrics. + // identical vertical metrics. Spoiler protection blanks it + // the same way. Text( - text = episode.overview?.takeIf { it.isNotBlank() }.orEmpty(), + text = visibleOverview.orEmpty(), fontSize = 16.sp, fontWeight = FontWeight.Normal, color = PrairieSecondaryText, diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailHero.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailHero.kt index 71c3d635a7..6175f310ea 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailHero.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailHero.kt @@ -36,33 +36,18 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.tv.material3.Icon +import androidx.tv.material3.LocalTextStyle import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CheckCircle import coil3.compose.AsyncImage +import org.prairieserver.prairie.common.settings.titleLogoUrl import org.prairieserver.prairie.common.ui.components.ThumbhashImage import org.prairieserver.prairie.tv.R -import org.prairieserver.prairie.tv.ui.theme.SuccessGreen - -/** - * Tokens for the hero facts row, mirroring tvOS `TVHeroFactToken`. - * - * - [TextToken] plain text (year / runtime / ★rating); consecutive text - * tokens get a "·" divider between them. - * - [Rating] a maturity/check token: green check icon + label. - * - [Chip] a playback-format value (4K / HDR / DOLBY VISION / ATMOS / CC). - * Detail renders these with the same quiet monospaced treatment as Home's - * format line rather than promoting every value to an outlined badge. - */ -internal sealed class TvHeroFactToken { - data class TextToken(val value: String) : TvHeroFactToken() - data class Rating(val value: String) : TvHeroFactToken() - data class Chip(val value: String) : TvHeroFactToken() -} +import org.prairieserver.prairie.tv.ui.components.TvFactsRow +import org.prairieserver.prairie.tv.ui.components.TvHeroFactToken /** * Approved tvOS detail hero, mapped onto Android TV's half-scale layout @@ -97,11 +82,16 @@ internal fun TvDetailHero( // Optional description-translation affordance (Apple tvOS parity), // rendered as its own focus stop directly under the synopsis. translation: (@Composable () -> Unit)? = null, + /** The shown synopsis was machine-translated: mark it "Translated by AI". */ + machineTranslated: Boolean = false, + /** Grows the hero, not the artwork, for a taller summary strip (request + * detail's status and stage track) so the actions stay inside its clip. */ + extraHeight: Dp = 0.dp, ) { // tvOS 690pt on a 1080pt canvas. Android TV's 1920×1080 emulator reports // a 960×540dp layout canvas, so the same fraction produces 345dp. val screenHeight = LocalConfiguration.current.screenHeightDp.dp - val heroHeight = screenHeight * HERO_HEIGHT_FRACTION + val heroHeight = screenHeight * HERO_HEIGHT_FRACTION + extraHeight BoxWithConstraints( modifier = modifier @@ -176,6 +166,7 @@ internal fun TvDetailHero( collapsedSynopsisLines = 3, compactSeries = compactSeries, translation = translation.takeUnless { compactSeries }, + machineTranslated = machineTranslated && !compactSeries, playbackSummary = playbackSummary, ) @@ -191,6 +182,14 @@ internal fun TvDetailHero( Box(Modifier.weight(1f)) { actions() } translation() } + } else if (compactSeries && machineTranslated) { + // Series reserves fixed editorial slots, so the marker sits + // at the end of the action row where the translate button + // would; it is not focusable and adds no D-pad stop. + Row(verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.weight(1f)) { actions() } + TvMachineTranslatedLabel() + } } else { actions() } @@ -215,6 +214,7 @@ private fun EditorialColumn( collapsedSynopsisLines: Int, compactSeries: Boolean, translation: (@Composable () -> Unit)? = null, + machineTranslated: Boolean = false, playbackSummary: (@Composable () -> Unit)? = null, ) { val isCombinedSeriesEpisode = compactSeries && !seriesTitle.isNullOrBlank() @@ -285,6 +285,7 @@ private fun EditorialColumn( ) } } + if (machineTranslated && !overview.isNullOrBlank()) TvMachineTranslatedLabel() translation?.invoke() } // Bottom-lock the credit and playback readout independently of synopsis length. @@ -332,15 +333,17 @@ private fun TitleBlock( logoUrl: String?, ) { val seriesContext = seriesTitle?.trim()?.takeIf { it.isNotEmpty() } + // "Show title art" off: every title here is text, episodes included. + val shownLogoUrl = titleLogoUrl(logoUrl) when { seriesContext != null -> EpisodeHierarchyTitle( seriesTitle = seriesContext, episodeTitle = title, - logoUrl = logoUrl, + logoUrl = shownLogoUrl, ) - !logoUrl.isNullOrBlank() -> AsyncImage( - model = logoUrl, + !shownLogoUrl.isNullOrBlank() -> AsyncImage( + model = shownLogoUrl, contentDescription = title, contentScale = ContentScale.Fit, alignment = Alignment.BottomStart, @@ -467,78 +470,24 @@ private fun MetadataRow( ratingChip: String?, compactRating: Boolean = false, ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(7.dp), - ) { - ratingChip?.takeIf { it.isNotBlank() }?.let { rating -> - RatingChip(text = rating, compact = compactRating) - } - tokens.forEachIndexed { index, token -> - if (index > 0) MetadataDivider() - when (token) { - is TvHeroFactToken.TextToken -> Text( - text = token.value, - fontWeight = FontWeight.Medium, - fontSize = 14.sp, - lineHeight = 16.sp, - color = Color.White.copy(alpha = 0.88f), - maxLines = 1, - ) - is TvHeroFactToken.Rating -> Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(3.dp), - ) { - Icon( - imageVector = Icons.Filled.CheckCircle, - contentDescription = null, - tint = SuccessGreen.copy(alpha = 0.9f), - modifier = Modifier.height(12.dp), - ) - Text( - text = token.value, - fontWeight = FontWeight.Medium, - fontSize = 14.sp, - lineHeight = 16.sp, - color = Color.White.copy(alpha = 0.88f), - maxLines = 1, - ) - } - is TvHeroFactToken.Chip -> Text( - text = homeStyleFormatLabel(token.value), - fontWeight = FontWeight.Medium, - fontSize = 14.sp, - lineHeight = 16.sp, - fontFamily = FontFamily.Monospace, - letterSpacing = 0.52.sp, - color = Color.White.copy(alpha = 0.55f), - maxLines = 1, - ) - } - } - sourceTokens.forEachIndexed { index, token -> - if (tokens.isNotEmpty() || index > 0) MetadataDivider() - Text( - text = token, + // Like tvOS: the source tokens (type, genres) give way first, then + // ratings from the end of the list, so the other facts and any playback + // formats stay visible. + TvFactsRow( + tokens = tokens + sourceTokens.map { TvHeroFactToken.TextToken(it, givesWayFirst = true) }, + style = LocalTextStyle.current.merge( + TextStyle( fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 16.sp, - color = Color.White.copy(alpha = 0.90f), - maxLines = 1, - ) - } - - } -} - -@Composable -private fun MetadataDivider() { - Text( - text = "·", - fontWeight = FontWeight.SemiBold, - fontSize = 14.sp, - lineHeight = 16.sp, - color = Color.White.copy(alpha = 0.45f), + color = Color.White.copy(alpha = 0.88f), + ), + ), + dividerColor = Color.White.copy(alpha = 0.45f), + ratingsDropFirst = true, + leading = ratingChip?.takeIf { it.isNotBlank() }?.let { rating -> + { RatingChip(text = rating, compact = compactRating) } + }, ) } @@ -570,12 +519,6 @@ private fun RatingChip(text: String, compact: Boolean) { } } -private fun homeStyleFormatLabel(value: String): String = when (value.uppercase()) { - "DOLBY VISION" -> "Dolby Vision" - "ATMOS" -> "Atmos" - else -> value -} - private fun splitDisplayTitle(raw: String): Pair<String, String?> { val separators = listOf(": ", " — ", " – ", " - ") for (sep in separators) { diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadata.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadata.kt index bf5e032c2c..99b90c71d8 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadata.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadata.kt @@ -1,16 +1,18 @@ package org.prairieserver.prairie.tv.ui.screens.detail +import org.prairieserver.prairie.model.catalog.DisplayRating import org.prairieserver.prairie.model.catalog.FileVersion import org.prairieserver.prairie.model.catalog.EpisodeListItem import org.prairieserver.prairie.model.catalog.ItemDetail import org.prairieserver.prairie.model.catalog.isAudiobookItemType import org.prairieserver.prairie.model.catalog.selectedMediaRuntimeMinutes +import org.prairieserver.prairie.model.catalog.titleRatings +import org.prairieserver.prairie.tv.ui.components.TvHeroFactToken import java.time.Instant import java.time.LocalDate import java.time.OffsetDateTime import java.time.ZoneId import java.time.ZoneOffset -import kotlin.math.round /** * Pure functions that build the hero metadata for the cinematic detail @@ -57,16 +59,20 @@ internal object TvDetailMetadata { /** Episode editorial facts used by the combined Series page. These mirror * tvOS: the episode's air date and runtime replace the Show facts, while - * version choices remain directly beneath the episode carousel. */ + * version choices remain directly beneath the episode carousel. The Show's + * [ratings] stay, like its content-rating chip, so focusing an episode + * never drops the ratings row. */ fun seriesEpisodeFactsLine( episode: EpisodeListItem, runtimeMinutes: Int = episode.runtime, + ratings: List<DisplayRating> = emptyList(), zone: ZoneId = ZoneId.systemDefault(), ): List<TvHeroFactToken> = buildList { abbreviatedDate(episode.airDate, zone)?.let { add(TvHeroFactToken.TextToken(it)) } runtimeLabel(runtimeMinutes)?.let { add(TvHeroFactToken.TextToken(it)) } + ratings.forEach { add(TvHeroFactToken.ExternalRating(it)) } } fun factsLine( @@ -95,9 +101,7 @@ internal object TvDetailMetadata { tokens += TvHeroFactToken.TextToken(it) } } - detail.ratingImdb?.let { - tokens += TvHeroFactToken.TextToken("★ ${formatOneDecimal(it)}") - } + detail.titleRatings().forEach { tokens += TvHeroFactToken.ExternalRating(it) } if (includePlaybackFormats) { tokens += qualityTokens(selectedVersion) } @@ -159,13 +163,6 @@ internal object TvDetailMetadata { ?: runCatching { OffsetDateTime.parse(raw).toInstant() }.getOrNull() ?: runCatching { LocalDate.parse(raw).atStartOfDay(ZoneOffset.UTC).toInstant() }.getOrNull() - private fun formatOneDecimal(value: Double): String { - val rounded = round(value * 10.0) / 10.0 - val whole = rounded.toInt() - val tenths = (round((rounded - whole) * 10.0)).toInt().coerceIn(0, 9) - return "$whole.$tenths" - } - private fun qualityTokens(version: FileVersion?): List<TvHeroFactToken> { version ?: return emptyList() val tokens = mutableListOf<TvHeroFactToken>() diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvEpisodeWindow.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvEpisodeWindow.kt index 12ecec9680..266100dfef 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvEpisodeWindow.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvEpisodeWindow.kt @@ -14,6 +14,13 @@ internal class TvEpisodeWindow { pages[season] = episodes.sortedBy { it.episodeNumber } } + /** Rewrites cached pages in place: one season, or every season when [season] is null. */ + fun mapEpisodes(season: Int?, transform: (EpisodeListItem) -> EpisodeListItem) { + pages.replaceAll { number, episodes -> + if (season == null || number == season) episodes.map(transform) else episodes + } + } + fun orderedSeasons(seasons: List<Season>): List<Int> = seasons.sortedWith(compareBy<Season> { it.isSpecialsForDisplay() }.thenBy { it.seasonNumber }) .map { it.seasonNumber }.distinct() diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvExpandableSynopsis.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvExpandableSynopsis.kt index 96c65dccec..a390c3bdad 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvExpandableSynopsis.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvExpandableSynopsis.kt @@ -28,7 +28,6 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged @@ -50,6 +49,8 @@ import androidx.compose.ui.window.PopupProperties import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import kotlinx.coroutines.launch +import org.prairieserver.prairie.tv.ui.components.TvDialogDefaults +import org.prairieserver.prairie.tv.ui.components.tvDialogSurface import org.prairieserver.prairie.tv.ui.focus.TvContentInitialFocusMaxAttempts import org.prairieserver.prairie.tv.ui.focus.claimFocusOrReport import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved @@ -172,20 +173,14 @@ private fun TvSynopsisDialog( Box( modifier = Modifier .fillMaxSize() - .background(Color.Black.copy(alpha = 0.66f)), + .background(TvDialogDefaults.Scrim), contentAlignment = Alignment.Center, ) { Box( modifier = Modifier .fillMaxWidth(0.62f) .fillMaxHeight(0.72f) - .clip(RoundedCornerShape(18.dp)) - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.98f)) - .border( - 0.6.dp, - Color.White.copy(alpha = 0.14f), - RoundedCornerShape(18.dp), - ) + .tvDialogSurface() .onPreviewKeyEvent { event -> when { event.type == KeyEventType.KeyUp && diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt index e574ce84a4..6bb563e24f 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt @@ -1,6 +1,5 @@ package org.prairieserver.prairie.tv.ui.screens.detail -import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.FastOutSlowInEasing @@ -40,6 +39,14 @@ import androidx.compose.material.icons.filled.BookmarkBorder import androidx.compose.material.icons.filled.MoreHoriz import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.SkipPrevious +import androidx.compose.material.icons.rounded.CheckCircleOutline +import androidx.compose.material.icons.rounded.FavoriteBorder +import androidx.compose.material.icons.rounded.Groups +import androidx.compose.material.icons.rounded.HeartBroken +import androidx.compose.material.icons.rounded.RemoveDone +import androidx.compose.material.icons.rounded.Shuffle +import androidx.compose.material.icons.rounded.Tv +import androidx.compose.material.icons.rounded.VideoLibrary import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -92,14 +99,19 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.koin.compose.koinInject +import org.prairieserver.prairie.common.ui.OnViewerAccessChanged +import org.prairieserver.prairie.network.AccessChangeSignals import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf import org.prairieserver.prairie.audiobook.AudioPlaybackTrack import org.prairieserver.prairie.audiobook.AudiobookTimeline import org.prairieserver.prairie.audiobook.buildAudiobookTimeline +import org.prairieserver.prairie.common.cards.LocalEpisodeSpoilerPrefs import org.prairieserver.prairie.common.ui.movieDirectorCredit import org.prairieserver.prairie.common.ui.openYoutubeTrailer import org.prairieserver.prairie.metadata.DescriptionTranslationPhase +import org.prairieserver.prairie.model.catalog.hasMachineTranslatedOverview +import org.prairieserver.prairie.model.catalog.pendingEpisodeTranslationLanguage import org.prairieserver.prairie.model.audiobook.AudiobookNarration import org.prairieserver.prairie.model.catalog.EpisodeListItem import org.prairieserver.prairie.model.catalog.FileVersion @@ -109,13 +121,14 @@ import org.prairieserver.prairie.model.catalog.VersionChapter import org.prairieserver.prairie.model.catalog.isAudiobookItemType import org.prairieserver.prairie.model.catalog.isSpecialsForDisplay import org.prairieserver.prairie.model.catalog.selectedMediaRuntimeMinutes +import org.prairieserver.prairie.model.catalog.titleRatings import org.prairieserver.prairie.model.catalog.trailerRailEntries import org.prairieserver.prairie.model.ebook.MediaRelatedItem -import org.prairieserver.prairie.model.feature.CLIENT_WATCH_TOGETHER_SURFACE_ENABLED import org.prairieserver.prairie.model.feature.MetadataAiFeatureStore import org.prairieserver.prairie.model.metadata.MetadataAiOnView import org.prairieserver.prairie.model.section.SectionItem -import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.settings.EpisodeSpoilerPrefs +import org.prairieserver.prairie.model.settings.EpisodeSpoilers import org.prairieserver.prairie.tv.ui.navigation.TvSubtitleLaunchSelection import org.prairieserver.prairie.tv.ui.navigation.explicitTvSubtitleLaunchSelection import org.prairieserver.prairie.tv.ui.components.TvDialogOption @@ -133,12 +146,16 @@ import org.prairieserver.prairie.tv.ui.focus.TvObservedFocusResult import org.prairieserver.prairie.tv.ui.focus.claimFocusOrReport import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved import org.prairieserver.prairie.tv.ui.screens.audiobook.formatAudiobookTime -import org.prairieserver.prairie.tv.ui.screens.watchtogether.TvJoinCodeDialog -import org.prairieserver.prairie.tv.ui.screens.watchtogether.TvSuggestToRoomViewModel -import org.prairieserver.prairie.tv.ui.screens.watchtogether.TvWatchTogetherEntryDialog -import org.prairieserver.prairie.tv.ui.screens.watchtogether.TvWatchTogetherViewModel +import org.prairieserver.prairie.tv.ui.screens.watchparty.TvWatchPartyDetailEffects +import org.prairieserver.prairie.tv.ui.screens.watchparty.TvWatchPartyPlayGuardDialog +import org.prairieserver.prairie.tv.ui.screens.watchparty.rememberTvWatchPartyDetailEntry +import org.prairieserver.prairie.tv.ui.screens.watchparty.rememberTvWatchPartyDetailOption +import org.prairieserver.prairie.tv.ui.screens.watchparty.rememberTvWatchPartyPlayGuard +import org.prairieserver.prairie.tv.ui.screens.watchparty.tvWatchPartyItem +import org.prairieserver.prairie.watchtogether.WatchPartyDestination import org.prairieserver.prairie.tv.ui.theme.Spacing import org.prairieserver.prairie.tv.ui.theme.TvSmoothBringIntoViewSpec +import org.prairieserver.prairie.viewmodel.WatchPartySpoilers internal data class TvSeriesDetailRedirect( val seriesContentId: String, @@ -189,15 +206,22 @@ fun TvItemDetailScreen( ) -> Unit, onSeriesClick: (seriesId: String) -> Unit, onSeasonClick: (seriesId: String, seasonNumber: Int) -> Unit, - onWatchTogether: (RoomSnapshot) -> Unit, + /** Open a Watch Party screen: the lobby or player, or the hub for null. */ + onWatchParty: (WatchPartyDestination?) -> Unit, onOpenPerson: (personId: Long) -> Unit, onBack: () -> Unit, + // Plays the first pick of a shuffle started from the More menu. + onShuffleStarted: (org.prairieserver.prairie.model.shuffle.Shuffle) -> Unit = {}, viewModel: TvItemDetailViewModel = koinViewModel( key = "item-detail-$contentId-$libraryId-${seasonNumber ?: "default"}-${initialEpisodeContentId ?: "default"}", parameters = { parametersOf(contentId, libraryId) }, ), ) { val state by viewModel.uiState.collectAsState() + val shuffleLauncher = org.prairieserver.prairie.common.ui.rememberShuffleLauncher( + org.koin.compose.koinInject(), + onShuffleStarted, + ) val seriesRedirect = remember(state.detail) { state.detail?.let(::tvSeriesDetailRedirect) } @@ -215,6 +239,17 @@ fun TvItemDetailScreen( BackHandler(enabled = true) { onBack() } + // D5: while this TV is in a Watch Party, Play (and extras) first asks to + // leave the party; leaving is never hidden behind a solo start. + val watchPartyPlayGuard = rememberTvWatchPartyPlayGuard() + val guardedOnPlay: (String, Int?, Int?, Boolean, TvSubtitleLaunchSelection?, String?, Double?) -> Unit = + { playContentId, fileId, audioTrackIndex, audioPicked, subtitleSelection, itemType, resumePositionSeconds -> + watchPartyPlayGuard.requestPlay { + onPlay(playContentId, fileId, audioTrackIndex, audioPicked, subtitleSelection, itemType, resumePositionSeconds) + } + } + TvWatchPartyPlayGuardDialog(watchPartyPlayGuard) + LaunchedEffect(seriesRedirect, seriesRedirectFailed) { val redirect = seriesRedirect ?: return@LaunchedEffect if (seriesRedirectFailed) return@LaunchedEffect @@ -239,6 +274,10 @@ fun TvItemDetailScreen( // any effect-local "skip the first" flag would reset and swallow exactly // the resume we care about. refreshOnReturn() no-ops while detail is still // null, which covers the initial load. + // The same quiet refresh when the server reports an access change, so + // availability, versions, and quality limits follow the new policy; a + // title the viewer can no longer see shows the unavailable state. + OnViewerAccessChanged(koinInject<AccessChangeSignals>(), viewModel.accessChanges) { viewModel.refreshAfterAccessChange() } val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current androidx.compose.runtime.DisposableEffect(lifecycleOwner) { val observer = androidx.lifecycle.LifecycleEventObserver { _, event -> @@ -306,13 +345,15 @@ fun TvItemDetailScreen( initialSeasonNumber = seasonNumber, initialEpisodeContentId = initialEpisodeContentId, viewModel = viewModel, - onPlay = onPlay, + libraryId = libraryId, + onPlay = guardedOnPlay, onItemDetail = onItemDetail, onItemDetailReplace = onItemDetailReplace, onSeriesClick = onSeriesClick, onSeasonClick = onSeasonClick, - onWatchTogether = onWatchTogether, + onWatchParty = onWatchParty, onOpenPerson = onOpenPerson, + shuffleLauncher = shuffleLauncher, ) } } @@ -325,13 +366,15 @@ private fun TvDetailContent( initialSeasonNumber: Int?, initialEpisodeContentId: String?, viewModel: TvItemDetailViewModel, + libraryId: Int?, onPlay: (contentId: String, fileId: Int?, audioTrackIndex: Int?, audioPickedThisSession: Boolean, subtitleSelection: TvSubtitleLaunchSelection?, itemType: String?, resumePositionSeconds: Double?) -> Unit, onItemDetail: (contentId: String) -> Unit, onItemDetailReplace: (contentId: String) -> Unit, onSeriesClick: (seriesId: String) -> Unit, onSeasonClick: (seriesId: String, seasonNumber: Int) -> Unit, - onWatchTogether: (RoomSnapshot) -> Unit, + onWatchParty: (WatchPartyDestination?) -> Unit, onOpenPerson: (personId: Long) -> Unit, + shuffleLauncher: org.prairieserver.prairie.common.ui.ShuffleLauncher, ) { val playFocus = remember { FocusRequester() } // The circular Version control in the hero action cluster. Hoisted here so @@ -616,9 +659,26 @@ private fun TvDetailContent( ?: episode.title ?: "Episode ${episode.episodeNumber}" } ?: detail.title - val heroOverview = activeSeriesEpisode?.let { episode -> - activeSeriesPlaybackDetail?.overview ?: episode.overview - } ?: detail.overview + // Spoiler protection: the focused episode's (or an episode page's) + // description stays hidden until the profile starts it. The rail row's + // watch state is the one "Mark as Watched" updates, so it wins. + val spoilerPrefs = LocalEpisodeSpoilerPrefs.current + val hidesHeroOverview = when { + activeSeriesEpisode != null -> spoilerPrefs.hidesOverview( + EpisodeSpoilers.isUnwatched( + activeSeriesEpisode.userData ?: activeSeriesPlaybackDetail?.userData, + ), + ) + detail.type == "episode" -> spoilerPrefs.hidesOverview(EpisodeSpoilers.isUnwatched(detail.userData)) + else -> false + } + val heroOverview = if (hidesHeroOverview) { + null + } else { + activeSeriesEpisode?.let { episode -> + activeSeriesPlaybackDetail?.overview ?: episode.overview + } ?: detail.overview + } // Always derive the Series credit from the Show itself. Episode focus can // replace the synopsis and playback target, but must never make Starring or // the controls underneath it jump to a different position. @@ -643,6 +703,7 @@ private fun TvDetailContent( runtimeMinutes = activeSeriesPlaybackDetail?.let { playbackDetail -> selectedMediaRuntimeMinutes(playbackDetail, activeSeriesSelectedVersion) } ?: episode.runtime, + ratings = detail.titleRatings(), ) } ?: TvDetailMetadata.factsLine( detail = detail, @@ -650,7 +711,7 @@ private fun TvDetailContent( selectedFileId = heroSelectedFileId, includePlaybackFormats = false, ) - val heroArtwork = resolveTvDetailHeroArtwork(detail, state.nextUpEpisode) + val heroArtwork = resolveTvDetailHeroArtwork(detail, state.nextUpEpisode, spoilerPrefs) val detailPageTint = rememberAmbientBackdropTintState() LaunchedEffect(heroArtwork.url) { detailPageTint.set(item = null, url = heroArtwork.url) @@ -718,6 +779,34 @@ private fun TvDetailContent( viewModel.translateDescription(auto = true) } } + // A series page's rail lists the selected season's episodes; the season's + // job fills any episode description still missing. + val pendingEpisodeLanguage = state.episodes + .filter { it.seasonNumber == state.selectedSeason } + .pendingEpisodeTranslationLanguage() + LaunchedEffect( + detail.contentId, + state.selectedSeason, + state.seasons.isNotEmpty(), + pendingEpisodeLanguage, + metadataAiStatus.onView, + ) { + if (metadataAiStatus.onView == MetadataAiOnView.Auto && pendingEpisodeLanguage != null) { + viewModel.translateShownSeasonEpisodes() + } + } + // Label the shown description when the server reports it machine-translated: + // the focused episode's on a series page, else the page item's own. + val heroMachineTranslated = if (activeSeriesEpisode != null) { + if (!activeSeriesPlaybackDetail?.overview.isNullOrBlank()) { + hasMachineTranslatedOverview(activeSeriesPlaybackDetail?.machineTranslatedFields) + } else { + hasMachineTranslatedOverview(activeSeriesEpisode.machineTranslatedFields) + } + } else { + hasMachineTranslatedOverview(detail.machineTranslatedFields) && + translationPhase != DescriptionTranslationPhase.Translating + } && !heroOverview.isNullOrBlank() val translationSlot: (@Composable () -> Unit)? = if (detail.pendingTranslationLanguage != null && (metadataAiStatus.onView == MetadataAiOnView.Button || @@ -818,7 +907,10 @@ private fun TvDetailContent( tagline = detail.tagline.takeIf { activeSeriesEpisode == null }, factsLine = heroFactsLine, directorText = heroCreditText, - translation = translationSlot.takeIf { activeSeriesEpisode == null }, + translation = translationSlot.takeIf { + activeSeriesEpisode == null && !hidesHeroOverview + }, + machineTranslated = heroMachineTranslated && !hidesHeroOverview, compactSeries = isSeriesDetail, playbackSummary = { TvDetailPlaybackSelectionSummary( @@ -841,7 +933,42 @@ private fun TvDetailContent( onPlay = onPlay, onSeriesClick = onSeriesClick, onSeasonClick = onSeasonClick, - onWatchTogether = onWatchTogether, + libraryId = libraryId, + onWatchParty = onWatchParty, + // Season mode marks the selected season; + // Show mode offers only the whole series. + watchedSeason = state.seasons + .firstOrNull { it.seasonNumber == state.selectedSeason } + ?.takeIf { isSeriesDetail && !isShowingSeriesOverview && it.episodeCount > 0 }, + onShuffleSeries = if ( + isSeriesDetail && + shuffleLauncher.supports(org.prairieserver.prairie.model.shuffle.ShuffleScopeKind.SERIES) + ) { + { + shuffleLauncher.start( + org.prairieserver.prairie.model.shuffle.ShuffleScopeKind.SERIES, + detail.contentId, + ) + } + } else { + null + }, + // Season mode offers the season on screen, + // hidden with fewer than two playable episodes. + shuffleSeason = state.seasons + .firstOrNull { it.seasonNumber == state.selectedSeason } + ?.takeIf { season -> + isSeriesDetail && !isShowingSeriesOverview && !state.episodesLoading && + state.episodes.all { it.seasonNumber == season.seasonNumber } && + org.prairieserver.prairie.model.shuffle.canShuffleSeason(state.episodes) && + shuffleLauncher.supports(org.prairieserver.prairie.model.shuffle.ShuffleScopeKind.SEASON) + }, + onShuffleSeason = { season -> + shuffleLauncher.start( + org.prairieserver.prairie.model.shuffle.ShuffleScopeKind.SEASON, + season.contentId, + ) + }, ) }, ) @@ -1487,23 +1614,15 @@ private fun HeroActionRow( onPlay: (contentId: String, fileId: Int?, audioTrackIndex: Int?, audioPickedThisSession: Boolean, subtitleSelection: TvSubtitleLaunchSelection?, itemType: String?, resumePositionSeconds: Double?) -> Unit, onSeriesClick: (seriesId: String) -> Unit, onSeasonClick: (seriesId: String, seasonNumber: Int) -> Unit, - onWatchTogether: (RoomSnapshot) -> Unit, + libraryId: Int?, + onWatchParty: (WatchPartyDestination?) -> Unit, + watchedSeason: org.prairieserver.prairie.model.catalog.Season? = null, + onShuffleSeries: (() -> Unit)? = null, + shuffleSeason: org.prairieserver.prairie.model.catalog.Season? = null, + onShuffleSeason: (org.prairieserver.prairie.model.catalog.Season) -> Unit = {}, ) { - val suggestViewModel: TvSuggestToRoomViewModel = koinViewModel() - val activeRoom by suggestViewModel.room.collectAsState() - val suggestState by suggestViewModel.uiState.collectAsState() - val suggestContext = LocalContext.current - LaunchedEffect(suggestState.notice, suggestState.error) { - val message = suggestState.notice ?: suggestState.error - if (message != null) { - Toast.makeText(suggestContext, message, Toast.LENGTH_SHORT).show() - suggestViewModel.consumeNotice() - suggestViewModel.clearError() - } - } + val watchPartyEntry = rememberTvWatchPartyDetailEntry() var moreOpen by remember(detail.contentId) { mutableStateOf(false) } - var watchTogetherOpen by remember(detail.contentId) { mutableStateOf(false) } - var joinCodeOpen by remember(detail.contentId) { mutableStateOf(false) } var playLaunchPending by remember(detail.contentId) { mutableStateOf(false) } LaunchedEffect(playLaunchPending) { if (playLaunchPending) { @@ -1539,11 +1658,6 @@ private fun HeroActionRow( // higher-frequency Watchlist toggle is visible in the stable action row. val hasSeriesNavigation = detail.type in setOf("episode", "season") && detail.seriesId != null val hasOverflowNavigation = hasSeriesNavigation - val hasWatchTogether = - CLIENT_WATCH_TOGETHER_SURFACE_ENABLED && !isAudiobookItemType(detail.type) - val hasSuggestionTarget = detail.type in setOf("movie", "episode") || nextUp != null - val canSuggestToRoom = - CLIENT_WATCH_TOGETHER_SURFACE_ENABLED && activeRoom != null && hasSuggestionTarget // Version set + selection state driving the selector row / Play file id. // Series/season use the next-up episode's versions + the next-up selection; @@ -1583,6 +1697,43 @@ private fun HeroActionRow( ) } val selectedFileId = selectedVersion?.fileId + // Watch Party offers movies, episodes, and series (as the next-up + // episode), staging the edition this page displays. + val watchPartyItem = when { + detail.type == "movie" || detail.type == "episode" -> tvWatchPartyItem( + contentId = detail.contentId, + contentType = detail.type, + title = detail.title, + subtitle = if (detail.type == "episode") { + listOfNotNull( + detail.seriesTitle, + detail.seasonNumber?.let { season -> detail.episodeNumber?.let { "S${season}E$it" } }, + ).joinToString(" · ") + } else { + detail.year.takeIf { it > 0 }?.toString() + }, + posterUrl = detail.posterUrl, + fileId = selectedFileId, + libraryId = libraryId, + spoilers = WatchPartySpoilers.of(detail), + ) + detail.type == "series" && nextUp != null && playReady -> tvWatchPartyItem( + contentId = nextUp.contentId, + contentType = "episode", + title = nextUp.title?.takeIf { it.isNotBlank() } ?: "Episode ${nextUp.episodeNumber}", + subtitle = "${detail.title} · S${nextUp.seasonNumber}E${nextUp.episodeNumber}", + posterUrl = nextUp.stillUrl ?: detail.posterUrl, + fileId = selectedFileId, + libraryId = libraryId, + spoilers = WatchPartySpoilers( + unwatchedEpisode = EpisodeSpoilers.isUnwatched(nextUp.userData), + posterIsEpisodeStill = if (!nextUp.stillUrl.isNullOrBlank()) nextUp.stillIsEpisodeStill else false, + backdropIsEpisodeStill = false, + ), + ) + else -> null + } + val watchPartyOption = rememberTvWatchPartyDetailOption(watchPartyEntry, watchPartyItem) val automaticAudioTrackOrdinal = resolveTvAutomaticAudioTrackOrdinal( version = selectedVersion, preferredAudioLanguage = state.preferredAudioLanguage, @@ -1753,11 +1904,39 @@ private fun HeroActionRow( if (moreOpen) { val options = buildList { + // Shuffle leads the menu, as on the web. + if (onShuffleSeries != null) { + add( + TvDialogOption( + key = "shuffle-series", + title = "Shuffle Series", + icon = Icons.Rounded.Shuffle, + onClick = { + moreOpen = false + onShuffleSeries() + }, + ), + ) + } + shuffleSeason?.let { season -> + add( + TvDialogOption( + key = "shuffle-season", + title = "Shuffle ${tvSeasonPickerLabel(season)}", + icon = Icons.Rounded.Shuffle, + onClick = { + moreOpen = false + onShuffleSeason(season) + }, + ), + ) + } add( TvDialogOption( key = "favorite", title = if (state.isFavorite) "Remove from Favorites" else "Add to Favorites", - selected = state.isFavorite, + // Actions take icons, not checks: a check means "selected". + icon = if (state.isFavorite) Icons.Rounded.HeartBroken else Icons.Rounded.FavoriteBorder, onClick = { moreOpen = false viewModel.onToggleFavorite() @@ -1768,41 +1947,38 @@ private fun HeroActionRow( TvDialogOption( key = "watched", title = if (state.isWatched) watchedUnmarkLabel(detail) else watchedMarkLabel(detail), - selected = state.isWatched, + icon = if (state.isWatched) Icons.Rounded.RemoveDone else Icons.Rounded.CheckCircleOutline, onClick = { moreOpen = false viewModel.onToggleWatched() }, ), ) - if (canSuggestToRoom) { + watchedSeason?.let { season -> + val seasonWatched = season.userData?.played == true add( TvDialogOption( - key = "suggest-to-room", - title = "Suggest to Watch Together", - subtitle = "Add to the room you are in", + key = "season-watched", + title = "Mark ${tvSeasonPickerLabel(season)} " + + if (seasonWatched) "Unwatched" else "Watched", + icon = if (seasonWatched) Icons.Rounded.RemoveDone else Icons.Rounded.CheckCircleOutline, onClick = { moreOpen = false - suggestViewModel.suggest( - contentId = playContentId, - contentType = playType, - title = nextUp?.title ?: detail.title, - subtitle = if (nextUp != null) detail.title else detail.seriesTitle, - posterUrl = nextUp?.stillUrl ?: detail.posterUrl, - ) + viewModel.onSetSeasonWatched(season, !seasonWatched) }, ), ) } - if (hasWatchTogether) { + watchPartyOption?.let { option -> add( TvDialogOption( - key = "watch-together", - title = "Watch Together", - subtitle = "Host a room or join by code", + key = "watch-party", + title = option.title, + subtitle = option.subtitle, + icon = Icons.Rounded.Groups, onClick = { moreOpen = false - watchTogetherOpen = true + option.onSelect() }, ), ) @@ -1818,6 +1994,7 @@ private fun HeroActionRow( key = "season-$season", title = "Go to Season $season", subtitle = detail.seriesTitle, + icon = Icons.Rounded.VideoLibrary, onClick = { moreOpen = false onSeasonClick(seriesId, season) @@ -1831,6 +2008,7 @@ private fun HeroActionRow( key = "series", title = "Go to Series", subtitle = detail.seriesTitle, + icon = Icons.Rounded.Tv, onClick = { moreOpen = false onSeriesClick(seriesId) @@ -1844,48 +2022,12 @@ private fun HeroActionRow( title = "More Actions", options = options, onDismiss = { moreOpen = false }, + // Shuffle is first and takes focus over a selected toggle below it. + initialFocusKey = options.firstOrNull()?.key?.takeIf { it.startsWith("shuffle-") }, ) } - if (watchTogetherOpen && hasWatchTogether) { - val watchTogetherViewModel: TvWatchTogetherViewModel = koinViewModel() - val watchTogetherState by watchTogetherViewModel.uiState.collectAsState() - - LaunchedEffect(watchTogetherState.result) { - val room = watchTogetherState.result ?: return@LaunchedEffect - watchTogetherViewModel.consumeResult() - watchTogetherOpen = false - joinCodeOpen = false - onWatchTogether(room) - } - - if (joinCodeOpen) { - TvJoinCodeDialog( - isBusy = watchTogetherState.isBusy, - error = watchTogetherState.error, - onJoin = watchTogetherViewModel::joinRoom, - onDismiss = { - watchTogetherViewModel.clearError() - joinCodeOpen = false - }, - ) - } else { - TvWatchTogetherEntryDialog( - isBusy = watchTogetherState.isBusy, - error = watchTogetherState.error, - onHost = { watchTogetherViewModel.createRoom(playContentId, playFileId) }, - onHostVote = watchTogetherViewModel::createEmptyVoteRoom, - onJoin = { - watchTogetherViewModel.clearError() - joinCodeOpen = true - }, - onDismiss = { - watchTogetherViewModel.clearError() - watchTogetherOpen = false - }, - ) - } - } + TvWatchPartyDetailEffects(entry = watchPartyEntry, onNavigate = onWatchParty) } internal data class SeriesEpisodePlaybackLaunch( @@ -2555,21 +2697,34 @@ internal data class TvDetailHeroArtwork( * Item detail responses can legitimately omit series/season artwork even * though their loaded episode rows carry landscape stills. Prefer the real * backdrop, then an episodic landscape still; never stretch a portrait poster - * across a movie/series hero. + * across a movie/series hero. A still that spoiler protection hides is never + * shown full-size here: only its ThumbHash colour field is used. */ internal fun resolveTvDetailHeroArtwork( detail: ItemDetail, nextUpEpisode: EpisodeListItem?, + spoilerPrefs: EpisodeSpoilerPrefs = EpisodeSpoilerPrefs.NONE, ): TvDetailHeroArtwork { if (!detail.backdropUrl.isNullOrBlank() || !detail.backdropThumbhash.isNullOrBlank()) { - return TvDetailHeroArtwork(detail.backdropUrl, detail.backdropThumbhash) + val hidesBackdrop = detail.type.equals("episode", ignoreCase = true) && + spoilerPrefs.hidesImage(EpisodeSpoilers.isUnwatched(detail.userData), detail.backdropIsEpisodeStill) + return TvDetailHeroArtwork(detail.backdropUrl.takeUnless { hidesBackdrop }, detail.backdropThumbhash) } return when (detail.type.lowercase()) { - "series", "season" -> TvDetailHeroArtwork( - nextUpEpisode?.stillUrl, - nextUpEpisode?.stillThumbhash, + "series", "season" -> { + val hidesStill = nextUpEpisode != null && + spoilerPrefs.hidesImage(EpisodeSpoilers.isUnwatched(nextUpEpisode.userData), nextUpEpisode.stillIsEpisodeStill) + TvDetailHeroArtwork( + nextUpEpisode?.stillUrl.takeUnless { hidesStill }, + nextUpEpisode?.stillThumbhash, + ) + } + "episode" -> TvDetailHeroArtwork( + detail.posterUrl.takeUnless { + spoilerPrefs.hidesImage(EpisodeSpoilers.isUnwatched(detail.userData), detail.posterIsEpisodeStill) + }, + detail.posterThumbhash, ) - "episode" -> TvDetailHeroArtwork(detail.posterUrl, detail.posterThumbhash) else -> TvDetailHeroArtwork(null, null) } } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailViewModel.kt index e654673f9d..4d23d24853 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailViewModel.kt @@ -20,13 +20,18 @@ import org.prairieserver.prairie.common.settings.dolbyVisionPolicySnapshot import org.prairieserver.prairie.domain.settings.ProfileSettingsController import org.prairieserver.prairie.model.catalog.BrowseItem import org.prairieserver.prairie.model.catalog.CastMember +import org.prairieserver.prairie.model.catalog.withTranslatedDescription +import org.prairieserver.prairie.model.catalog.withEpisodeDescription import org.prairieserver.prairie.model.catalog.EpisodeListItem import org.prairieserver.prairie.model.catalog.FileVersion import org.prairieserver.prairie.model.catalog.ItemDetail import org.prairieserver.prairie.model.catalog.LeafItemUserData import org.prairieserver.prairie.model.catalog.Season +import org.prairieserver.prairie.model.catalog.SeasonUserData import org.prairieserver.prairie.model.catalog.isAudiobookItemType import org.prairieserver.prairie.model.catalog.initialSeasonDisplayPlan +import org.prairieserver.prairie.model.catalog.sortedForDisplay +import org.prairieserver.prairie.model.catalog.pendingEpisodeTranslationLanguage import org.prairieserver.prairie.model.playback.combinedSubtitleSelectionIndexes import org.prairieserver.prairie.model.playback.ClientCodecCapabilities import org.prairieserver.prairie.model.playback.buildPlaybackSubtitleChoices @@ -37,6 +42,7 @@ import org.prairieserver.prairie.playback.resolveSubtitleTrackOrdinal import org.prairieserver.prairie.playback.subtitleTrackFingerprint import org.prairieserver.prairie.model.section.SectionItem import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.isAccessRefusal import org.prairieserver.prairie.network.IdentityTransitionBarrier import org.prairieserver.prairie.network.IdentityTransitionPhase import org.prairieserver.prairie.network.TokenManager @@ -50,6 +56,7 @@ import org.prairieserver.prairie.tv.ui.util.isTvHiddenMediaType import org.prairieserver.prairie.tv.ui.util.visibleOnTv import org.prairieserver.prairie.viewmodel.applyLocalPlaybackProgress import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -371,6 +378,9 @@ class TvItemDetailViewModel( private val identityTransitions: IdentityTransitionBarrier, private val capabilityDetector: PlaybackCapabilityDetector? = null, ) : ViewModel() { + /** Access changes this ViewModel has applied, kept while its screen is away. */ + val accessChanges = org.prairieserver.prairie.network.AccessChangeCursor() + private var similarGeneration = 0L private val _uiState = MutableStateFlow(TvItemDetailUiState()) @@ -399,6 +409,16 @@ class TvItemDetailViewModel( val translationPhase: StateFlow<org.prairieserver.prairie.metadata.DescriptionTranslationPhase> = descriptionTranslation.phase + // A season's translation job also fills its episodes' descriptions. The + // series page lists the selected season's episodes in its rail, so it + // starts that job when an episode row reports a missing description. Its + // own controller keeps the series overview's single-flight latch apart. + private val episodeTranslation = + org.prairieserver.prairie.metadata.DescriptionTranslationController( + repository = metadataAiRepository, + delayMs = { kotlinx.coroutines.delay(it) }, + ) + /** * Loads the cascaded subtitle preferences that annotate the selector row's @@ -515,7 +535,8 @@ class TvItemDetailViewModel( } } - fun loadAll() { + /** [afterAccessChange] is passed to [loadDetail]. */ + fun loadAll(afterAccessChange: Boolean = false) { viewModelScope.launch { runCatching { playerSettingsStore.refreshFromServer() } } @@ -527,7 +548,7 @@ class TvItemDetailViewModel( // the detail must succeed before we render, but favorite/watchlist // state can trickle in afterward. loadUserState() - loadDetail() + loadDetail(afterAccessChange) } } @@ -598,13 +619,21 @@ class TvItemDetailViewModel( return true } - private fun loadDetail() { + /** + * [afterAccessChange] skips joining a Home warm-up still in flight, whose + * answer may predate the change. + */ + private fun loadDetail(afterAccessChange: Boolean = false) { val similarRun = ++similarGeneration moreLikeThisJob?.cancel() + // A newer load replaces an unfinished one, so a response the server + // gave under an older access policy never lands after it. + detailLoadJob?.cancel() + quietDetailJob?.cancel() _uiState.update { it.copy(moreLikeThis = emptyList(), moreLikeThisLoading = false) } - viewModelScope.launch { + detailLoadJob = viewModelScope.launch { val similarOwner = recommendationRepository?.captureSimilarAuthority() - when (val result = catalogRepository.getItemDetail(contentId, libraryId = libraryId)) { + when (val result = catalogRepository.getItemDetail(contentId, libraryId = libraryId, joinWarmup = !afterAccessChange)) { is ApiResult.Success -> { val detail = withLocalProgress(result.data) if (isTvHiddenMediaType(detail.type)) { @@ -653,6 +682,10 @@ class TvItemDetailViewModel( is ApiResult.Error -> _uiState.update { it.copy( isLoading = false, + // The durable cache still holds a title the server now + // refuses; drop the copy seedCachedDetail painted so the + // screen shows the error, not stale actions. + detail = if (result.isAccessRefusal()) null else it.detail, error = result.message.ifBlank { "Failed to load details" }, ) } @@ -685,10 +718,38 @@ class TvItemDetailViewModel( * ended. Deliberately NOT [loadAll] — no loading flashes, and the user's * season selection is preserved. */ - fun refreshOnReturn() { + fun refreshOnReturn() = quietRefresh() + + /** + * [refreshOnReturn] after the server reports an access change, except that + * a refusal ([isAccessRefusal]) replaces the detail with the error the + * initial load shows. Transient failures still keep the current detail. + * With no detail on screen (an earlier change refused it, or the first + * load failed), it runs the full load again, as Retry does, so a title the + * viewer regains access to comes back. A load still in flight is replaced + * the same way, because its answer may predate the change. + */ + fun refreshAfterAccessChange() { + val state = _uiState.value + when { + // A load still in flight may have been answered under the old + // policy: replace its request rather than letting the change pass. + state.isLoading -> loadDetail(afterAccessChange = true) + state.detail == null -> loadAll(afterAccessChange = true) + else -> quietRefresh(showAccessRefusal = true) + } + } + + /** + * [afterWatchedChange] reads the season list and episodes fresh from the + * server and skips repainting the rail from the on-disk catalog cache: + * the cache, and any coalesced request, still hold the pre-write state. + * [showAccessRefusal]: see [refreshAfterAccessChange]. + */ + private fun quietRefresh(afterWatchedChange: Boolean = false, showAccessRefusal: Boolean = false) { val current = _uiState.value.detail ?: return - val playbackReturn = TvDetailTrackSelectionSession.consumePlaybackReturn(contentId) - playbackReturn?.let { saved -> + val consumedPlaybackReturn = TvDetailTrackSelectionSession.consumePlaybackReturn(contentId) + consumedPlaybackReturn?.let { saved -> _uiState.update { val returnedDetail = it.detail?.withPlaybackReturn(saved) val resolvedFileId = returnedDetail?.let { detail -> @@ -703,7 +764,20 @@ class TvItemDetailViewModel( ) } } - viewModelScope.launch { + // A newer detail read replaces an unfinished one, so an older answer + // cannot land after it, for example restoring a title the newer read + // found refused. The replacement inherits an access-change refresh's + // refusal handling, so a return refresh cannot drop it. + val replacing = quietDetailJob?.isActive == true + val showRefusal = showAccessRefusal || (replacing && quietDetailShowsRefusal) + // The replaced read may still owe the player's saved position to its + // answer, unless a watched change has since replaced that position. + val playbackReturn = consumedPlaybackReturn + ?: quietPlaybackReturn.takeIf { replacing && !afterWatchedChange } + quietDetailJob?.cancel() + quietDetailShowsRefusal = showRefusal + quietPlaybackReturn = playbackReturn + quietDetailJob = viewModelScope.launch { // Local overlay first: the player's final position write is already // on disk, so the label corrects before the server round-trip. val overlaid = withLocalProgress(current) @@ -711,7 +785,12 @@ class TvItemDetailViewModel( if (overlaid != current) { _uiState.update { it.copy(detail = overlaid) } } - when (val result = catalogRepository.getItemDetail(contentId, libraryId = libraryId)) { + when (val result = catalogRepository.getItemDetail( + contentId, + libraryId = libraryId, + // An access-change refresh must not reuse a pre-change warm-up. + joinWarmup = !showRefusal, + )) { is ApiResult.Success -> { val detail = withLocalProgress(result.data) .let { refreshed -> playbackReturn?.let(refreshed::withPlaybackReturn) ?: refreshed } @@ -725,8 +804,16 @@ class TvItemDetailViewModel( } } } + is ApiResult.Error -> if (showRefusal && result.isAccessRefusal()) { + _uiState.update { + it.copy( + isLoading = false, + detail = null, + error = result.message.ifBlank { "Failed to load details" }, + ) + } + } // Quiet refresh: on failure keep showing what we have. - is ApiResult.Error, is ApiResult.NetworkError -> Unit } } @@ -743,6 +830,9 @@ class TvItemDetailViewModel( // Null: too far behind to be given a delta, so re-check the lot. val favoritesToRecheck = TvFavoriteRevalidationSession.changedSince(favoritesRevalidatedThrough) + if (current.type.lowercase() == "series" && seriesId != null) { + refreshSeasonsQuietly(seriesId, fresh = afterWatchedChange) + } if (seriesId != null && season != null) { loadEpisodes( seriesId, @@ -750,6 +840,7 @@ class TvItemDetailViewModel( quiet = true, revalidateFavorites = favoritesToRecheck, favoritesVersion = favoritesVersion, + freshRead = afterWatchedChange, ) } } @@ -786,6 +877,14 @@ class TvItemDetailViewModel( if (current.isTogglingWatched) return val target = !current.isWatched val previousDetail = current.detail + val admittedAtMs = System.currentTimeMillis() + val seriesGeneration = if (previousDetail?.type?.lowercase() == "series") { + seasonsRefreshGeneration++ + ++seriesWatchMutationGeneration + } else { + null + } + val episodeMutationWatermark = nextEpisodeWatchMutationGeneration _uiState.update { it.copy( isTogglingWatched = true, @@ -800,6 +899,9 @@ class TvItemDetailViewModel( val result = personalDataRepository.performPersonalWrite(writeIntent) if (!personalDataRepository.isCurrent(writeIntent)) return@launch if (result !is ApiResult.Success) { + if (seriesGeneration != null && seriesGeneration == seriesWatchMutationGeneration) { + failedSeriesWatchGeneration = seriesGeneration + } // Roll back on error. _uiState.update { it.copy( @@ -810,9 +912,45 @@ class TvItemDetailViewModel( } } else { _uiState.update { it.copy(isTogglingWatched = false) } + val isSeries = previousDetail?.type?.lowercase() == "series" + if (isSeries) { + // The server applied the change to every episode, so + // cached neighbour seasons in the carousel follow it. + // Episodes written on their own since this began keep that + // state: any successful write since, or a newer write that + // has not failed. + fun EpisodeListItem.applied(): EpisodeListItem { + val latest = lastEpisodeWatchMutation[contentId] ?: 0L + val keep = (succeededEpisodeWatchMutation[contentId] ?: 0L) > episodeMutationWatermark || + (latest > episodeMutationWatermark && failedEpisodeWatchMutation[contentId] != latest) + return if (keep) this else withWatchedPlaybackState(target) + } + episodeWindow.mapEpisodes(null) { it.applied() } + _uiState.update { state -> + state.copy(episodes = state.episodes.map { it.applied() }) + } + publishCarousel() + userItemState.clearLocalPlaybackProgressBefore( + (_uiState.value.carouselEpisodes + _uiState.value.episodes).map { it.contentId }, + admittedAtMs, + writeIntent.identityGeneration, + ) + } // Re-read server-resolved state (including series/season episode // resolution) without flashing the full detail loading screen. - refreshOnReturn() + quietRefresh(afterWatchedChange = isSeries) + if (isSeries && previousDetail != null) { + // Seasons outside the carousel are read after the visible refresh starts. + val loaded = (_uiState.value.carouselEpisodes + _uiState.value.episodes) + .map { it.seasonNumber } + .toSet() + val unloaded = _uiState.value.seasons.map { it.seasonNumber }.filter { it !in loaded } + userItemState.clearLocalPlaybackProgressBefore( + unloaded.flatMap { seasonEpisodeIds(previousDetail.contentId, it) }, + admittedAtMs, + writeIntent.identityGeneration, + ) + } } } finally { if (watchedMutationOwner == writeIntent) { @@ -823,6 +961,121 @@ class TvItemDetailViewModel( } } + /** + * Marks every episode of [season] from the series page's More dialog. The + * server applies the change to the season's episodes. + */ + fun onSetSeasonWatched(season: Season, watched: Boolean) { + val current = _uiState.value + val detail = current.detail?.takeIf { it.type.lowercase() == "series" } ?: return + val seasonNumber = season.seasonNumber + val previousSeason = current.seasons.firstOrNull { it.seasonNumber == seasonNumber } ?: return + val previousPage = episodeWindow.get(seasonNumber) + val previousEpisodes = current.episodes.takeIf { current.selectedSeason == seasonNumber } + val admittedAtMs = System.currentTimeMillis() + val episodeMutationWatermark = nextEpisodeWatchMutationGeneration + val seriesGenerationAtStart = seriesWatchMutationGeneration + val generation = (seasonWatchMutationGenerations[seasonNumber] ?: 0L) + 1 + seasonWatchMutationGenerations[seasonNumber] = generation + // A season list read already in flight predates this change. + seasonsRefreshGeneration++ + + episodeWindow.mapEpisodes(seasonNumber) { it.withWatchedPlaybackState(watched) } + _uiState.update { state -> + state.copy( + seasons = state.seasons.map { + if (it.seasonNumber != seasonNumber) it else it.copy( + userData = (it.userData ?: SeasonUserData()).copy(played = watched), + ) + }, + episodes = if (state.selectedSeason == seasonNumber) { + state.episodes.map { it.withWatchedPlaybackState(watched) } + } else { + state.episodes + }, + ) + } + publishCarousel() + if (current.selectedSeason == seasonNumber) refreshNextUp(_uiState.value.episodes) + + val writeIntent = personalDataRepository.beginWatched(season.contentId, watched) + viewModelScope.launch { + val result = personalDataRepository.performPersonalWrite(writeIntent) + if (!personalDataRepository.isCurrent(writeIntent)) return@launch + if (seasonWatchMutationGenerations[seasonNumber] != generation) return@launch + seasonWatchMutationGenerations.remove(seasonNumber) + if (result is ApiResult.Success) { + val known = (episodeWindow.get(seasonNumber).orEmpty() + previousEpisodes.orEmpty()) + .filter { it.seasonNumber == seasonNumber } + .map { it.contentId } + val episodeIds = known.ifEmpty { seasonEpisodeIds(detail.contentId, seasonNumber) } + userItemState.clearLocalPlaybackProgressBefore(episodeIds, admittedAtMs, writeIntent.identityGeneration) + // Re-reads the series hero, the season list, and the selected season's episodes. + quietRefresh(afterWatchedChange = true) + return@launch + } + if (seriesWatchMutationGeneration != seriesGenerationAtStart && + failedSeriesWatchGeneration != seriesWatchMutationGeneration + ) { + // A series write since this one began also covers this season; + // the snapshot is stale, so re-read the server instead. + quietRefresh(afterWatchedChange = true) + return@launch + } + // Restore by id: carousel moves may have republished the optimistic + // page. An episode marked on its own since the season write began + // keeps that newer state. + val previousById = (previousPage.orEmpty() + previousEpisodes.orEmpty()) + .filter { + val latest = lastEpisodeWatchMutation[it.contentId] ?: 0L + // An episode written successfully on its own since the season + // write began keeps that state. Otherwise a newer episode + // write that failed rolled back to this season's optimistic + // state, so undo it here too. + (succeededEpisodeWatchMutation[it.contentId] ?: 0L) <= episodeMutationWatermark && + (latest <= episodeMutationWatermark || failedEpisodeWatchMutation[it.contentId] == latest) + } + .associateBy { it.contentId } + episodeWindow.mapEpisodes(seasonNumber) { previousById[it.contentId]?.withTranslatedDescription(it) ?: it } + _uiState.update { state -> + state.copy( + seasons = state.seasons.map { if (it.seasonNumber == seasonNumber) previousSeason else it }, + episodes = if (state.selectedSeason == seasonNumber) { + state.episodes.map { previousById[it.contentId]?.withTranslatedDescription(it) ?: it } + } else { + state.episodes + }, + ) + } + publishCarousel() + if (_uiState.value.selectedSeason == seasonNumber) refreshNextUp(_uiState.value.episodes) + } + } + + /** Episode ids of a season read fresh from the server; empty if the read fails. */ + private suspend fun seasonEpisodeIds(seriesContentId: String, seasonNumber: Int): List<String> = + when (val r = catalogRepository.getEpisodes(seriesContentId, seasonNumber, libraryId = libraryId, fresh = true)) { + is ApiResult.Success -> r.data.episodes.map { it.contentId } + else -> emptyList() + } + + /** Replaces the season list (and its watched state) without touching the selection. */ + private fun refreshSeasonsQuietly(seriesContentId: String, fresh: Boolean = false) { + // Only the latest read publishes; a season watched change bumps the + // generation so an older read cannot undo its optimistic state. + val generation = ++seasonsRefreshGeneration + viewModelScope.launch { + val result = catalogRepository.getSeasons(seriesContentId, libraryId = libraryId, fresh = fresh) + if (result !is ApiResult.Success) return@launch + val seasons = result.data.seasons.sortedForDisplay() + _uiState.update { state -> + if (generation != seasonsRefreshGeneration || state.detail?.contentId != seriesContentId || + seasons.isEmpty() + ) state else state.copy(seasons = seasons) + } + } + } + fun onSetRating(stars: Int) { val current = _uiState.value if (current.isTogglingRating) return @@ -1269,6 +1522,12 @@ class TvItemDetailViewModel( */ private var favoritesRevalidatedThrough: Long = TvFavoriteRevalidationSession.currentVersion() private var moreLikeThisJob: Job? = null + private var detailLoadJob: Job? = null + private var quietDetailJob: Job? = null + /** Whether [quietDetailJob] clears the page on a refusal (an access-change refresh). */ + private var quietDetailShowsRefusal = false + /** The playback return [quietDetailJob] overlays on its answer. */ + private var quietPlaybackReturn: TvDetailTrackSelectionSession.Saved? = null private var nextUpDetailJob: Job? = null private var activeSeriesEpisodeContentId: String? = null // A seasons refresh may complete after the viewer has already changed the @@ -1280,8 +1539,18 @@ class TvItemDetailViewModel( // the rail stay consistent (T15). private var loadedSeason: Int? = null private var episodeListGeneration: Long = 0 + private val episodeDescriptionRevisions = mutableMapOf<Int, Long>() private var nextEpisodeWatchMutationGeneration: Long = 0 private val episodeWatchMutationGenerations = mutableMapOf<String, Long>() + private val seasonWatchMutationGenerations = mutableMapOf<Int, Long>() + // Unlike episodeWatchMutationGenerations, kept after completion so a season + // rollback can tell which episodes changed on their own since it began. + private val lastEpisodeWatchMutation = mutableMapOf<String, Long>() + private val failedEpisodeWatchMutation = mutableMapOf<String, Long>() + private val succeededEpisodeWatchMutation = mutableMapOf<String, Long>() + private var seriesWatchMutationGeneration: Long = 0 + private var failedSeriesWatchGeneration: Long = -1 + private var seasonsRefreshGeneration: Long = 0 private var nextUpPlaybackDetailGeneration: Long = 0 private var nextUpSelectorRevision: Long = 0 private var pendingNextUpSelectionHandoff: PendingNextUpSelectionHandoff? = null @@ -1317,6 +1586,7 @@ class TvItemDetailViewModel( quiet: Boolean = false, revalidateFavorites: Set<String>? = emptySet(), favoritesVersion: Long? = null, + freshRead: Boolean = false, ) { // Cancel any in-flight episode load so a slower response for a // previously-selected season can't overwrite episodes/next-up for the @@ -1327,6 +1597,7 @@ class TvItemDetailViewModel( // the authoritative owner of the network result publications below. episodeLoadRequestGeneration += 1 val requestGeneration = episodeLoadRequestGeneration + val descriptionRevision = episodeDescriptionRevisions[seasonNumber] ?: 0L episodeLoadJob?.cancel() episodeLoadJob = viewModelScope.launch { fun ownsRequest(): Boolean = @@ -1335,9 +1606,14 @@ class TvItemDetailViewModel( if (!ownsRequest()) return@launch if (!quiet) _uiState.update { it.copy(episodesLoading = true) } - seedCachedEpisodes(seriesContentId, seasonNumber) + if (!freshRead) seedCachedEpisodes(seriesContentId, seasonNumber) if (!ownsRequest()) return@launch - val result = catalogRepository.getEpisodes(seriesContentId, seasonNumber, libraryId = libraryId) + val result = catalogRepository.getEpisodes( + seriesContentId, + seasonNumber, + libraryId = libraryId, + fresh = freshRead, + ) if (!ownsRequest()) return@launch when (result) { is ApiResult.Success -> { @@ -1347,9 +1623,16 @@ class TvItemDetailViewModel( if (!ownsRequest()) return@launch loadedSeason = seasonNumber episodeListGeneration += 1 - _uiState.update { it.copy(episodesLoading = false, episodes = episodes) } - refreshNextUp(episodes) - recordCarouselSeason(seasonNumber, episodes) + _uiState.update { state -> + val current = state.episodes.associateBy { it.contentId } + val rows = if (descriptionRevision != (episodeDescriptionRevisions[seasonNumber] ?: 0L)) { + episodes.map { row -> current[row.contentId]?.let(row::withTranslatedDescription) ?: row } + } else episodes + state.copy(episodesLoading = false, episodes = rows) + } + val publishedEpisodes = _uiState.value.episodes + refreshNextUp(publishedEpisodes) + recordCarouselSeason(seasonNumber, publishedEpisodes) val revalidationComplete = refreshEpisodeFavoriteStates(episodes, revalidate = revalidateFavorites) // Caught up only now, and only if every id we were asked to @@ -1471,6 +1754,7 @@ class TvItemDetailViewModel( val listGeneration = episodeListGeneration val mutationGeneration = ++nextEpisodeWatchMutationGeneration episodeWatchMutationGenerations[episodeContentId] = mutationGeneration + lastEpisodeWatchMutation[episodeContentId] = mutationGeneration val updatedEpisodes = previousEpisodes.map { episode -> if (episode.contentId == episodeContentId) episode.withWatchedPlaybackState(watched) else episode } @@ -1483,7 +1767,12 @@ class TvItemDetailViewModel( val result = personalDataRepository.performPersonalWrite(writeIntent) if (!personalDataRepository.isCurrent(writeIntent)) return@launch val isCurrentMutation = episodeWatchMutationGenerations[episodeContentId] == mutationGeneration + if (result is ApiResult.Success) { + succeededEpisodeWatchMutation[episodeContentId] = + maxOf(succeededEpisodeWatchMutation[episodeContentId] ?: 0L, mutationGeneration) + } if (result !is ApiResult.Success) { + if (isCurrentMutation) failedEpisodeWatchMutation[episodeContentId] = mutationGeneration if ( isCurrentMutation && previousEpisode != null && @@ -1492,7 +1781,7 @@ class TvItemDetailViewModel( val live = _uiState.value if (live.selectedSeason == mutationSeason) { val restored = live.episodes.map { episode -> - if (episode.contentId == episodeContentId) previousEpisode else episode + if (episode.contentId == episodeContentId) previousEpisode.withTranslatedDescription(episode) else episode } if (_uiState.compareAndSet(live, live.copy(episodes = restored))) { publishCarousel() @@ -1515,6 +1804,10 @@ class TvItemDetailViewModel( if (!seriesId.isNullOrBlank() && season != null) { loadEpisodes(seriesId, season, quiet = true) } + // The season's watched state may have flipped with this episode. + if (detail?.type?.lowercase() == "series" && !seriesId.isNullOrBlank()) { + refreshSeasonsQuietly(seriesId) + } } } } @@ -1574,8 +1867,15 @@ class TvItemDetailViewModel( val previousId = _uiState.value.nextUpEpisode?.contentId if (nextUp?.contentId == previousId && _uiState.value.nextUpEpisode != null) { // Same target — just refresh the snapshot (userData may have changed) - // without re-loading playback detail. - _uiState.update { it.copy(nextUpEpisode = nextUp, nextUpTargetReady = true) } + // without re-loading playback detail. A fresher row also carries + // the episode's translated description into the hero. + _uiState.update { + it.copy( + nextUpEpisode = nextUp, + nextUpTargetReady = true, + nextUpPlaybackDetail = nextUp?.let { row -> it.nextUpPlaybackDetail?.withEpisodeDescription(row) } ?: it.nextUpPlaybackDetail, + ) + } return } @@ -1718,13 +2018,13 @@ class TvItemDetailViewModel( // read was suspended. Finish loading the target but // leave that newer explicit choice untouched. it.copy( - nextUpPlaybackDetail = playbackDetail, + nextUpPlaybackDetail = it.nextUpEpisode?.let(playbackDetail::withEpisodeDescription) ?: playbackDetail, isLoadingNextUpPlaybackDetail = false, didLoadNextUpPlaybackDetail = true, ) } else { it.copy( - nextUpPlaybackDetail = playbackDetail, + nextUpPlaybackDetail = it.nextUpEpisode?.let(playbackDetail::withEpisodeDescription) ?: playbackDetail, isLoadingNextUpPlaybackDetail = false, didLoadNextUpPlaybackDetail = true, selectedNextUpFileId = selection.fileId, @@ -1872,14 +2172,86 @@ class TvItemDetailViewModel( val refreshed = withLocalProgress(result.data) if (metadataAiRepository.isCurrent(owner) && _uiState.value.detail?.contentId == detail.contentId) { _uiState.update { it.copy(detail = refreshed) } - refreshed.pendingTranslationLanguage + val seriesId = detail.seriesId + val seasonNumber = detail.seasonNumber + if (detail.type.equals("season", ignoreCase = true) && + seriesId != null && seasonNumber != null + ) { + refreshEpisodesForTranslation(seriesId, seasonNumber, owner, target) + ?: refreshed.pendingTranslationLanguage + } else refreshed.pendingTranslationLanguage } else target } else -> target // transient refetch failure: keep polling } }, - onTranslated = { }, + onTranslated = {}, + ) + } + } + + private suspend fun refreshEpisodesForTranslation( + seriesId: String, + seasonNumber: Int, + owner: org.prairieserver.prairie.network.AuthScopeSnapshot?, + target: String, + ): String? { + val requestGeneration = episodeLoadRequestGeneration + val listGeneration = episodeListGeneration + val result = catalogRepository.getEpisodes(seriesId, seasonNumber, libraryId = libraryId, fresh = true) + if (result !is ApiResult.Success || !metadataAiRepository.isCurrent(owner)) return target + kotlinx.coroutines.currentCoroutineContext().ensureActive() + if (_uiState.value.selectedSeason != seasonNumber) return result.data.episodes.pendingEpisodeTranslationLanguage() + val fresh = withLocalProgress(result.data.episodes) + kotlinx.coroutines.currentCoroutineContext().ensureActive() + if (!metadataAiRepository.isCurrent(owner) || + requestGeneration != episodeLoadRequestGeneration || + listGeneration != episodeListGeneration || + _uiState.value.selectedSeason != seasonNumber + ) return target + episodeDescriptionRevisions[seasonNumber] = (episodeDescriptionRevisions[seasonNumber] ?: 0L) + 1 + _uiState.update { state -> + val current = state.episodes.associateBy { it.contentId } + val merged = fresh.sortedBy { it.episodeNumber } + .map { row -> current[row.contentId]?.withTranslatedDescription(row) ?: row } + state.copy(episodes = merged) + } + val episodes = _uiState.value.episodes + refreshNextUp(episodes) + recordCarouselSeason(seasonNumber, episodes) + return result.data.episodes.pendingEpisodeTranslationLanguage() + } + + /** + * `auto` on-view mode for a series page's episode rail: start the shown + * season's translation once per (season, language) when its episode rows + * report a missing description, re-reading the rail until none does. The + * season detail's pending language covers its episodes, so it is the + * completion signal. + */ + fun translateShownSeasonEpisodes() { + val state = _uiState.value + val detail = state.detail ?: return + if (!detail.type.equals("series", ignoreCase = true)) return + val seasonNumber = state.selectedSeason ?: return + val season = state.seasons.firstOrNull { it.seasonNumber == seasonNumber } ?: return + val target = state.episodes + .filter { it.seasonNumber == seasonNumber } + .pendingEpisodeTranslationLanguage() ?: return + // A season switched to while another season's job polls is retried + // when that job ends (below), rather than latched as already fired. + if (!episodeTranslation.claimAutoFire(season.contentId, target)) return + episodeTranslation.resetFailure() + viewModelScope.launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) { + episodeTranslation.translate( + contentId = season.contentId, + targetLanguage = target, + refetchPendingLanguage = { owner -> + refreshEpisodesForTranslation(detail.contentId, seasonNumber, owner, target) + }, + onTranslated = {}, ) + if (episodeTranslation.takeDeferredAuto()) translateShownSeasonEpisodes() } } @@ -2070,13 +2442,14 @@ class TvItemDetailViewModel( } +/** Watched and unwatched both reset resume progress, as the server does. */ private fun ItemDetail.withWatchedPlaybackState(watched: Boolean): ItemDetail { val current = userData ?: LeafItemUserData() return copy( userData = current.copy( played = watched, - isInProgress = if (watched) false else current.isInProgress, - positionSeconds = if (watched) null else current.positionSeconds, + isInProgress = false, + positionSeconds = null, ), ) } @@ -2100,6 +2473,7 @@ private fun ItemDetail.toSectionItem(): SectionItem = SectionItem( genres = genres, status = status, ratingImdb = ratingImdb, + ratingTmdb = ratingTmdb, contentRating = contentRating, overview = overview, posterUrl = posterUrl, @@ -2116,6 +2490,7 @@ private fun BrowseItem.toSectionItem(): SectionItem = SectionItem( genres = genres, status = status, ratingImdb = ratingImdb, + ratingTmdb = ratingTmdb, contentRating = contentRating, overlaySummary = overlaySummary, overview = overview, diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvMediaInfoDialog.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvMediaInfoDialog.kt index fe3894c65f..3cd286fe48 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvMediaInfoDialog.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvMediaInfoDialog.kt @@ -46,6 +46,7 @@ import org.prairieserver.prairie.playback.subtitleLabelIndicatesHearingImpaired import org.prairieserver.prairie.tv.ui.components.rememberTvDialogInitialFocus import java.util.Locale import kotlinx.coroutines.launch +import org.prairieserver.prairie.tv.ui.screens.player.TvPlayerChrome data class TvMediaInfoTrackSummary( val primary: String, @@ -111,8 +112,8 @@ fun TvMediaInfoDialog( .fillMaxWidth(0.36f) .fillMaxHeight(0.88f) .clip(RoundedCornerShape(topStart = 18.dp, bottomStart = 18.dp)) - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.96f)) - .border(0.6.dp, Color.White.copy(alpha = 0.14f), RoundedCornerShape(topStart = 18.dp, bottomStart = 18.dp)) + .background(TvPlayerChrome.Card) + .border(1.dp, TvPlayerChrome.PanelStroke, RoundedCornerShape(topStart = 18.dp, bottomStart = 18.dp)) .onPreviewKeyEvent { ev -> when { ev.type == KeyEventType.KeyUp && (ev.key == Key.Back || ev.key == Key.Escape) -> { diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvPlaybackFormatting.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvPlaybackFormatting.kt index 3c74a3064b..f72b222606 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvPlaybackFormatting.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvPlaybackFormatting.kt @@ -512,8 +512,8 @@ object TvPlaybackFormatting { /** * Inputs needed to preview what the player's subtitle auto-resolver would * land on, so the row can annotate "Auto" with the concrete track (or - * "None"). Mirrors the subset of [TvPlayerViewModel.resolveAutoSubtitleSelection]'s - * inputs the detail page can supply. Analogue of silo-apple's + * "None"). Mirrors the subset of `resolveTvAutoSubtitleIdentity`'s + * inputs the detail page can supply. Analogue of prairie-apple's * `DetailPlaybackFormatting.SubtitleAutoContext`. */ data class SubtitleAutoContext( @@ -532,7 +532,7 @@ object TvPlaybackFormatting { * Selector VALUE label for Subtitles. * * When [autoContext] is supplied, the Auto preview is resolved through the - * SAME rules the player runs at launch ([TvPlayerViewModel.resolveAutoSubtitleSelection], + * SAME rules the player runs at launch (`resolveTvAutoSubtitleIdentity`, * mirrored in [autoResolvedSubtitle]) — preferred-language / mode / * forced-subs — so the row shows exactly what will play, including * "Auto - None" when Auto resolves to no subtitles. It never consults the @@ -578,7 +578,7 @@ object TvPlaybackFormatting { * subtitle auto-resolver would pick for the no-override case, or `null` * when Auto resolves to no subtitles (mode off, "no subs" preference, no * language match, or audio already in the preferred language). Mirrors - * [TvPlayerViewModel.resolveAutoSubtitleSelection] branch-for-branch over + * `resolveTvAutoSubtitleIdentity` branch-for-branch over * the catalog [SubtitleTrack] list. A resolver `NoChange`/`Disable` maps to * `null` here: the detail page starts from nothing playing, so both mean * "no subtitle". diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvPlaybackSelectorRow.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvPlaybackSelectorRow.kt index 165c64e516..82dc8d6496 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvPlaybackSelectorRow.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvPlaybackSelectorRow.kt @@ -1,33 +1,13 @@ package org.prairieserver.prairie.tv.ui.screens.detail -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Chat import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material.icons.filled.Layers import androidx.compose.material.icons.filled.Movie -import androidx.compose.material.icons.filled.Tv 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.focus.FocusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp import org.prairieserver.prairie.model.catalog.PlaybackVariant import org.prairieserver.prairie.model.catalog.playbackEditions import org.prairieserver.prairie.model.catalog.hasEditionChoices @@ -265,292 +245,3 @@ internal fun TvPlaybackActionSelectors( triggerStyle = TvSelectorTriggerStyle.CircularAction, ) } - -@Composable -fun TvPlaybackSelectorRow( - versions: List<FileVersion>, - currentVersion: FileVersion?, - selectedVersionFileId: Int?, - selectedAudioTrackIndex: Int?, - selectedSubtitleTrackIndex: Int?, - automaticAudioTrackOrdinal: Int? = null, - automaticAudioResolutionKnown: Boolean = false, - // Cascaded subtitle prefs (profile-sourced) that let the Subtitles pill - // preview the concrete track Auto will resolve to — the same rules the - // player runs at launch. - preferredSubtitleLanguage: String?, - subtitleMode: String?, - showForcedSubtitles: Boolean, - onSelectVersion: (Int?) -> Unit, - onSelectAudioTrack: (Int?) -> Unit, - onSelectSubtitleTrack: (Int?) -> Unit, - modifier: Modifier = Modifier, - /** Give every visible segment an equal, fixed share of the supplied width. */ - stretchSegments: Boolean = false, -) { - // hasAnySelector — only show once an effective playable version is known. - if (currentVersion == null) return - - val editions = TvPlaybackFormatting.editions(versions) - val currentEdition = TvPlaybackFormatting.currentEdition(versions, currentVersion) - // Scope the version list to the current edition when editions are present - // (mirrors Apple's `scopedVersions`). - val scopedVersions = if (editions.size > 1 && currentEdition != null) { - currentEdition.versions - } else { - versions - } - val editionOptions = editions.map { edition -> - val count = edition.versions.size - TvSelectorOption( - key = "edition:${edition.id}", - title = edition.label, - detail = "$count version${if (count == 1) "" else "s"}", - selected = currentEdition?.id == edition.id, - onSelect = { onSelectVersion(edition.versions.firstOrNull()?.fileId) }, - ) - } - val versionOptions = buildList { - add( - TvSelectorOption( - key = "version:auto", - title = "Auto", - detail = "Best match for this device", - selected = selectedVersionFileId == null, - onSelect = { onSelectVersion(null) }, - ), - ) - scopedVersions.forEach { version -> - add( - TvSelectorOption( - key = "version:${version.fileId}", - title = TvPlaybackFormatting.versionShortLabel(version), - detail = TvPlaybackFormatting.versionDetailLabel(version), - selected = selectedVersionFileId == version.fileId, - onSelect = { onSelectVersion(version.fileId) }, - ), - ) - } - } - // Hoisted out of the buildList blocks below: these are the REAL choices, and - // their counts — not the assembled menu row counts, which carry Auto/Off — - // decide whether each pill is interactive. - val formattedAudioOptions = - TvPlaybackFormatting.audioOptions(currentVersion, selectedAudioTrackIndex) - val formattedSubtitleOptions = TvPlaybackFormatting.subtitleOptions( - currentVersion, - selectedSubtitleTrackIndex, - preferredLanguage = preferredSubtitleLanguage, - ) - val audioSelectorOptions = buildList { - add( - TvSelectorOption( - key = "audio:auto", - title = "Auto", - detail = "Use the file default track", - selected = isAudioSelectorOptionSelected(null, selectedAudioTrackIndex), - onSelect = { onSelectAudioTrack(null) }, - ), - ) - if (formattedAudioOptions.isEmpty()) { - add( - TvSelectorOption( - key = "audio:unknown", - title = "Unknown", - detail = "", - selected = false, - onSelect = {}, - enabled = false, - ), - ) - } else { - formattedAudioOptions.forEach { option -> - add( - TvSelectorOption( - key = "audio:${option.ordinal}", - title = option.title, - detail = option.detail, - selected = option.isSelected, - onSelect = { onSelectAudioTrack(option.ordinal) }, - ), - ) - } - } - } - val subtitleSelectorOptions = buildList { - add( - TvSelectorOption( - key = "subtitle:auto", - title = "Auto", - detail = "Use your subtitle preferences", - selected = selectedSubtitleTrackIndex == null, - onSelect = { onSelectSubtitleTrack(null) }, - ), - ) - add( - TvSelectorOption( - key = "subtitle:off", - title = "Off", - detail = "Start without subtitles", - selected = selectedSubtitleTrackIndex == -1, - onSelect = { onSelectSubtitleTrack(-1) }, - ), - ) - formattedSubtitleOptions.forEach { option -> - add( - TvSelectorOption( - key = "subtitle:${option.stableId}", - title = option.title, - detail = option.detail, - selected = option.isSelected, - onSelect = { onSelectSubtitleTrack(option.selectionIndex) }, - ), - ) - } - } - - val audioValue = if (selectedAudioTrackIndex == null && !automaticAudioResolutionKnown) { - "Auto" - } else { - TvPlaybackFormatting.audioValueLabel( - currentVersion, - selectedAudioTrackIndex, - automaticAudioTrackOrdinal, - ) - } - val subtitleValue = TvPlaybackFormatting.subtitleValueLabel( - currentVersion, - selectedSubtitleTrackIndex, - autoContext = if (selectedAudioTrackIndex != null || automaticAudioResolutionKnown) { - TvPlaybackFormatting.SubtitleAutoContext( - preferredLanguage = preferredSubtitleLanguage, - mode = subtitleMode, - showForced = showForcedSubtitles, - audioLanguage = TvPlaybackFormatting.resolvedAudioLanguage( - currentVersion, - selectedAudioTrackIndex, - automaticAudioTrackOrdinal, - ), - ) - } else { - null - }, - ) - var groupExpanded by remember { mutableStateOf(false) } - - // Series supplies the measured action-row width and stretches its segments - // across that stable footprint. Other detail pages retain the compact, - // leading capsule. In fixed mode the values do not expand on focus, so the - // selector never changes shape while the viewer moves through it. - PlaybackSelectorCapsule( - modifier = modifier - .fillMaxWidth() - .onFocusChanged { groupExpanded = it.hasFocus } - .focusGroup(), - stretchContent = stretchSegments, - ) { - val segmentModifier = if (stretchSegments) Modifier.weight(1f) else Modifier - if (editions.size > 1) { - TvAnchoredSelectorMenu( - modifier = segmentModifier, - icon = Icons.Filled.Layers, - label = "Edition", - value = currentEdition?.label ?: "Standard", - compactValue = currentEdition?.label ?: "Standard", - options = editionOptions, - interactive = selectorIsInteractive(editions.size), - triggerStyle = TvSelectorTriggerStyle.ConnectedSegment, - groupExpanded = groupExpanded, - connectedFillWidth = stretchSegments, - connectedExpandValue = !stretchSegments, - ) - SelectorDivider() - } - - TvAnchoredSelectorMenu( - modifier = segmentModifier, - icon = Icons.Filled.Tv, - label = "Version", - value = TvPlaybackFormatting.versionValueLabel(currentVersion, selectedVersionFileId), - compactValue = TvPlaybackFormatting.versionCompactLabel(currentVersion), - options = versionOptions, - interactive = selectorIsInteractive(scopedVersions.size), - triggerStyle = TvSelectorTriggerStyle.ConnectedSegment, - groupExpanded = groupExpanded, - connectedFillWidth = stretchSegments, - connectedExpandValue = !stretchSegments, - ) - - if (formattedAudioOptions.isNotEmpty()) { - SelectorDivider() - TvAnchoredSelectorMenu( - modifier = segmentModifier, - icon = Icons.AutoMirrored.Filled.VolumeUp, - label = "Audio", - value = audioValue, - compactValue = audioValue.removePrefix("Auto: "), - options = audioSelectorOptions, - interactive = selectorIsInteractive(formattedAudioOptions.size), - triggerStyle = TvSelectorTriggerStyle.ConnectedSegment, - groupExpanded = groupExpanded, - connectedFillWidth = stretchSegments, - connectedExpandValue = !stretchSegments, - ) - } - - if (formattedSubtitleOptions.isNotEmpty()) { - SelectorDivider() - TvAnchoredSelectorMenu( - modifier = segmentModifier, - icon = Icons.AutoMirrored.Filled.Chat, - label = "Subtitles", - value = "Subtitles $subtitleValue", - compactValue = compactSubtitleSelectorValue(subtitleValue), - options = subtitleSelectorOptions, - interactive = selectorIsInteractive(formattedSubtitleOptions.size), - triggerStyle = TvSelectorTriggerStyle.ConnectedSegment, - groupExpanded = groupExpanded, - connectedFillWidth = stretchSegments, - connectedExpandValue = !stretchSegments, - ) - } - } -} - -/** The one chrome shell shared by both the live selector and its loading state. */ -@Composable -private fun PlaybackSelectorCapsule( - modifier: Modifier = Modifier, - stretchContent: Boolean = false, - content: @Composable RowScope.() -> Unit, -) { - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - ) { - Row( - modifier = (if (stretchContent) Modifier.fillMaxWidth() else Modifier) - .height(25.dp) - .background(Color.Black.copy(alpha = 0.26f), CircleShape) - .border(0.75.dp, Color.White.copy(alpha = 0.42f), CircleShape) - .padding(1.dp), - verticalAlignment = Alignment.CenterVertically, - content = content, - ) - } -} - -@Composable -private fun SelectorDivider() { - androidx.compose.foundation.layout.Box( - modifier = Modifier - .width(0.5.dp) - .height(17.dp) - .background(Color.White.copy(alpha = 0.22f)), - ) -} - -internal fun compactSubtitleSelectorValue(value: String): String = value - .removePrefix("Auto: ") - .removePrefix("Auto - ") - .substringBefore(" · ") diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/home/TvHomeScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/home/TvHomeScreen.kt index e87faeccd7..b50dcc094e 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/home/TvHomeScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/home/TvHomeScreen.kt @@ -101,7 +101,10 @@ fun TvHomeScreen( androidx.compose.runtime.DisposableEffect(lifecycleOwner) { val observer = androidx.lifecycle.LifecycleEventObserver { _, event -> if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) { - if (shouldRefreshOnResume()) { + // The observer gets ON_RESUME as soon as it is added. On a new + // ViewModel that is while its own first load is still running, + // and a second full Home read would only supersede it. + if (shouldRefreshOnResume() && !viewModel.uiState.value.isLoading) { viewModel.refreshFromRealtime() } } @@ -145,6 +148,7 @@ fun TvHomeScreen( firstRowContainerFocusRequester = firstRowContainerFocusRequester, onContentUpFallbackChanged = onContentUpFallbackChanged, onSetWatched = viewModel::setWatched, + onMarqueeTranslated = viewModel::refreshFromRealtime, onToggleFavorite = viewModel::toggleFavorite, onToggleWatchlist = viewModel::toggleWatchlist, onDismissContinueWatching = viewModel::dismissContinueWatching, @@ -239,8 +243,10 @@ private fun TvHomeContent( onDismissContinueWatching: (String, String) -> Unit = { _, _ -> }, onDismissNextUp: (String, String) -> Unit = { _, _ -> }, sectionsFullyResolved: Boolean = true, + onMarqueeTranslated: () -> Unit = {}, ) { TvSkylineSectionFeed( + onMarqueeTranslated = onMarqueeTranslated, surfaceKey = "home", // tvOS Home drops the foreground marquee + row band by 56pt while // keeping the ambient art and top navigation fixed (28dp at TV scale). diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/libraries/TvLibrariesScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/libraries/TvLibrariesScreen.kt index d92432ff65..73a1340ab5 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/libraries/TvLibrariesScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/libraries/TvLibrariesScreen.kt @@ -2,6 +2,7 @@ package org.prairieserver.prairie.tv.ui.screens.libraries import androidx.compose.foundation.background import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.key @@ -11,7 +12,10 @@ import org.prairieserver.prairie.tv.ui.components.TvCatalogEmptyState import org.prairieserver.prairie.tv.ui.components.TvErrorScreen import org.prairieserver.prairie.tv.ui.components.TvLoadingScreen import org.prairieserver.prairie.tv.ui.screens.library.TvLibraryDetailScreen +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.common.ui.OnViewerAccessChanged +import org.prairieserver.prairie.network.AccessChangeSignals @Composable fun TvLibrariesScreen( @@ -26,11 +30,19 @@ fun TvLibrariesScreen( // route to the user-collection detail rather than the library one (#69). onUserCollectionClick: (collectionId: String, title: String) -> Unit, onInitialContentFocus: () -> Unit = {}, + /** Plays a shuffle the library page started, bound to that library. */ + onPlayShuffle: (shuffle: org.prairieserver.prairie.model.shuffle.Shuffle, libraryId: Int?) -> Unit, viewModel: TvLibrariesViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() + OnViewerAccessChanged(koinInject<AccessChangeSignals>(), viewModel.accessChanges) { viewModel.load() } val selectedLibrary = state.libraries.firstOrNull { it.id == state.selectedLibraryId } ?: state.libraries.firstOrNull() + // A library hidden or shown on another device, picked up on foreground. + val hiddenLibrariesRevision by viewModel.hiddenLibrariesRevision.collectAsState() + LaunchedEffect(viewModel, hiddenLibrariesRevision) { + viewModel.onHiddenLibrariesRevision(hiddenLibrariesRevision) + } when { state.isLoading && state.libraries.isEmpty() -> TvLoadingScreen( @@ -65,6 +77,7 @@ fun TvLibrariesScreen( } }, onInitialContentFocus = onInitialContentFocus, + onShuffleStarted = { shuffle -> onPlayShuffle(shuffle, selectedLibrary.id) }, ) } } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/libraries/TvLibrariesViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/libraries/TvLibrariesViewModel.kt index 2917d6895a..bf2d4d6dbd 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/libraries/TvLibrariesViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/libraries/TvLibrariesViewModel.kt @@ -3,14 +3,21 @@ package org.prairieserver.prairie.tv.ui.screens.libraries import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import org.prairieserver.prairie.model.personal.UserLibrary +import org.prairieserver.prairie.common.network.ServerReachabilityState +import org.prairieserver.prairie.common.network.ServerReachabilityStatus import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.repository.PersonalDataRepository import org.prairieserver.prairie.tv.data.preferences.LegacyTvPrefsMigration import org.prairieserver.prairie.tv.data.preferences.TvLibrarySelectionStore import org.prairieserver.prairie.tv.ui.util.visibleOnTv +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -23,7 +30,11 @@ class TvLibrariesViewModel( private val personalDataRepository: PersonalDataRepository, private val librarySelectionStore: TvLibrarySelectionStore, private val legacyTvPrefsMigration: LegacyTvPrefsMigration, + /** Server reachability; each reachable probe retries a failed load. */ + reachability: Flow<ServerReachabilityState> = emptyFlow(), ) : ViewModel() { + /** Access changes this ViewModel has applied, kept while its screen is away. */ + val accessChanges = org.prairieserver.prairie.network.AccessChangeCursor() data class UiState( val isLoading: Boolean = true, @@ -35,8 +46,41 @@ class TvLibrariesViewModel( private val _uiState = MutableStateFlow(UiState()) val uiState: StateFlow<UiState> = _uiState.asStateFlow() + /** Bumps when the profile hides or shows a library on another device. */ + val hiddenLibrariesRevision: StateFlow<Int> = personalDataRepository.hiddenLibrariesRevision + private var seenHiddenLibrariesRevision = hiddenLibrariesRevision.value + + // The latest load replaces any still running, so an older answer can't + // land last. Declared before init, which starts the first load. + private var loadJob: Job? = null + // The last load failed and none is running. A revision that asked for a + // re-load won't come again, so the next reachable probe retries it. + private var reloadPending = false + // Only the first load may fall back to the offline cache; later ones + // re-check, so a failure stays pending instead of showing a stale list. + private var firstLoadAttempted = false + init { load() + reachability + .onEach { if (reloadPending && it.status == ServerReachabilityStatus.Reachable) load() } + .launchIn(viewModelScope) + } + + fun onHiddenLibrariesRevision(revision: Int) { + if (revision == seenHiddenLibrariesRevision) return + seenHiddenLibrariesRevision = revision + // Drop newly hidden libraries now, so a failed re-load can't keep them. + _uiState.update { state -> + val libraries = personalDataRepository.withoutHidden(state.libraries) + state.copy( + libraries = libraries, + selectedLibraryId = state.selectedLibraryId + ?.takeIf { id -> libraries.any { it.id == id } } + ?: libraries.firstOrNull()?.id, + ) + } + load() } fun onLibrarySelected(libraryId: Int) { @@ -48,7 +92,9 @@ class TvLibrariesViewModel( } fun load() { - viewModelScope.launch { + loadJob?.cancel() + reloadPending = false + loadJob = viewModelScope.launch { _uiState.update { it.copy(isLoading = true, error = null) } // Seed the per-profile selection from the legacy global // `tv_prefs` key BEFORE the first read — the resolve below @@ -56,7 +102,13 @@ class TvLibrariesViewModel( // Sentinel-gated no-op after the first run. legacyTvPrefsMigration.migrateIfNeeded() val storedLibraryId = librarySelectionStore.getSelectedLibraryId() - when (val result = personalDataRepository.listUserLibraries()) { + val result = if (firstLoadAttempted) { + personalDataRepository.recheckUserLibraries(_uiState.value.libraries.mapTo(mutableSetOf()) { it.id }) + } else { + personalDataRepository.listUserLibraries() + } + firstLoadAttempted = true + when (result) { is ApiResult.Success -> { val libraries = result.data .visibleOnTv() @@ -77,17 +129,23 @@ class TvLibrariesViewModel( ) } } - is ApiResult.Error -> _uiState.update { - it.copy( - isLoading = false, - error = result.message.ifBlank { "Failed to load libraries" }, - ) + is ApiResult.Error -> { + reloadPending = true + _uiState.update { + it.copy( + isLoading = false, + error = result.message.ifBlank { "Failed to load libraries" }, + ) + } } - is ApiResult.NetworkError -> _uiState.update { - it.copy( - isLoading = false, - error = "Network error: ${result.exception.message ?: "unknown"}", - ) + is ApiResult.NetworkError -> { + reloadPending = true + _uiState.update { + it.copy( + isLoading = false, + error = "Network error: ${result.exception.message ?: "unknown"}", + ) + } } } } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryBrowseControls.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryBrowseControls.kt index f347743bb9..56f76aed69 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryBrowseControls.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryBrowseControls.kt @@ -22,11 +22,14 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.CheckBox +import androidx.compose.material.icons.filled.CheckBoxOutlineBlank import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.Shuffle import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.SwapVert import androidx.compose.material.icons.outlined.Cancel @@ -106,6 +109,8 @@ fun TvBrowseControlRow( * grid below has no card to give it to (an empty or fully-filtered list). */ sortPillFocusRequester: FocusRequester? = null, + /** Starts a shuffle of what the grid browses; null where nothing can shuffle. */ + onShuffle: (() -> Unit)? = null, ) { // Clearing removes the Clear pill from composition; focus must hop to the // Filter pill first or it would snap away to the nearest surviving scope. @@ -214,6 +219,28 @@ fun TvBrowseControlRow( ) } } + + if (onShuffle != null) TvShufflePill(onClick = onShuffle) + } +} + +/** A Shuffle action drawn like the browse control pills beside it. */ +@Composable +fun TvShufflePill(onClick: () -> Unit, modifier: Modifier = Modifier) { + BrowseControlPill(onClick = onClick, modifier = modifier) { foreground -> + Icon( + imageVector = Icons.Filled.Shuffle, + contentDescription = null, + tint = foreground, + modifier = Modifier.size(16.dp), + ) + Text( + text = "Shuffle", + style = MaterialTheme.typography.labelLarge.copy(fontSize = 14.sp, lineHeight = 18.sp), + color = foreground, + fontWeight = FontWeight.Medium, + maxLines = 1, + ) } } @@ -357,6 +384,12 @@ fun TvBrowseSortPanel( // Filter panel (list → values) // ============================================================================ +/** The library Browse "Preserve sort & filters" value and its change handler. */ +data class TvBrowsePreserveToggle( + val enabled: Boolean, + val onChange: (Boolean) -> Unit, +) + @Composable fun TvBrowseFilterPanel( libraryType: String, @@ -364,6 +397,8 @@ fun TvBrowseFilterPanel( initial: TvCatalogFacetSelection, onApply: (TvCatalogFacetSelection) -> Unit, onClose: () -> Unit, + /** Shows the "Preserve sort & filters" row; null hides it (non-library grids). */ + preserve: TvBrowsePreserveToggle? = null, ) { var draft by remember { mutableStateOf(initial) } // null = top-level facet list; non-null = that facet's value list. @@ -457,6 +492,7 @@ fun TvBrowseFilterPanel( firstRowFocusRequester = screenFocusRequester, onOpenFacet = { openFacet = it }, onDraftChanged = { draft = it }, + preserve = preserve, onDone = commitAndClose, ) else -> FacetValuesScreen( @@ -481,6 +517,7 @@ private fun FilterListScreen( firstRowFocusRequester: FocusRequester, onOpenFacet: (TvCatalogFacet) -> Unit, onDraftChanged: (TvCatalogFacetSelection) -> Unit, + preserve: TvBrowsePreserveToggle?, onDone: () -> Unit, ) { if (availableFacets.isEmpty()) { @@ -539,6 +576,25 @@ private fun FilterListScreen( text = "OPTIONS", modifier = Modifier.padding(start = 12.dp, top = 14.dp, bottom = 4.dp), ) + if (preserve != null) { + // The toggle takes effect at once rather than on Done (tvOS `preserveRow`). + BrowsePanelRow( + onClick = { preserve.onChange(!preserve.enabled) }, + ) { foreground -> + Icon( + imageVector = if (preserve.enabled) Icons.Filled.CheckBox else Icons.Filled.CheckBoxOutlineBlank, + contentDescription = null, + tint = foreground, + modifier = Modifier.size(15.dp), + ) + Text( + text = "Preserve sort & filters", + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp, lineHeight = 17.sp), + color = foreground, + maxLines = 1, + ) + } + } BrowsePanelRow( onClick = { if (draft.canReset) onDraftChanged(TvCatalogFacetSelection()) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryBrowseFacets.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryBrowseFacets.kt index 9282201203..e6693a5b4e 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryBrowseFacets.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryBrowseFacets.kt @@ -1,5 +1,7 @@ package org.prairieserver.prairie.tv.ui.screens.library +import org.prairieserver.prairie.catalog.filter.CatalogFacet +import org.prairieserver.prairie.catalog.filter.CatalogFilterState import org.prairieserver.prairie.model.catalog.CatalogFiltersResponse import org.prairieserver.prairie.model.catalog.CatalogQueryGroup import org.prairieserver.prairie.model.catalog.CatalogQueryRule @@ -272,3 +274,47 @@ data class TvCatalogFacetSelection( private fun Set<String>.toggle(value: String): Set<String> = if (value in this) this - value else this + value } + +/** + * The persisted half of a Browse filter — sort, order, facets, and match — in + * the shared [CatalogFilterState] shape `BrowsePrefsStore` saves on phone and + * Apple. The A–Z prefix, the legacy genre chip, and preset query groups are + * per-visit and never saved (tvOS strips `namePrefix` the same way). + */ +internal fun TvLibraryBrowseFilter.toSavedState(): CatalogFilterState = CatalogFilterState( + selections = TvCatalogFacet.entries.associate { facet -> + CatalogFacet.valueOf(facet.name) to facetSelection.selectedValues(facet) + .map { if (facet == TvCatalogFacet.WatchStatus) it.toSharedWatchStatus() else it } + .toSet() + }.filterValues { it.isNotEmpty() }, + matchAll = facetSelection.matchAll, + sort = sort, + order = order, +) + +/** + * Rebuilds the Browse filter from a saved state. Unknown watch-status and + * dynamic-range values are dropped; other facet values are restored as saved. + */ +internal fun CatalogFilterState.toTvBrowseFilter(): TvLibraryBrowseFilter { + var selection = TvCatalogFacetSelection(matchAll = matchAll) + for ((facet, values) in selections) { + val tvFacet = TvCatalogFacet.entries.firstOrNull { it.name == facet.name } ?: continue + for (value in values) { + val tvValue = if (tvFacet == TvCatalogFacet.WatchStatus) value.toTvWatchStatus() else value + if (tvValue != null && !selection.isSelected(tvFacet, tvValue)) { + selection = selection.toggled(tvFacet, tvValue) + } + } + } + return TvLibraryBrowseFilter(sort = sort, order = order, facetSelection = selection) +} + +// The shared model spells In Progress `inProgress` (Apple's raw value); the TV +// panel uses the `in_progress` wire name. Every other status matches. +private fun String.toSharedWatchStatus(): String = + if (this == TvWatchStatusFilter.InProgress.wireValue) "inProgress" else this + +private fun String.toTvWatchStatus(): String? = + if (this == "inProgress") TvWatchStatusFilter.InProgress.wireValue + else TvWatchStatusFilter.entries.firstOrNull { it.wireValue == this }?.wireValue diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryCollectionDetailScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryCollectionDetailScreen.kt index 4650d425f9..44d08a105b 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryCollectionDetailScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryCollectionDetailScreen.kt @@ -35,6 +35,22 @@ import org.prairieserver.prairie.tv.ui.theme.tvPresetGridColumns import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf +/** + * A mixed library opens personal collections here too, so the shuffle scope + * follows the collection's source: a personal collection id means nothing to + * the library-collection namespace. A Movies- or Series-scoped view offers no + * shuffle, since a shuffle request carries no media type or library and would + * draw from the whole collection. + */ +internal fun collectionShuffleKind( + collectionSource: String, + mediaScope: String?, +): org.prairieserver.prairie.model.shuffle.ShuffleScopeKind? = when { + mediaScope != null -> null + collectionSource == "user_collection" -> org.prairieserver.prairie.model.shuffle.ShuffleScopeKind.USER_COLLECTION + else -> org.prairieserver.prairie.model.shuffle.ShuffleScopeKind.LIBRARY_COLLECTION +} + /** Which overlay panel is open over the collection grid (mirrors Browse). */ private enum class TvCollectionPanel { Sort, Filter } @@ -44,14 +60,34 @@ fun TvLibraryCollectionDetailScreen( collectionId: String, title: String, libraryType: String, + collectionSource: String = "library_collection", + mediaScope: String? = null, onItemClick: (contentId: String) -> Unit, onBack: () -> Unit, + // Plays the first pick of a shuffle started from the header. + onShuffleStarted: (org.prairieserver.prairie.model.shuffle.Shuffle) -> Unit = {}, viewModel: TvLibraryCollectionDetailViewModel = koinViewModel( - key = "library-collection-$libraryId-$collectionId", - parameters = { parametersOf(libraryId, collectionId, title) }, + key = "library-collection-$libraryId-$collectionId-$libraryType-$collectionSource-${mediaScope.orEmpty()}", + parameters = { parametersOf(libraryId, collectionId, title, mediaScope, collectionSource) }, ), ) { val state by viewModel.uiState.collectAsState() + val shuffleLauncher = org.prairieserver.prairie.common.ui.rememberShuffleLauncher( + org.koin.compose.koinInject(), + onShuffleStarted, + ) + val shuffleKind = collectionShuffleKind(collectionSource, mediaScope) + val onShuffle = if ( + shuffleKind != null && + state.items.isNotEmpty() && + shuffleLauncher.supports(shuffleKind) + ) { + { + shuffleLauncher.start(shuffleKind, collectionId) + } + } else { + null + } BackHandler(onBack = onBack) @@ -125,6 +161,7 @@ fun TvLibraryCollectionDetailScreen( onSort = { openPanel = TvCollectionPanel.Sort }, onFilter = { openPanel = TvCollectionPanel.Filter }, onClearFilters = viewModel::clearFilters, + onShuffle = onShuffle, ) }, emptyState = { @@ -174,6 +211,7 @@ private fun CollectionHeader( onSort: () -> Unit, onFilter: () -> Unit, onClearFilters: () -> Unit, + onShuffle: (() -> Unit)? = null, ) { Column(modifier = Modifier.fillMaxWidth()) { Text( @@ -195,6 +233,7 @@ private fun CollectionHeader( onSort = onSort, onFilter = onFilter, onClearFilters = onClearFilters, + onShuffle = onShuffle, ) Spacer(modifier = Modifier.weight(1f)) itemCountLabel(state)?.let { label -> diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryCollectionDetailViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryCollectionDetailViewModel.kt index 1c6724ab9e..eeba21c73d 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryCollectionDetailViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryCollectionDetailViewModel.kt @@ -23,6 +23,8 @@ class TvLibraryCollectionDetailViewModel( private val libraryId: Int, private val collectionId: String, val title: String, + private val mediaScope: String? = null, + private val collectionSource: String = "library_collection", ) : ViewModel() { data class UiState( @@ -99,7 +101,7 @@ class TvLibraryCollectionDetailViewModel( viewModelScope.launch { val result = catalogRepository.getFilters( includeTechnical = true, - source = "library_collection", + source = collectionSource, collectionId = collectionId, ) if (result is ApiResult.Success) { @@ -177,6 +179,9 @@ class TvLibraryCollectionDetailViewModel( collectionId, continuation = cursor, limit = PAGE_SIZE, + mediaType = mediaScope, + libraryId = libraryId.takeIf { mediaScope != null }, + source = collectionSource, sort = state.sort.ifBlank { null }, order = state.order, queryGroups = facetGroups, diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryDetailScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryDetailScreen.kt index d6615d4c31..57e5287873 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryDetailScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryDetailScreen.kt @@ -57,6 +57,7 @@ import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved import org.prairieserver.prairie.tv.ui.focus.TvObservedFocusResult import org.prairieserver.prairie.tv.ui.focus.TvContentInitialFocusMaxAttempts import androidx.tv.material3.Card +import androidx.tv.material3.Button import androidx.tv.material3.CardDefaults import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Icon @@ -84,8 +85,11 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.offset import androidx.compose.ui.unit.sp +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf +import org.prairieserver.prairie.common.ui.OnViewerAccessChanged +import org.prairieserver.prairie.network.AccessChangeSignals /** * Android TV library detail surface, with tvOS as the master. @@ -101,6 +105,7 @@ fun TvLibraryDetailScreen( libraryId: Int, libraryTitle: String, libraryType: String, + mediaScope: String? = null, onItemClick: (contentId: String) -> Unit, onCollectionClick: (collectionId: String, title: String, isUserCollection: Boolean) -> Unit, onInitialContentFocus: () -> Unit = {}, @@ -114,12 +119,31 @@ fun TvLibraryDetailScreen( // again rather than being keyed on the section value alone. sectionRequestNonce: Int = 0, onContentUpFallbackChanged: ((((Boolean) -> Boolean)?) -> Unit)? = null, + // Plays the first pick of a shuffle started from the Library tab. + onShuffleStarted: (org.prairieserver.prairie.model.shuffle.Shuffle) -> Unit = {}, viewModel: TvLibraryDetailViewModel = koinViewModel( - key = "library-$libraryId", - parameters = { parametersOf(libraryId, libraryTitle, libraryType) }, + key = "library-$libraryId-${mediaScope.orEmpty()}", + parameters = { parametersOf(libraryId, libraryTitle, libraryType, mediaScope) }, ), ) { val state by viewModel.uiState.collectAsState() + val shuffleLauncher = org.prairieserver.prairie.common.ui.rememberShuffleLauncher( + org.koin.compose.koinInject(), + onShuffleStarted, + ) + // Movie, TV, and mixed libraries shuffle when the server offers it. A + // Movies- or Series-scoped view of a mixed library does not: a shuffle + // request carries no media type, so it would draw from both. + val onShuffleLibrary = if ( + mediaScope == null && + org.prairieserver.prairie.model.shuffle.isShuffleLibraryType(libraryType) && + shuffleLauncher.supports(org.prairieserver.prairie.model.shuffle.ShuffleScopeKind.LIBRARY) + ) { + { shuffleLauncher.start(org.prairieserver.prairie.model.shuffle.ShuffleScopeKind.LIBRARY, libraryId.toString()) } + } else { + null + } + OnViewerAccessChanged(koinInject<AccessChangeSignals>(), viewModel.accessChanges) { viewModel.refreshAfterAccessChange() } // Apply the committed cascade section on entry / whenever the commit // changes it. Keyed on sectionRequestNonce (bumped on every commit) AND the @@ -138,7 +162,7 @@ fun TvLibraryDetailScreen( ) { when (state.selectedTab) { TvLibraryTab.Recommended -> RecommendedTab( - surfaceKey = "library-$libraryId", + surfaceKey = "library-$libraryId-${mediaScope.orEmpty()}", state = state, onItemClick = onItemClick, onRetry = viewModel::retryRecommended, @@ -160,7 +184,9 @@ fun TvLibraryDetailScreen( showBrowseControls = true, onSortKeySelected = viewModel::onSortKeySelected, onFacetSelectionApplied = viewModel::onFacetSelectionApplied, + onPreserveFiltersChanged = viewModel::onPreserveFiltersChanged, onContentUpFallbackChanged = onContentUpFallbackChanged, + onShuffle = onShuffleLibrary, ) TvLibraryTab.Genres -> LibraryTab( state = state, @@ -252,6 +278,7 @@ fun TvLibraryDetailScreen( // Tab content // ============================================================================ +@OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun RecommendedTab( /** Distinguishes this feed's saveable slots from other surfaces'. */ @@ -291,14 +318,28 @@ private fun RecommendedTab( ) } else -> { - TvSkylineSectionFeed( - surfaceKey = surfaceKey, - sections = rows, - onItemClick = onItemClick, - focusRequest = focusRequest, - onInitialContentFocus = onInitialContentFocus, - onContentUpFallbackChanged = onContentUpFallbackChanged, - ) + Box { + TvSkylineSectionFeed( + surfaceKey = surfaceKey, + sections = rows, + onItemClick = onItemClick, + focusRequest = focusRequest, + onInitialContentFocus = onInitialContentFocus, + onContentUpFallbackChanged = onContentUpFallbackChanged, + ) + state.recommendedError?.let { message -> + Row( + modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = Spacing.safeArea, vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(message, modifier = Modifier.weight(1f)) + Button(onClick = onRetry) { Text("Retry") } + } + } + } } } } @@ -329,9 +370,11 @@ private fun LibraryTab( showBrowseControls: Boolean = false, onSortKeySelected: (TvLibrarySortOption) -> Unit = {}, onFacetSelectionApplied: (TvCatalogFacetSelection) -> Unit = {}, + onPreserveFiltersChanged: ((Boolean) -> Unit)? = null, /** Shell hook for overriding D-pad Up while the A–Z rail holds focus. */ onContentUpFallbackChanged: ((((Boolean) -> Boolean)?) -> Unit)? = null, onClearAudiobookGroup: (() -> Unit)? = null, + onShuffle: (() -> Unit)? = null, ) { val restoredGridItemFocusRequester = remember { FocusRequester() } val gridState = rememberLazyGridState() @@ -361,9 +404,16 @@ private fun LibraryTab( ) if (state.browseError != null) { + // The error replaces the Sort/Filter row, and Retry resends the same + // query. A saved filter the server rejects would otherwise keep + // Browse failing across restarts, so offer to clear it here; that + // also overwrites the saved state. + val canClearFilters = showBrowseControls && state.browseFilter.facetSelection.canReset TvErrorScreen( message = state.browseError, onRetry = onRetry, + secondaryActionLabel = "Clear filters".takeIf { canClearFilters }, + onSecondaryAction = { onFacetSelectionApplied(TvCatalogFacetSelection()) }.takeIf { canClearFilters }, modifier = Modifier.padding( start = Spacing.safeArea, top = TvTopMenuLayout.contentTopInset, @@ -407,6 +457,7 @@ private fun LibraryTab( onOpenSortPanel = { openPanel = TvBrowsePanel.Sort }, onOpenFilterPanel = { openPanel = TvBrowsePanel.Filter }, onClearFilters = { onFacetSelectionApplied(TvCatalogFacetSelection()) }, + onShuffle = onShuffle, ) } // The A–Z jump rail only makes sense for title-sorted browsing (the @@ -439,6 +490,7 @@ private fun LibraryTab( initial = state.browseFilter.facetSelection, onApply = onFacetSelectionApplied, onClose = { openPanel = null }, + preserve = onPreserveFiltersChanged?.let { TvBrowsePreserveToggle(state.preserveFilters, it) }, ) null -> Unit } @@ -466,6 +518,7 @@ private fun LibraryGrid( onOpenSortPanel: () -> Unit = {}, onOpenFilterPanel: () -> Unit = {}, onClearFilters: () -> Unit = {}, + onShuffle: (() -> Unit)? = null, ) { var attachedRestoreItemId by remember { mutableStateOf<String?>(null) } val nearEnd by remember( @@ -560,6 +613,7 @@ private fun LibraryGrid( onFilter = onOpenFilterPanel, onClearFilters = onClearFilters, modifier = Modifier.onFocusChanged { controlsFocused = it.hasFocus }, + onShuffle = onShuffle, ) } } @@ -591,7 +645,13 @@ private fun LibraryGrid( } } else if (state.browseItems.isEmpty()) { item(span = { GridItemSpan(maxLineSpan) }, key = "empty") { - TvCatalogEmptyState(message = "No titles match the current filters.") + TvCatalogEmptyState( + message = if (state.browseLibraryHasItems == false) { + "This library is empty." + } else { + "No titles match the current filters." + }, + ) } } else { itemsIndexed( diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryDetailViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryDetailViewModel.kt index ee52c14a02..f0685d56e9 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryDetailViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryDetailViewModel.kt @@ -2,6 +2,7 @@ package org.prairieserver.prairie.tv.ui.screens.library import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import org.prairieserver.prairie.common.settings.BrowsePrefsStore import org.prairieserver.prairie.model.catalog.AudiobookGroup import org.prairieserver.prairie.model.catalog.BrowseItem import org.prairieserver.prairie.model.catalog.CatalogQueryGroup @@ -15,14 +16,19 @@ import org.prairieserver.prairie.repository.CatalogRepository import org.prairieserver.prairie.repository.SectionRepository import org.prairieserver.prairie.tv.ui.util.tvCatalogMediaTypeFor import org.prairieserver.prairie.tv.ui.util.visibleOnTv +import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import kotlinx.coroutines.isActive +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit /** * Library content sections committed by the Skyline cascade. The extra browse @@ -80,9 +86,8 @@ enum class TvLibrarySortOption(val label: String, val wireValue: String) { ListOrder("Recently Saved", "__list_order"), Title("Title", "title"), DateAdded("Date Added", "added_at"), - // Server expects "year" for release-date sort (matches phone); the old - // "release_date" value was unsupported. - ReleaseDate("Year", "year"), + ReleaseDate("Release Date", "release_date"), + Year("Year", "year"), Rating("Rating", "rating_imdb"), Runtime("Runtime", "runtime"), Resolution("Resolution", "resolution"), @@ -101,7 +106,7 @@ enum class TvLibrarySortOption(val label: String, val wireValue: String) { // No direction to report — the order is whatever the source defines. CollectionOrder, ListOrder -> "Default" Title, Author, Narrator, SeriesName -> if (order == "asc") "A–Z" else "Z–A" - ReleaseDate, DateAdded -> if (order == "asc") "Oldest" else "Newest" + ReleaseDate, Year, DateAdded -> if (order == "asc") "Oldest" else "Newest" Runtime -> if (order == "asc") "Shortest" else "Longest" Rating, Resolution -> if (order == "asc") "Lowest" else "Highest" } @@ -115,7 +120,7 @@ enum class TvLibrarySortOption(val label: String, val wireValue: String) { if (org.prairieserver.prairie.model.navigation.isAudiobookLikeLibraryType(libraryType)) { listOf(Title, Author, Narrator, SeriesName, DateAdded, Runtime) } else { - listOf(Title, DateAdded, ReleaseDate, Rating, Runtime, Resolution) + listOf(Title, DateAdded, ReleaseDate, Year, Rating, Runtime, Resolution) } /** @@ -135,7 +140,7 @@ enum class TvLibrarySortOption(val label: String, val wireValue: String) { * `added_at` by for these sources. */ fun availableForPersonalList(): List<TvLibrarySortOption> = - listOf(ListOrder, Title, DateAdded, ReleaseDate, Rating, Runtime) + listOf(ListOrder, Title, DateAdded, Year, Rating, Runtime) } } @@ -159,7 +164,12 @@ class TvLibraryDetailViewModel( private val libraryId: Int, private val libraryTitle: String, private val libraryType: String, + private val mediaScope: String? = null, + private val browsePrefs: BrowsePrefsStore? = null, + catalogLanguage: org.prairieserver.prairie.domain.settings.CatalogLanguageRevision? = null, ) : ViewModel() { + /** Access changes this ViewModel has applied, kept while its screen is away. */ + val accessChanges = org.prairieserver.prairie.network.AccessChangeCursor() data class UiState( val title: String, @@ -174,6 +184,8 @@ class TvLibraryDetailViewModel( val filtersLoading: Boolean = true, val browseItems: List<BrowseItem> = emptyList(), val browseHasMore: Boolean = false, + /** For an empty grid: false when the library itself is empty, true when filters hid everything, null when unknown (#451). */ + val browseLibraryHasItems: Boolean? = null, val browseLoading: Boolean = false, val browseLoadingMore: Boolean = false, val browseError: String? = null, @@ -188,12 +200,15 @@ class TvLibraryDetailViewModel( val collectionSections: List<TvCollectionSection> = emptyList(), val collectionsLoading: Boolean = false, val collectionsError: String? = null, + /** "Preserve sort & filters" for this library's Browse grid (default ON). */ + val preserveFilters: Boolean = true, ) private val _uiState = MutableStateFlow( UiState( title = libraryTitle, libraryType = libraryType, + preserveFilters = browsePrefs?.preserveEnabled(libraryId, mediaScope) ?: true, ), ) val uiState: StateFlow<UiState> = _uiState.asStateFlow() @@ -216,6 +231,13 @@ class TvLibraryDetailViewModel( private var browseRawLoaded = 0 private var loadedAudiobookGroupBy: String? = null private var audiobookGroupsGeneration = 0 + // Declared before init, which starts the first Recommended load: an + // initializer below it would reset this generation and drop that load's + // job afterward, so a reload could neither cancel nor outrank it. + private var recommendedGeneration = 0L + private var filtersJob: Job? = null + private var collectionsJob: Job? = null + private var recommendedJob: Job? = null init { // Only the default Recommended tab loads eagerly. Filters (the genre @@ -223,13 +245,18 @@ class TvLibraryDetailViewModel( // `/catalog/filters` call is slow and is wasted work for the (common) // case where the user never leaves Recommended. loadRecommended() + // Titles and descriptions are localized per metadata language. + catalogLanguage?.let { language -> + viewModelScope.launch { + language.revision.drop(1).collect { + refreshAfterLanguageChange() + } + } + } } fun onTabSelected(tab: TvLibraryTab) { val state = _uiState.value - val nextFilter = state.browseFilter.forTab(tab) - val filterChanged = nextFilter != state.browseFilter - val audiobookGroupBy = tab.audiobookGroupBy // Re-selecting the section that is already active is a no-op. The // screen re-issues the committed section every time it re-enters // composition — backing out of item detail / the player returns to a @@ -238,6 +265,9 @@ class TvLibraryDetailViewModel( // and facets back to the tab's defaults (Title A–Z). Only a genuine // tab CHANGE applies the new tab's defaults. if (state.selectedTab == tab) return + val nextFilter = state.browseFilter.forTab(tab) + val filterChanged = nextFilter != state.browseFilter + val audiobookGroupBy = tab.audiobookGroupBy _uiState.update { it.copy( selectedTab = tab, @@ -372,6 +402,11 @@ class TvLibraryDetailViewModel( browseFilter = it.browseFilter.forTab(it.selectedTab), ) } + // The group list went stale while a group was open (an access change). + val groupBy = _uiState.value.selectedTab.audiobookGroupBy + if (groupBy != null && loadedAudiobookGroupBy != groupBy) { + loadAudiobookGroups(groupBy = groupBy, reset = true) + } } fun retryRecommended() { @@ -391,20 +426,110 @@ class TvLibraryDetailViewModel( loadAudiobookGroups(groupBy = groupBy, reset = true) } + private fun refreshAfterLanguageChange() { + recommendedJob?.cancel() + filtersJob?.cancel() + collectionsJob?.cancel() + ++recommendedGeneration + ++browseGeneration + ++audiobookGroupsGeneration + browseContinuation = null + audiobookContinuation = null + _uiState.update { + it.copy( + sections = emptyList(), + browseItems = emptyList(), + collections = emptyList(), + collectionSections = emptyList(), + genres = emptyList(), + facetOptions = null, + recommendedLoading = false, + browseLoading = false, + browseLoadingMore = false, + audiobookGroups = emptyList(), + audiobookGroupsLoading = false, + audiobookGroupsLoadingMore = false, + filtersLoading = false, + collectionsLoading = false, + ) + } + refreshAfterAccessChange() + } + + /** + * The server reported an access change: titles, sections, facets, and + * collections in this library may differ under the new policy. Reload the + * visible tab now and let the others reload when next opened, instead of + * trusting their one-time loaded flags. + */ + fun refreshAfterAccessChange() { + loadedRecommended = false + loadedBrowse = false + loadedCollections = false + loadedFilters = false + loadedAudiobookGroupBy = null + val state = _uiState.value + when (state.selectedTab) { + TvLibraryTab.Recommended -> loadRecommended() + TvLibraryTab.Browse, + TvLibraryTab.Genres, + TvLibraryTab.Alphabet, + TvLibraryTab.RecentlyAdded -> { + loadFilters() + loadBrowse(reset = true) + } + TvLibraryTab.Authors, + TvLibraryTab.Series -> { + val groupBy = state.selectedTab.audiobookGroupBy ?: return + // A selected author or series shows its titles; keep the + // selection and reload those. The group list reloads when the + // selection is cleared (onAudiobookGroupCleared). + if (state.selectedAudiobookGroup != null) { + loadBrowse(reset = true) + } else { + loadAudiobookGroups(groupBy = groupBy, reset = true) + } + } + TvLibraryTab.Collections -> loadCollections() + } + } + + /** + * Turning preserve off clears the saved state; turning it on saves the + * current Browse sort and filters at once (tvOS `setPreserveEnabled`). + */ + fun onPreserveFiltersChanged(enabled: Boolean) { + browsePrefs?.setPreserveEnabled(libraryId, enabled, mediaScope) + _uiState.update { it.copy(preserveFilters = enabled) } + if (enabled) saveBrowseFilter(_uiState.value) + } + private fun updateBrowseFilter(filter: TvLibraryBrowseFilter) { - if (_uiState.value.browseFilter == filter) return + val previous = _uiState.value.browseFilter + if (previous == filter) return _uiState.update { it.copy(browseFilter = filter) } + if (previous.sort != filter.sort || previous.order != filter.order || previous.facetSelection != filter.facetSelection) { + saveBrowseFilter(_uiState.value) + } if (_uiState.value.selectedTab == TvLibraryTab.Browse || loadedBrowse) { loadBrowse(reset = true) } } - private var recommendedGeneration = 0L + // Only the Browse grid's own sort and filters persist; the other grid + // sections are fixed presets. + private fun saveBrowseFilter(state: UiState) { + if (state.selectedTab != TvLibraryTab.Browse) return + browsePrefs?.saveState(libraryId, state.browseFilter.toSavedState(), mediaScope) + } private fun loadRecommended() { val run = ++recommendedGeneration loadedRecommended = true - viewModelScope.launch { + // A retry supersedes the running load; cancel it so its refill + // cursor chains stop instead of paging until the final publish check. + recommendedJob?.cancel() + recommendedJob = viewModelScope.launch { _uiState.update { it.copy(recommendedLoading = true, recommendedError = null) } val owner = sectionRepository.captureLibrarySectionAuthority() @@ -480,12 +605,26 @@ class TvLibraryDetailViewModel( sections.map { section -> resolvedById[section.id] ?: section } } + // Independent shelves can overlap, but keep each cursor chain sequential. + val refillPermits = Semaphore(3) + val scoped = resolved.filterNot { it.featured }.map { section -> + async { + refillPermits.withPermit { + scopeTvLibrarySection(section, mediaScope) { cursor -> + // Each page request also rechecks the auth identity. + kotlinx.coroutines.currentCoroutineContext().ensureActive() + sectionRepository.getLibrarySectionCatalogItems(libraryId, section.id, owner, cursor) + } + } + } + }.awaitAll() if (!mayPublish()) return@launch _uiState.update { it.copy( - sections = resolved.visibleOnTv(), + sections = scoped.map { it.section }.visibleOnTv(), recommendedLoading = false, - recommendedError = null, + recommendedError = if (scoped.any { it.incomplete }) + "Some shelves could not be fully loaded for this media type. Retry or open Browse." else null, ) } } @@ -493,12 +632,17 @@ class TvLibraryDetailViewModel( private fun loadFilters() { loadedFilters = true - viewModelScope.launch { + // A reload after an access change replaces an unfinished load, so the + // older answer cannot land after it. + filtersJob?.cancel() + filtersJob = viewModelScope.launch { _uiState.update { it.copy(filtersLoading = true) } // include_technical adds the resolution / audio-language / // subtitle-language vocabularies the filter panel offers (tvOS // FacetLoader always requests them). - when (val filters = catalogRepository.getFilters(libraryId, includeTechnical = true)) { + val filters = catalogRepository.getFilters(libraryId, includeTechnical = true) + ensureActive() + when (filters) { is ApiResult.Success -> _uiState.update { it.copy( genres = filters.data.genres.sorted(), @@ -546,9 +690,10 @@ class TvLibraryDetailViewModel( } val facetGroups = filter.facetSelection.toQueryGroups() + val browseMediaType = mediaScope ?: mediaTypeFor(libraryType) val result = catalogRepository.browse( source = "query", - mediaType = mediaTypeFor(libraryType), + mediaType = browseMediaType, libraryId = libraryId, genre = filter.genre, sort = filter.sort, @@ -571,6 +716,21 @@ class TvLibraryDetailViewModel( when (result) { is ApiResult.Success -> { val response = result.data + val visibleItems = response.items.visibleOnTv() + // An empty first page only says this view matched nothing (#451): + // with nothing narrowing it, the page was the whole library; + // otherwise one unfiltered item decides, within the Movies/Series + // scope of a mixed library as Apple does. Loading stays up + // meanwhile, so the wrong message never flashes. + val libraryHasItems = when { + !reset || visibleItems.isNotEmpty() -> null + // Titles the TV does not show (isTvHiddenMediaType) still + // mean the library is not empty. + response.items.isNotEmpty() -> true + filter == TvLibraryBrowseFilter(sort = filter.sort, order = filter.order) -> false + else -> catalogRepository.libraryHasItems(libraryId, browseMediaType) + } + if (generation != browseGeneration) return@launch browseContinuation = response.continuation if (browseSnapshot == null) { browseSnapshot = response.snapshot @@ -581,7 +741,6 @@ class TvLibraryDetailViewModel( browseRawLoaded + response.items.size } _uiState.update { - val visibleItems = response.items.visibleOnTv() it.copy( // distinctBy contentId is a belt-and-suspenders guard // against duplicate keys crashing LazyVerticalGrid if a @@ -592,6 +751,7 @@ class TvLibraryDetailViewModel( (it.browseItems + visibleItems).distinctBy { item -> item.contentId } }, browseHasMore = response.hasMore, + browseLibraryHasItems = if (reset) libraryHasItems else it.browseLibraryHasItems, browseLoading = false, browseLoadingMore = false, browseError = null, @@ -702,9 +862,13 @@ class TvLibraryDetailViewModel( private fun loadCollections() { loadedCollections = true - viewModelScope.launch { + // As in loadFilters: the newest load is the only one that publishes. + collectionsJob?.cancel() + collectionsJob = viewModelScope.launch { _uiState.update { it.copy(collectionsLoading = true, collectionsError = null) } - when (val result = sectionRepository.getLibraryCollectionsGrouped(libraryId)) { + val result = sectionRepository.getLibraryCollectionsGrouped(libraryId) + ensureActive() + when (result) { is ApiResult.Success -> { val sections = buildCollectionSections(result.data) _uiState.update { @@ -782,8 +946,9 @@ class TvLibraryDetailViewModel( when (tab) { TvLibraryTab.Recommended, TvLibraryTab.Collections -> this - // Browse lands on the tvOS default view: Title A–Z, no facets. - TvLibraryTab.Browse -> copy( + // Browse restores the saved sort and filters, else lands on the + // tvOS default view: Title A–Z, no facets. + TvLibraryTab.Browse -> browsePrefs?.savedState(libraryId, mediaScope)?.toTvBrowseFilter() ?: copy( genre = null, namePrefix = null, sort = TvLibrarySortOption.Title.wireValue, @@ -859,6 +1024,7 @@ internal val TvLibrarySortOption.defaultOrder: String TvLibrarySortOption.SeriesName -> "asc" TvLibrarySortOption.DateAdded, TvLibrarySortOption.ReleaseDate, + TvLibrarySortOption.Year, TvLibrarySortOption.Rating, TvLibrarySortOption.Runtime, TvLibrarySortOption.Resolution -> "desc" diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryMediaScope.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryMediaScope.kt new file mode 100644 index 0000000000..53a9a5d74b --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryMediaScope.kt @@ -0,0 +1,13 @@ +package org.prairieserver.prairie.tv.ui.screens.library + +import org.prairieserver.prairie.catalog.scopeLibrarySection +import org.prairieserver.prairie.model.catalog.CatalogResponse +import org.prairieserver.prairie.model.section.ResolvedSection +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.apiv2.CatalogContinuationV2 + +internal suspend fun scopeTvLibrarySection( + section: ResolvedSection, + mediaScope: String?, + loadPage: suspend (CatalogContinuationV2?) -> ApiResult<CatalogResponse>, +) = scopeLibrarySection(section, mediaScope, loadPage) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/people/TvPersonDetailScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/people/TvPersonDetailScreen.kt index 6a6b032458..616a5f9ee8 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/people/TvPersonDetailScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/people/TvPersonDetailScreen.kt @@ -73,8 +73,10 @@ import org.prairieserver.prairie.model.catalog.Person import org.prairieserver.prairie.model.catalog.personMetadataBadges import org.prairieserver.prairie.tv.ui.components.TvCatalogEmptyState import org.prairieserver.prairie.tv.ui.components.TvCatalogGrid +import org.prairieserver.prairie.tv.ui.components.TvDialogDefaults import org.prairieserver.prairie.tv.ui.components.TvErrorScreen import org.prairieserver.prairie.tv.ui.components.TvLoadingScreen +import org.prairieserver.prairie.tv.ui.components.tvDialogSurface import org.prairieserver.prairie.tv.ui.theme.DarkSurfaceElevated import org.prairieserver.prairie.tv.ui.theme.Spacing import org.prairieserver.prairie.tv.ui.theme.tvPresetGridColumns @@ -468,16 +470,14 @@ private fun TvPersonBioDialog( Box( modifier = Modifier .fillMaxSize() - .background(Color.Black.copy(alpha = 0.62f)), + .background(TvDialogDefaults.Scrim), contentAlignment = Alignment.Center, ) { Box( modifier = Modifier .fillMaxWidth(0.56f) .fillMaxHeight(0.76f) - .clip(RoundedCornerShape(18.dp)) - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.98f)) - .border(0.6.dp, Color.White.copy(alpha = 0.14f), RoundedCornerShape(18.dp)) + .tvDialogSurface() .onPreviewKeyEvent { ev -> when { ev.type == KeyEventType.KeyUp && diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvAiTranslateDialog.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvAiTranslateDialog.kt index ab5062a06f..c5eec32271 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvAiTranslateDialog.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvAiTranslateDialog.kt @@ -77,10 +77,7 @@ fun TvAiTranslateDialog( mutableIntStateOf(audioSources.indexOfFirst { it.isSelected }.coerceAtLeast(0)) } var targetPos by remember { - mutableIntStateOf( - TvSubtitleLanguageOptions.indexOf(defaultTargetLanguage.take(2).lowercase()) - .takeIf { it >= 0 } ?: 0, - ) + mutableIntStateOf(tvSubtitleLanguageIndex(defaultTargetLanguage)) } val firstRowFocus = remember { FocusRequester() } val initialNonce = remember { aiState.completedNonce } @@ -345,9 +342,7 @@ fun TvAiTranslateDialog( val src = audioSources[ audioSourcePos.coerceIn(0, audioSources.lastIndex), ] - val sameLanguage = src.language - ?.take(2) - ?.equals(targetLanguage.take(2), ignoreCase = true) == true + val sameLanguage = tvIsSameLanguage(src.language, targetLanguage) onSubmit( if (sameLanguage) "transcribe" else "transcribe_translate", src.index, diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvIntroAutoSkipBanner.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvIntroAutoSkipBanner.kt index 1104059828..3d77c444ae 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvIntroAutoSkipBanner.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvIntroAutoSkipBanner.kt @@ -232,3 +232,8 @@ private fun TvIntroPromptPill( } } } + +@Composable +fun TvMarkerSkipPill(label: String, onSelect: () -> Unit) { + TvIntroPromptPill(label = label, progress = 0f, onSelect = onSelect, autoFocus = false) +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvOnDeckItems.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvOnDeckItems.kt new file mode 100644 index 0000000000..3c8c0d3510 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvOnDeckItems.kt @@ -0,0 +1,65 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import org.prairieserver.prairie.model.section.ResolvedSection +import org.prairieserver.prairie.model.settings.EpisodeSpoilers +import org.prairieserver.prairie.tv.ui.util.visibleOnTv + +/** One On Deck card on the Up Next overlay (tvOS `nextUpCarouselItems` parity). */ +data class TvOnDeckItem( + val contentId: String, + /** The section item's type, so a pick opens the right player (audiobooks too). */ + val type: String, + val title: String, + val subtitle: String?, + val artUrl: String?, + val artThumbhash: String?, + val progressFraction: Float?, + /** An episode the profile has not started; spoiler protection may hide [artUrl]. */ + val unwatchedEpisode: Boolean = false, + /** Whether [artUrl] is the episode's own still (null when the server doesn't say). */ + val artIsEpisodeStill: Boolean? = null, +) + +/** + * The phone's On Deck projection: home continue-watching pools minus the + * current item and its series, deduped, capped at 12, and only items with + * 16:9 art. Items TV hides (see visibleOnTv) are dropped first, as on TV Home. + */ +internal fun List<ResolvedSection>.toTvOnDeckItems(contentId: String, seriesId: String?): List<TvOnDeckItem> { + return this + .visibleOnTv() + .filter { it.sectionType in setOf("continue_watching", "in_progress", "next_up") } + .flatMap { it.items } + .filter { item -> + item.contentId != contentId && + (seriesId == null || item.seriesId != seriesId) + } + .filter { !it.backdropUrl.isNullOrBlank() } + .distinctBy { it.contentId } + .take(12) + .map { item -> + val progress = item.positionSeconds?.let { pos -> + item.durationSeconds?.takeIf { it > 0 }?.let { dur -> + (pos / dur).toFloat().coerceIn(0f, 1f) + } + } + TvOnDeckItem( + contentId = item.contentId, + type = item.type, + title = item.seriesTitle ?: item.title, + subtitle = when { + item.seasonNumber != null && item.episodeNumber != null -> + "S${item.seasonNumber}·E${item.episodeNumber}" + + (item.title.takeIf { it.isNotBlank() }?.let { " — $it" } ?: "") + item.year > 0 -> item.year.toString() + else -> null + }, + artUrl = item.backdropUrl, + artThumbhash = item.backdropThumbhash, + progressFraction = progress, + unwatchedEpisode = item.type.equals("episode", ignoreCase = true) && + EpisodeSpoilers.isUnwatched(item), + artIsEpisodeStill = item.backdropIsEpisodeStill, + ) + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlaybackRealtimeController.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlaybackRealtimeController.kt index f3addb6707..660151db63 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlaybackRealtimeController.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlaybackRealtimeController.kt @@ -5,6 +5,8 @@ import org.prairieserver.prairie.network.PlaybackRealtimeEvent import org.prairieserver.prairie.playback.PlaybackAction import org.prairieserver.prairie.playback.decodeMarkersUpdate import org.prairieserver.prairie.playback.decodePlaybackSubtitleReady +import org.prairieserver.prairie.playback.decodePlaybackSubtitleSyncUpdated +import org.prairieserver.prairie.playback.decodePlaybackSubtitleTimingChanged import org.prairieserver.prairie.playback.decidePlaybackAction import org.prairieserver.prairie.playback.isTransport import kotlinx.coroutines.CancellationException @@ -27,6 +29,13 @@ class TvPlaybackRealtimeController( private val client: PlaybackRealtimeClient, private val viewModel: TvPlayerViewModel, private val scope: CoroutineScope, + /** + * Set while the player belongs to a Watch Party. Transport commands then + * go through the room's permission decision instead of the player, and + * Stop/terminate end only this device's engagement. Returns whether the + * command was accepted. + */ + private val roomTransport: ((PlaybackAction) -> Boolean)? = null, ) { private companion object { const val BACKOFF_START_MS = 2_000L @@ -42,7 +51,10 @@ class TvPlaybackRealtimeController( try { client.connect(sessionId).collect { event -> when (event) { - is PlaybackRealtimeEvent.Opened -> client.sendHello(sessionId) + is PlaybackRealtimeEvent.Opened -> { + client.sendHello(sessionId) + viewModel.reconcileMarkers(sessionId) + } is PlaybackRealtimeEvent.Command -> handleCommand(event) is PlaybackRealtimeEvent.ServerEvent -> handleServerEvent(event) is PlaybackRealtimeEvent.Closed -> { /* fall through to reconnect */ } @@ -62,9 +74,22 @@ class TvPlaybackRealtimeController( private suspend fun handleCommand(cmd: PlaybackRealtimeEvent.Command) { client.sendAck(sessionId, cmd.commandId) val action = decidePlaybackAction(cmd) - // Watch Together is authoritative for transport — reject (don't apply) - // transport commands while in a room so an admin can't desync members. - val gated = action.isTransport && viewModel.remoteTransportSuppressed + val room = roomTransport + if (room != null && action.isTransport) { + // The room is authoritative for transport: an admin command is one + // more input, decided by this membership's permissions. + if (action is PlaybackAction.Stop) { + // Result first: leaving tears the screen down. + client.sendResult(sessionId, cmd.commandId, STATUS_COMPLETED) + room(action) + } else { + val accepted = room(action) + client.sendResult(sessionId, cmd.commandId, if (accepted) STATUS_COMPLETED else STATUS_REJECTED) + } + return + } + // A party player with no room route yet must not let transport through. + val gated = action.isTransport && viewModel.inWatchParty val status = if (action is PlaybackAction.Reject || gated) STATUS_REJECTED else STATUS_COMPLETED // Result BEFORE applying — a Stop tears the screen down (cancelling this // coroutine), so applying first could drop the result. @@ -89,10 +114,11 @@ class TvPlaybackRealtimeController( private suspend fun handleServerEvent(event: PlaybackRealtimeEvent.ServerEvent) { when (event.name) { "subtitle_ready" -> viewModel.applySubtitleReady(decodePlaybackSubtitleReady(event)) - "markers_updated" -> { - val markers = decodeMarkersUpdate(event) - viewModel.applyUpdatedMarkers(markers.intro, markers.credits, markers.recap, markers.preview) - } + "subtitle_timing_changed" -> + viewModel.applySubtitleTimingChanged(decodePlaybackSubtitleTimingChanged(event)) + "subtitle_sync_updated" -> + viewModel.applySubtitleSyncUpdated(decodePlaybackSubtitleSyncUpdated(event)) + "markers_updated" -> viewModel.applyMarkersUpdate(decodeMarkersUpdate(event)) // chapter_thumbnail_ready: no scrubber-thumbnail UI yet → nothing to update. else -> { /* ignore */ } } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerChrome.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerChrome.kt new file mode 100644 index 0000000000..ab838653ff --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerChrome.kt @@ -0,0 +1,86 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import org.prairieserver.prairie.tv.ui.theme.InterFamily + +/** + * The TV player's visual language, shared with the phone player: smoked + * panels over the picture, Paper for whatever has focus, Inter with tabular + * figures for anything that counts time. Focus is an inversion to Paper with + * Ink content, never a scale, as on tvOS. + */ +internal object TvPlayerChrome { + val Paper = Color(0xFFEDEDED) + val Ink = Color(0xFF0B0B0C) + val Graphite = Paper.copy(alpha = 0.60f) + val Faint = Paper.copy(alpha = 0.38f) + val InkMuted = Ink.copy(alpha = 0.58f) + + /** Panels and menus over the picture. Opaque enough that nothing reads through. */ + val Smoke = Color(0xFF131417).copy(alpha = 0.955f) + + /** Dialog and anchored-menu cards: fully opaque. */ + val Card = Color(0xFF17181B) + val PanelStroke = Color.White.copy(alpha = 0.10f) + + /** Transport buttons at rest: a dark disc and a ring, so they read on letterbox bars. */ + val ButtonRest = Color.Black.copy(alpha = 0.38f) + val ButtonRing = Color.White.copy(alpha = 0.24f) + + /** A selected row that does not have focus. */ + val Selected = Color.White.copy(alpha = 0.075f) + val Chip = Color.White.copy(alpha = 0.10f) + + /** Destructive: bright red text at rest, a red fill on focus. */ + val Stop = Color(0xFFFF453A) + val StopText = Color(0xFFFF6961) + val StopRest = Stop.copy(alpha = 0.12f) + + val SideInset = 48.dp + val BottomInset = 26.dp +} + +/** + * Text that is read meets the TV app's 14sp floor. The two exceptions are + * chrome at tvOS half scale, approved in TvTypographyReadabilityTest: the + * uppercase eyebrow above titles and sections, and the SDH/FORCED chips. + */ +internal object TvPlayerType { + private val base = TextStyle(fontFamily = InterFamily, color = TvPlayerChrome.Paper) + + val Eyebrow = base.copy( + fontSize = 11.sp, + lineHeight = 14.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.14.em, + color = TvPlayerChrome.Graphite, + ) + val Title = base.copy(fontSize = 27.sp, lineHeight = 33.sp, fontWeight = FontWeight.Bold, letterSpacing = (-0.018).em) + val Time = base.copy(fontSize = 14.sp, lineHeight = 18.sp, fontWeight = FontWeight.SemiBold, fontFeatureSettings = "tnum") + val ButtonLabel = base.copy(fontSize = 14.sp, lineHeight = 18.sp, fontWeight = FontWeight.SemiBold) + val ButtonValue = ButtonLabel.copy(fontWeight = FontWeight.Medium) + val Tab = base.copy(fontSize = 14.sp, lineHeight = 18.sp, fontWeight = FontWeight.SemiBold) + val Row = base.copy(fontSize = 15.sp, lineHeight = 20.sp, fontWeight = FontWeight.Medium) + val RowDetail = base.copy(fontSize = 14.sp, lineHeight = 18.sp, fontWeight = FontWeight.Medium, color = TvPlayerChrome.Graphite) + /** A row's value. Not tabular: Inter's tnum also widens hyphens ("X - Large"). */ + val Value = RowDetail + + /** Columns of times and counts that must line up, such as chapter starts. */ + val Figures = RowDetail.copy(fontFeatureSettings = "tnum") + val Chip = base.copy( + fontSize = 9.sp, + lineHeight = 11.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.07.em, + color = TvPlayerChrome.Paper.copy(alpha = 0.78f), + ) + val DialogTitle = base.copy(fontSize = 21.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold, letterSpacing = (-0.01).em) + val ConfirmTitle = base.copy(fontSize = 24.sp, lineHeight = 30.sp, fontWeight = FontWeight.Bold, letterSpacing = (-0.015).em) + val Body = base.copy(fontSize = 15.sp, lineHeight = 22.sp, color = TvPlayerChrome.Graphite) + val Button = base.copy(fontSize = 15.sp, lineHeight = 19.sp, fontWeight = FontWeight.SemiBold) +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerHud.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerHud.kt index 3aee54971a..bb6be83501 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerHud.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerHud.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.ScrollableState import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState @@ -19,6 +20,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -32,6 +34,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.rememberScrollState @@ -39,10 +42,14 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.rounded.AutoAwesome +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.ChevronRight +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.Search import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue @@ -51,89 +58,109 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment -import androidx.compose.ui.res.stringResource import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.shadow import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.toRect +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathOperation import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.graphics.TileMode import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Icon import androidx.tv.material3.Text import kotlinx.coroutines.launch import org.prairieserver.prairie.common.player.PlayerStatsSnapshot import org.prairieserver.prairie.common.player.healthRows import org.prairieserver.prairie.playback.stats.displayLine import org.prairieserver.prairie.playback.stats.planRows -import org.prairieserver.prairie.domain.player.IntroSkipMode -import org.prairieserver.prairie.tv.R import org.prairieserver.prairie.common.player.SleepTimerState +import org.prairieserver.prairie.domain.player.IntroSkipMode import org.prairieserver.prairie.model.catalog.VersionChapter import org.prairieserver.prairie.model.playback.PlaybackExecutionPlan import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo +import org.prairieserver.prairie.model.playback.SubtitleIdentity import org.prairieserver.prairie.model.settings.SubtitleAppearance import org.prairieserver.prairie.model.settings.SubtitleBackgroundStylePreset +import org.prairieserver.prairie.playback.SubtitleTimingActions +import org.prairieserver.prairie.tv.R +import org.prairieserver.prairie.tv.ui.components.tvDialogSurface import org.prairieserver.prairie.tv.ui.focus.TvContentInitialFocusMaxAttempts import org.prairieserver.prairie.tv.ui.focus.TvFocusLog import org.prairieserver.prairie.tv.ui.focus.TvFrameRelocationMaxAttempts import org.prairieserver.prairie.tv.ui.focus.rememberTvContentInitialFocus import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved -import org.prairieserver.prairie.tv.ui.theme.DarkSurfaceElevated - -// Geometry follows tvOS TVPlayerInfoHUD at the 0.5x point→dp map, adjusted for -// Android's larger body type: the card is WIDE and SHORT (tvOS 1100×380pt on a -// 1920×1080 canvas — 57% × 35%), with the tab rail floating above it on the -// video rather than inside it. The previous card was 51% × 67% as rendered — a -// portrait slab on a landscape screen, sitting on faces. -private val HudWidthFraction = 0.74f -private val HudMaxWidth = 720.dp + +// Geometry follows the options mockup on a 960×540dp screen: a capsule tab +// rail 24dp from the top, and a smoked panel 10dp under it, 780dp (81%) wide +// with a 22dp radius. The panel stays wide and short, as tvOS TVPlayerInfoHUD +// does, so it sits above faces rather than on them. +private val HudWidthFraction = 0.8125f +private val HudMaxWidth = 780.dp /** - * Card height wraps the pane between these bounds. A fixed 360dp height left - * Audio (two rows) and Stats (nine) as the same 60%-empty slab; wrapping lets - * a two-row pane be a two-row card. The max keeps long panes scrolling inside - * the card rather than growing it down over the transport. + * Card height wraps the pane between these bounds. A fixed height left Audio + * (two rows) and Stats (nine) as the same mostly-empty slab; wrapping lets a + * two-row pane be a two-row card. The max keeps long panes scrolling inside + * the card rather than growing it down over the picture. */ private val HudCardMinHeight = 156.dp -private val HudCardMaxHeight = 300.dp -private val HudPanelCorner = 16.dp -private val HudPanelPadding = 20.dp -private val HudTabCardGap = 12.dp -private val HudTabHeight = 38.dp -private val HudPaneBottomPadding = 4.dp -private val HudPaneColumnGap = 36.dp -private val HudTitleTextSize = 21.sp -private val HudTitleLineHeight = 25.sp +private val HudCardMaxHeight = 336.dp +private val HudPanelCorner = 22.dp +private val HudPanelPadding = PaddingValues(start = 22.dp, end = 22.dp, top = 20.dp, bottom = 18.dp) +private val HudRailTop = 24.dp +private val HudTabCardGap = 10.dp +private val HudTabHeight = 32.dp +private val HudRowHeight = 34.dp +private val HudRowCorner = 10.dp +private val HudRowPadding = 10.dp +private val HudCheckColumn = 20.dp +private val HudPaneColumnGap = 26.dp +private val HudSectionGap = 12.dp private val HudBodyTextSize = 16.sp private val HudBodyLineHeight = 20.sp -private val HudMetaTextSize = 15.sp -private val HudMetaLineHeight = 19.sp -private val HudChipTextSize = 14.sp -private val HudChipLineHeight = 18.sp -private val HudTabTextSize = 16.sp -private val HudTabLineHeight = 20.sp + +/** The left column of a two-column pane takes 330 of 736dp, as in the mockup. */ +private const val HudLeftColumnWeight = 33f +private const val HudRightColumnWeight = 38f /** * Lets a [HudFocusedSettingRow] register its own focus requester as the row that @@ -211,9 +238,14 @@ internal fun TvPlayerHud( onSubtitleDelayChanged: (Int) -> Unit, subtitleAppearance: SubtitleAppearance, onSubtitleAppearanceChanged: (SubtitleAppearance) -> Unit, + /** False when the server is known to discard subtitle text opacity. */ + subtitleTextOpacitySupported: Boolean = true, onSubtitlesPaneShown: () -> Unit, onSearchSubtitles: (() -> Unit)?, onTranslateWithAi: (() -> Unit)?, + subtitleSync: TvHudSubtitleSync = TvHudSubtitleSync(), + onSyncSubtitle: (String) -> Unit = {}, + onResetSubtitleTiming: (String) -> Unit = {}, hdrEnabled: Boolean, onHdrEnabledChanged: (Boolean) -> Unit, dolbyVisionEnabled: Boolean, @@ -224,6 +256,12 @@ internal fun TvPlayerHud( onSelectChapter: (Int) -> Unit, onDismiss: () -> Unit, initialTab: HudTab = HudTab.Info, + /** + * False in a Watch Party: the room plays one exact file at 1x, so the + * version picker and the speed row are hidden. Quality stays (it is the + * same file's ladder). + */ + versionAndSpeedControlsVisible: Boolean = true, modifier: Modifier = Modifier, ) { val tabs = visibleHudTabs( @@ -321,49 +359,57 @@ internal fun TvPlayerHud( } } - // Keep an open subtitle picker synchronized with reducer state while - // retaining the same stable focused row through Applying -> committed. - LaunchedEffect(subtitlePresentation, activePicker?.title) { - val current = activePicker - if (current?.title == "Subtitle Track") { - val checkedRow = subtitlePresentation.rows.firstOrNull { it.checked } - val focusedRow = subtitlePresentation.rows.firstOrNull { it.focused } - activePicker = current.copy( - options = subtitlePresentation.rows.map { row -> - HudPickerOption( - id = row.stableId, - label = if (row.applying) "${row.label} · Applying…" else row.label, - ) - }, - selectedId = checkedRow?.stableId - ?: subtitlePresentation.rows.firstOrNull()?.stableId.orEmpty(), - focusedId = focusedRow?.stableId - ?: current.focusedId, - ) - } + // The Subtitles pane's right column drills into Appearance and Timing in + // place. Owned here so Back can step out of a page before it closes the + // HUD, and reset when the viewer moves to another tab. + var subtitlePage by remember { mutableStateOf(HudSubtitlePage.Root) } + LaunchedEffect(selectedTab) { + if (selectedTab != HudTab.Subtitles) subtitlePage = HudSubtitlePage.Root } - val presentPicker: (HudPickerPresentation) -> Unit = { activePicker = it } + // Every picker names the tab it came from above its title. + val presentPicker: (HudPickerPresentation) -> Unit = { picker -> + activePicker = if (picker.eyebrow == null) picker.copy(eyebrow = selectedTab.label) else picker + } val closePicker: () -> Unit = { activePicker = null } // Android 16 no longer dispatches KEYCODE_BACK to target-36 apps. Register - // the picker as the most specific callback; when it is closed, the player - // screen's callback remains responsible for dismissing the HUD itself. + // the picker and the subtitle page as the most specific callbacks; when + // both are closed, the player screen's callback dismisses the HUD itself. + BackHandler(enabled = activePicker == null && subtitlePage != HudSubtitlePage.Root) { + subtitlePage = HudSubtitlePage.Root + } BackHandler(enabled = activePicker != null) { closePicker() } - // Top-center: a floating tab rail over the video, and a card beneath it - // holding only the pane — the TVPlayerInfoHUD composition. No full-screen - // scrim; the picture stays visible. - // - // fillMaxWidth BEFORE widthIn. Chained the other way round, fillMaxWidth - // sees the already-capped max and takes its fraction of THAT: 0.72 × 680 = - // 490dp, which is what actually rendered — narrow enough to clip the tab - // rail ("Chap…") and cramp every two-column pane. + val pickerOpen = activePicker != null + val chromeAlpha by animateFloatAsState( + targetValue = if (pickerOpen) 0.3f else 1f, + animationSpec = tween(160), + label = "hudChromeAlpha", + ) + + // Full screen, so the scrim and a picker can use the whole picture. The + // scrim is light while the panel is up (the picture stays watchable and + // the rail reads over bright frames) and deepens behind a picker. Box( modifier = modifier + .fillMaxSize() + .drawBehind { + if (pickerOpen) { + drawRect(Color.Black.copy(alpha = 0.55f)) + } else { + drawRect(Color.Black.copy(alpha = 0.10f)) + drawRect( + Brush.verticalGradient( + 0f to Color.Black.copy(alpha = 0.60f), + 1f to Color.Black.copy(alpha = 0.30f), + endY = 300.dp.toPx(), + tileMode = TileMode.Clamp, + ), + ) + } + } .onFocusChanged { hudHasFocus = it.hasFocus } - .fillMaxWidth(HudWidthFraction) - .widthIn(max = HudMaxWidth) .onPreviewKeyEvent { ev -> if (ev.key != Key.Back && ev.key != Key.Escape) return@onPreviewKeyEvent false TvFocusLog.d { "hud key BACK type=${ev.type} picker=${activePicker != null}" } @@ -381,11 +427,13 @@ internal fun TvPlayerHud( KeyEventType.KeyUp -> { // Pre-Android-16 remote and keyboard fallback. System // Back uses the callbacks above and on TvPlayerScreen. - if (activePicker != null) { - activePicker = null - } else { - TvFocusLog.d { "hud key BACK -> onDismiss" } - onDismiss() + when { + activePicker != null -> activePicker = null + subtitlePage != HudSubtitlePage.Root -> subtitlePage = HudSubtitlePage.Root + else -> { + TvFocusLog.d { "hud key BACK -> onDismiss" } + onDismiss() + } } true } @@ -394,19 +442,29 @@ internal fun TvPlayerHud( }, ) { CompositionLocalProvider(LocalHudPickerReturnFocus provides registerPickerReturnFocus) { + // fillMaxWidth BEFORE widthIn. Chained the other way round, fillMaxWidth + // sees the already-capped max and takes its fraction of THAT, which + // clipped the tab rail and cramped every two-column pane. Column( modifier = Modifier - .fillMaxWidth() - .graphicsLayer { alpha = if (activePicker != null) 0.28f else 1f }, + .align(Alignment.TopCenter) + .padding(top = HudRailTop) + .fillMaxWidth(HudWidthFraction) + .widthIn(max = HudMaxWidth) + .graphicsLayer { alpha = chromeAlpha }, verticalArrangement = Arrangement.spacedBy(HudTabCardGap), horizontalAlignment = Alignment.CenterHorizontally, ) { - // Floating tab rail. Centred like the tvOS HStack; the scroll is a - // safety net for very long localised labels — at this width the six - // English tabs fit with room. + // Floating capsule rail. The scroll is a safety net for very long + // localised labels; the six English tabs fit with room. Row( - modifier = Modifier.horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .clip(CircleShape) + .background(HudRailFill) + .border(1.dp, TvPlayerChrome.PanelStroke, CircleShape) + .padding(4.dp) + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(2.dp), ) { tabs.forEach { tab -> HudTabPill( @@ -426,6 +484,8 @@ internal fun TvPlayerHud( // The card: wraps its pane between the height bounds, so a // two-row Audio pane is a two-row card and a nine-row Stats pane // scrolls inside a full one. Shadow sits outside the clip. + val panelWindow = remember { HudPanelWindow() } + val panelShape = RoundedCornerShape(HudPanelCorner) Box( modifier = Modifier .fillMaxWidth() @@ -437,9 +497,8 @@ internal fun TvPlayerHud( // groups being entered/left, so both redirects apply to // every row regardless of which control was "nearest". .focusProperties { - // Down from a pill lands on the pane's FIRST row — the - // top-left control — not whichever swatch or row happens - // to sit under that pill. + // Down from a pill lands on the pane's entry row, not + // whichever swatch or row happens to sit under that pill. enter = { direction -> if (direction == FocusDirection.Down && paneEntryAvailable) { paneEntryFocus @@ -461,41 +520,51 @@ internal fun TvPlayerHud( } .focusGroup() .shadow( - elevation = 14.dp, - shape = RoundedCornerShape(HudPanelCorner), + elevation = 24.dp, + shape = panelShape, ambientColor = Color.Black.copy(alpha = 0.6f), spotColor = Color.Black.copy(alpha = 0.6f), ) - .clip(RoundedCornerShape(HudPanelCorner)) - // Near-opaque. The picture showing through the card read as - // "glass" but cost legibility over bright or busy frames — - // and this is a settings surface people squint at from the - // sofa. Keep the video visible AROUND the card, not through it. - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.96f)) - .border( - width = 0.5.dp, - color = Color.White.copy(alpha = 0.14f), - shape = RoundedCornerShape(HudPanelCorner), - ) + .clip(panelShape) + .onPlaced { panelWindow.panel = it } + // Smoked at 95.5%: the picture shows AROUND the card, not + // through it, because this is a settings surface people + // read from the sofa. The one exception is the subtitle + // sample, which cuts a window so it sits on the real frame. + .drawBehind { + val corner = CornerRadius(HudPanelCorner.toPx()) + val outline = Path().apply { addRoundRect(RoundRect(size.toRect(), corner)) } + val window = panelWindow.bounds + if (window == null) { + drawPath(outline, TvPlayerChrome.Smoke) + } else { + val hole = Path().apply { + addRoundRect(RoundRect(window, CornerRadius(HudPreviewCorner.toPx()))) + } + drawPath(Path.combine(PathOperation.Difference, outline, hole), TvPlayerChrome.Smoke) + } + } + .border(width = 1.dp, color = TvPlayerChrome.PanelStroke, shape = panelShape) .padding(HudPanelPadding), ) { + CompositionLocalProvider(LocalHudPanelWindow provides panelWindow) { when (selectedTab) { - HudTab.Info -> HudPaneViewport { - HudInfoPane( - title = title, - positionSec = positionSec, - durationSec = durationSec, - seasonNumber = seasonNumber, - episodeNumber = episodeNumber, - stats = stats, - playbackPlan = playbackPlan, - subtitleLabel = subtitlePresentation.rows - .firstOrNull { row -> row.checked } - ?.label - ?: "Off", - chapters = chapters, - ) - } + // Info scrolls per column, so it must not sit inside the + // single-column viewport: nested vertical scrolls throw. + HudTab.Info -> HudInfoPane( + title = title, + positionSec = positionSec, + durationSec = durationSec, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + stats = stats, + playbackPlan = playbackPlan, + subtitleLabel = subtitlePresentation.rows + .firstOrNull { row -> row.checked } + ?.let { row -> tvSubtitleRowParts(row.label).title } + ?: "Off", + chapters = chapters, + ) HudTab.Stats -> HudPaneViewport { HudStatsPane( stats = stats, @@ -514,7 +583,7 @@ internal fun TvPlayerHud( HudTab.Video -> HudVideoPane( videoQualities = videoQualities, onSelectVideoQuality = onSelectVideoQuality, - fileVersions = fileVersions, + fileVersions = if (versionAndSpeedControlsVisible) fileVersions else emptyList(), selectedFileId = selectedFileId, onSelectFileVersion = onSelectFileVersion, hdrEnabled = hdrEnabled, @@ -526,6 +595,7 @@ internal fun TvPlayerHud( onFillModeChanged = onVideoFillModeChanged, playbackSpeed = playbackSpeed, onPlaybackSpeedChanged = onPlaybackSpeedChanged, + speedRowVisible = versionAndSpeedControlsVisible, sleepTimerState = sleepTimerState, onStartSleepTimer = onStartSleepTimer, onCancelSleepTimer = onCancelSleepTimer, @@ -562,37 +632,45 @@ internal fun TvPlayerHud( ) HudTab.Subtitles -> HudSubtitlesPane( presentation = subtitlePresentation, + page = subtitlePage, + onPageChange = { subtitlePage = it }, subtitleDelayMs = subtitleDelayMs, subtitleDelayEnabled = subtitleDelayEnabled, onSubtitleDelayChanged = onSubtitleDelayChanged, appearance = subtitleAppearance, onAppearanceChanged = onSubtitleAppearanceChanged, + showTextOpacity = subtitleTextOpacitySupported, onPaneShown = onSubtitlesPaneShown, onSearchSubtitles = onSearchSubtitles, onTranslateWithAi = onTranslateWithAi, + timing = subtitleSync.timing, + syncStatus = subtitleSync::statusFor, + onSyncSubtitle = onSyncSubtitle, + onResetTiming = onResetSubtitleTiming, entryFocusRequester = paneEntryFocus, enabled = activePicker == null, onPresentPicker = presentPicker, ) - HudTab.Chapters -> HudPaneViewport { - HudChaptersPane( - chapters = chapters, - onSelectChapter = onSelectChapter, - entryFocusRequester = paneEntryFocus, - ) - } + HudTab.Chapters -> HudChaptersPane( + chapters = chapters, + positionSec = positionSec, + durationSec = durationSec, + onSelectChapter = onSelectChapter, + entryFocusRequester = paneEntryFocus, + ) + } } } } } - // Centered modal picker dialog, drawn on top of the dimmed rail + card. - // matchParentSize, not fillMaxSize: the HUD box now wraps its content, - // so a fillMaxSize child would see an unbounded height and not stretch. + // Centered modal picker, drawn over the dimmed rail and card. val picker = activePicker if (picker != null) { Box( - modifier = Modifier.matchParentSize(), + modifier = Modifier + .fillMaxSize() + .padding(vertical = 24.dp), contentAlignment = Alignment.Center, ) { HudPickerDialog( @@ -604,6 +682,35 @@ internal fun TvPlayerHud( } } +/** Where the Subtitles pane's right column is: its root, or a drill-in page. */ +internal enum class HudSubtitlePage { Root, Appearance, Timing } + +/** The rail floats on the picture, so it carries its own smoked ground. */ +private val HudRailFill = TvPlayerChrome.Smoke.copy(alpha = 0.78f) +private val HudPreviewCorner = 12.dp + +/** + * The window the subtitle sample cuts in the panel, in panel coordinates. + * The panel draws its fill around it, so the sample sits on the live picture. + */ +private class HudPanelWindow { + var panel: LayoutCoordinates? = null + var bounds by mutableStateOf<Rect?>(null) + + fun report(source: LayoutCoordinates?) { + val panelCoordinates = panel + bounds = if (source == null || panelCoordinates == null || !source.isAttached || !panelCoordinates.isAttached) { + null + } else { + // Clipped to every scroll viewport in between, so a sample that + // scrolls partly away never opens a hole outside its column. + panelCoordinates.localBoundingBoxOf(source, clipBounds = true).takeIf { !it.isEmpty } + } + } +} + +private val LocalHudPanelWindow = staticCompositionLocalOf<HudPanelWindow?> { null } + enum class HudTab(val label: String) { Info("Info"), Stats("Stats for nerds"), @@ -652,52 +759,33 @@ private fun HudTabPill( if (isFocused) onFocused() } - // The rail floats on the video, so an idle pill needs its own ground: - // tvOS HUDTabPillBody — black@0.45 fill with a white@0.18 hairline idle; - // solid white when selected; white@0.9 when merely focused. A white@0.06 - // fill (the old idle) vanishes over a bright frame. - // - // Selection follows focus, so a focused pill is always the selected one. - // A selected pill that is NOT focused means focus is down in the pane — - // it dims to a marker so the one solid-white element on screen is the - // control you're actually on. (Deliberate departure from tvOS, which - // keeps the selected pill white throughout.) + // The rail carries the ground, so an idle tab is just its label. Selection + // follows focus, so a focused tab is always the selected one; a selected + // tab that is NOT focused means focus is down in the pane, and it keeps a + // white-16% marker so the one Paper element on screen is the control + // you're on. Focus is an inversion, never a scale. val bg = when { - isFocused -> Color.White - isSelected -> Color.White.copy(alpha = 0.22f) - // Firmer than tvOS's black@0.45: white type on 0.45 loses contrast - // over a bright frame, and the rail has no card behind it. - else -> Color.Black.copy(alpha = 0.62f) + isFocused -> TvPlayerChrome.Paper + isSelected -> Color.White.copy(alpha = 0.16f) + else -> Color.Transparent + } + val fg = when { + isFocused -> TvPlayerChrome.Ink + isSelected -> TvPlayerChrome.Paper + else -> TvPlayerChrome.Graphite } - val fg = if (isFocused) Color.Black else Color.White - val stroke = if (isFocused || isSelected) Color.Transparent else Color.White.copy(alpha = 0.18f) - val scale by animateFloatAsState( - targetValue = if (isFocused) 1.0f else 0.96f, - animationSpec = tween(120), - label = "hudTabScale", - ) Box( modifier = Modifier - .graphicsLayer { scaleX = scale; scaleY = scale } .height(HudTabHeight) - .clip(RoundedCornerShape(50)) + .clip(CircleShape) .background(bg) - .border(width = 0.5.dp, color = stroke, shape = RoundedCornerShape(50)) .focusRequester(focusRequester) .focusable(enabled = enabled, interactionSource = interactionSource) - .padding(horizontal = 18.dp), + .padding(horizontal = 15.dp), contentAlignment = Alignment.Center, ) { - Text( - text = label, - color = fg, - style = MaterialTheme.typography.titleSmall.copy( - fontSize = HudTabTextSize, - lineHeight = HudTabLineHeight, - fontWeight = FontWeight.SemiBold, - ), - ) + Text(text = label, style = TvPlayerType.Tab, color = fg, maxLines = 1) } } @@ -706,19 +794,26 @@ private fun HudPaneViewport( modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit, ) { + val scroll = rememberScrollState() Column( modifier = modifier .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(bottom = HudPaneBottomPadding), - verticalArrangement = Arrangement.spacedBy(10.dp), + .hudFadingEdges(scroll) + .verticalScroll(scroll), content = content, ) } +/** + * The pane grid every two-column tab shares: 330 of 736dp on the left, a + * 26dp gutter, the rest on the right. Each column scrolls on its own and + * fades at the edge it scrolls past rather than cutting a row in half. + */ @Composable -private fun HudTwoColumnPane( +private fun HudColumns( modifier: Modifier = Modifier, + /** Pinned above the left column's scroll, so a long list keeps its label. */ + leftHeader: (@Composable () -> Unit)? = null, left: @Composable ColumnScope.() -> Unit, right: @Composable ColumnScope.() -> Unit, ) { @@ -726,8 +821,24 @@ private fun HudTwoColumnPane( modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(HudPaneColumnGap), ) { - PaneColumn("Title", modifier = Modifier.weight(1f), content = left) - PaneColumn("Stream", modifier = Modifier.weight(1f), content = right) + val leftScroll = rememberScrollState() + val rightScroll = rememberScrollState() + Column(modifier = Modifier.weight(HudLeftColumnWeight)) { + leftHeader?.invoke() + Column( + modifier = Modifier + .hudFadingEdges(leftScroll) + .verticalScroll(leftScroll), + content = left, + ) + } + Column( + modifier = Modifier + .weight(HudRightColumnWeight) + .hudFadingEdges(rightScroll) + .verticalScroll(rightScroll), + content = right, + ) } } @@ -749,16 +860,12 @@ private fun HudInfoPane( playbackPlan: PlaybackExecutionPlan?, subtitleLabel: String, chapters: List<VersionChapter>, - modifier: Modifier = Modifier, ) { val episodeTag = if (seasonNumber != null && episodeNumber != null) { "S$seasonNumber · E$episodeNumber" } else { null } - val metaBits = buildList { - if (durationSec > 0) add(formatTime(durationSec)) - } val streamRows = buildList<Pair<String, String>> { stats.backendRoute?.let { add("Route" to it) } playbackPlan?.takeIf { @@ -782,70 +889,39 @@ private fun HudInfoPane( stats.resolution?.let { add(it) } } - HudTwoColumnPane( - modifier = modifier, + HudColumns( left = { - Text( - text = title.ifBlank { "Now Playing" }, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.headlineSmall.copy( - fontSize = HudTitleTextSize, - lineHeight = HudTitleLineHeight, - fontWeight = FontWeight.SemiBold, - ), - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - if (episodeTag != null) { - Text( - text = episodeTag, - color = Color.White.copy(alpha = 0.75f), - style = MaterialTheme.typography.titleMedium.copy( - fontSize = HudBodyTextSize, - lineHeight = HudBodyLineHeight, - ), - ) - } - if (metaBits.isNotEmpty()) { + HudEyebrow("Title") + Column( + modifier = Modifier.padding(horizontal = HudRowPadding), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (episodeTag != null) { + Text(text = episodeTag.uppercase(), style = TvPlayerType.Eyebrow) + } Text( - text = metaBits.joinToString(" · "), - color = Color.White.copy(alpha = 0.65f), - style = MaterialTheme.typography.bodyMedium.copy( - fontSize = HudMetaTextSize, - lineHeight = HudMetaLineHeight, - ), + text = title.ifBlank { "Now Playing" }, + style = TvPlayerType.DialogTitle, + maxLines = 2, + overflow = TextOverflow.Ellipsis, ) - } - }, - right = { - if (badges.isNotEmpty()) { - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - badges.forEach { badge -> - Box( - modifier = Modifier - .clip(RoundedCornerShape(50)) - .border( - width = 0.5.dp, - color = Color.White.copy(alpha = 0.35f), - shape = RoundedCornerShape(50), - ) - .padding(horizontal = 7.dp, vertical = 3.dp), - ) { - Text( - text = badge, - color = Color.White, - style = MaterialTheme.typography.labelMedium.copy( - fontSize = HudChipTextSize, - lineHeight = HudChipLineHeight, - fontWeight = FontWeight.SemiBold, - ), - ) - } + if (durationSec > 0) { + Text(text = formatTime(durationSec), style = TvPlayerType.Figures) + } + if (badges.isNotEmpty()) { + Row( + modifier = Modifier.padding(top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + badges.forEach { badge -> HudChip(badge) } } } } + }, + right = { + HudEyebrow("Stream") streamRows.forEach { (label, value) -> - LabelValueRow(label = label, value = value) + HudReadOnlyRow(label = label, value = value) } }, ) @@ -885,58 +961,47 @@ private fun currentChapterTitle(chapters: List<VersionChapter>, positionSec: Dou return current.title.ifBlank { "Chapter ${current.index + 1}" } } +/** A section label inside a pane, inset to line up with row text. */ @Composable -private fun PaneColumn( - header: String, - modifier: Modifier = Modifier, - content: @Composable ColumnScope.() -> Unit, -) { - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(8.dp), +private fun HudEyebrow(text: String, modifier: Modifier = Modifier, trailing: String? = null) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(start = HudRowPadding, end = HudRowPadding, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, ) { Text( - text = header.uppercase(), - color = Color.White.copy(alpha = 0.5f), - style = MaterialTheme.typography.labelMedium.copy( - fontSize = HudChipTextSize, - lineHeight = HudChipLineHeight, - fontWeight = FontWeight.SemiBold, - ), + text = text.uppercase(), + style = TvPlayerType.Eyebrow, + maxLines = 1, + modifier = Modifier.weight(1f), ) - content() + if (trailing != null) { + Text(text = trailing, style = TvPlayerType.Figures, maxLines = 1) + } } } +/** Space between two sections in one column. */ @Composable -private fun LabelValueRow(label: String, value: String) { - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - // Fixed gap, weighted value — same reasoning as HudFocusedSettingRow: a - // lone weighted spacer collapses to 0dp once the two texts fill the row, - // which is how "SubtitlesArabic — SRT · Exter…" rendered. - Text( - text = label, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.bodyLarge.copy( - fontSize = HudBodyTextSize, - lineHeight = HudBodyLineHeight, - fontWeight = FontWeight.Medium, - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Spacer(modifier = Modifier.width(12.dp)) +private fun HudSectionSpacer() { + Spacer(modifier = Modifier.height(HudSectionGap)) +} + +/** A small uppercase tag: SDH, FORCED, a resolution, an HDR format. */ +@Composable +private fun HudChip(text: String, onPaper: Boolean = false) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(if (onPaper) TvPlayerChrome.Ink.copy(alpha = 0.10f) else TvPlayerChrome.Chip) + .padding(horizontal = 5.dp, vertical = 2.dp), + ) { Text( - text = value, - color = Color.White.copy(alpha = 0.7f), - style = MaterialTheme.typography.bodyLarge.copy( - fontSize = HudBodyTextSize, - lineHeight = HudBodyLineHeight, - ), + text = text.uppercase(), + style = TvPlayerType.Chip, + color = if (onPaper) TvPlayerChrome.InkMuted else TvPlayerType.Chip.color, maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.End, - modifier = Modifier.weight(1f), ) } } @@ -985,11 +1050,8 @@ private fun HudStatsPane( horizontalArrangement = Arrangement.spacedBy(HudPaneColumnGap), ) { listOf(rows.take(split), rows.drop(split)).forEach { column -> - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - column.forEach { (label, value) -> LabelValueRow(label = label, value = value) } + Column(modifier = Modifier.weight(1f)) { + column.forEach { (label, value) -> HudReadOnlyRow(label = label, value = value) } } } } @@ -1099,15 +1161,11 @@ private val PLAYBACK_SPEED_OPTIONS = listOf(0.75, 1.0, 1.25, 1.5, 2.0) private fun speedOptionId(speed: Double): String = String.format(java.util.Locale.ROOT, "%.2f", speed) -/** 1.0 -> "1.0×", 1.25 -> "1.25×" (matches tvOS speed labels). */ +/** 1.0 -> "1×", 1.25 -> "1.25×", 2.0 -> "2×": no trailing zero, as on the phone. */ private fun formatTvPlaybackSpeed(speed: Double): String { - val text = if (speed % 1.0 == 0.0) { - // Locale.ROOT so comma-decimal devices render "1.0×", not "1,0×", - // matching the dot-formatted speedOptionId used to commit the choice. - String.format(java.util.Locale.ROOT, "%.1f", speed) - } else { - speed.toString().trimEnd('0').trimEnd('.') - } + // Locale.ROOT so comma-decimal devices render "1.25×", not "1,25×", + // matching the dot-formatted speedOptionId used to commit the choice. + val text = String.format(java.util.Locale.ROOT, "%.2f", speed).trimEnd('0').trimEnd('.') return "$text×" } @@ -1157,6 +1215,7 @@ private fun HudVideoPane( onFillModeChanged: (VideoFillMode) -> Unit, playbackSpeed: Double, onPlaybackSpeedChanged: (Double) -> Unit, + speedRowVisible: Boolean, sleepTimerState: SleepTimerState, onStartSleepTimer: (Int) -> Unit, onCancelSleepTimer: () -> Unit, @@ -1173,32 +1232,21 @@ private fun HudVideoPane( // actually focusable. A disabled row is not focusable, so pointing the // requester at it would cancel the move in from the rail. val hasVersionRow = fileVersions.size > 1 - val hasQualityRow = videoQualities.size > 1 + // The quality menu exists whenever a plan does: even a single entry + // lists Auto + Original. + val hasQualityRow = videoQualities.isNotEmpty() val entryRow = when { hasVersionRow -> "version" hasQualityRow -> "quality" - else -> "speed" + speedRowVisible -> "speed" + else -> "aspect" } - Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(HudPaneColumnGap), - ) { - // Playback column — Quality / Speed / Aspect / HDR + auto toggles. - PaneColumn( - "Playback", - modifier = Modifier - .weight(1f) - .verticalScroll(rememberScrollState()), - ) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - // Quality — derived from the real per-format video variants - // (resolution / bitrate) flattened from the video group. This is - // a genuine Media3 track override (setOverrideForType on the - // video group), not a no-op. When there is only one real variant - // (Auto + 0 or 1 format) there is nothing to switch, so the row - // is shown disabled with an "Auto" value rather than faking it. - // (videoQualities, when present, always contains a synthetic - // "Auto" entry, so a genuine choice means size > 2.) + HudColumns( + modifier = modifier, + left = { + // Playback column — Version / Quality / Speed / Aspect. + HudEyebrow("Playback") + Column { // Version — the server's file versions (4K / 1080p encodes). // Switching restarts the session on that file at the current // position (QA 2026-07-08 / tvOS parity). @@ -1235,57 +1283,65 @@ private fun HudVideoPane( ) } - // The server-transcode quality ladder always offers at least - // Auto + Original (plus downscale rungs below the source), so the - // row is enabled whenever there is more than one option. - val hasQualityChoice = videoQualities.size > 1 - val selectedQuality = videoQualities.firstOrNull { it.isSelected } - val qualityValue = selectedQuality?.label ?: "Auto" - HudFocusedSettingRow( - label = "Quality", - value = qualityValue, - enabled = enabled && hasQualityChoice, - entryFocusRequester = entryFocusRequester.takeIf { entryRow == "quality" }, - onActivate = { - onPresentPicker( - HudPickerPresentation( - title = "Quality", - options = videoQualities.map { - HudPickerOption(id = it.id, label = it.label) - }, - selectedId = (selectedQuality?.id ?: "auto"), - onSelect = { id -> onSelectVideoQuality(id) }, - ), - ) - }, - ) + // Quality — the plan's quality menu: Auto, then the server's + // entries in its order, each re-planned on the server when + // picked. Hidden only when there is no plan. + if (hasQualityRow) { + val selectedQuality = videoQualities.firstOrNull { it.isSelected } + HudFocusedSettingRow( + label = "Quality", + value = selectedQuality?.label ?: "Auto", + enabled = enabled, + entryFocusRequester = entryFocusRequester.takeIf { entryRow == "quality" }, + onActivate = { + onPresentPicker( + HudPickerPresentation( + title = "Quality", + options = videoQualities.map { + HudPickerOption(id = it.id, label = it.label, trailing = it.bitrateLabel) + }, + // Prairie ladder rows use "auto" as the Auto id. + selectedId = selectedQuality?.id ?: "auto", + onSelect = { id -> onSelectVideoQuality(id) }, + ), + ) + }, + ) + } - HudFocusedSettingRow( - label = "Speed", - value = formatTvPlaybackSpeed(playbackSpeed), - enabled = enabled, - entryFocusRequester = entryFocusRequester.takeIf { entryRow == "speed" }, - onActivate = { - onPresentPicker( - HudPickerPresentation( - title = "Playback Speed", - options = PLAYBACK_SPEED_OPTIONS.map { - HudPickerOption(speedOptionId(it), formatTvPlaybackSpeed(it)) - }, - selectedId = speedOptionId(playbackSpeed), - onSelect = { id -> - PLAYBACK_SPEED_OPTIONS.firstOrNull { speedOptionId(it) == id } - ?.let(onPlaybackSpeedChanged) - }, - ), - ) - }, - ) + if (speedRowVisible) { + HudFocusedSettingRow( + label = "Speed", + value = formatTvPlaybackSpeed(playbackSpeed), + enabled = enabled, + entryFocusRequester = entryFocusRequester.takeIf { entryRow == "speed" }, + onActivate = { + onPresentPicker( + HudPickerPresentation( + title = "Playback speed", + options = PLAYBACK_SPEED_OPTIONS.map { + if (it == 1.0) { + HudPickerOption(speedOptionId(it), "Normal", trailing = formatTvPlaybackSpeed(it)) + } else { + HudPickerOption(speedOptionId(it), formatTvPlaybackSpeed(it)) + } + }, + selectedId = speedOptionId(playbackSpeed), + onSelect = { id -> + PLAYBACK_SPEED_OPTIONS.firstOrNull { speedOptionId(it) == id } + ?.let(onPlaybackSpeedChanged) + }, + ), + ) + }, + ) + } HudFocusedSettingRow( label = "Aspect", value = fillModeLabel(fillMode), enabled = enabled, + entryFocusRequester = entryFocusRequester.takeIf { entryRow == "aspect" }, onActivate = { onPresentPicker( HudPickerPresentation( @@ -1303,114 +1359,106 @@ private fun HudVideoPane( }, ) } - } - + }, // Right column: what the device does with the picture, then what the // player does on its own. Previously the left column carried eight // rows against a lone Sleep timer here — the pane scrolled while // half the card sat empty. - Column( - modifier = Modifier - .weight(1f) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - PaneColumn("Output") { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - HudFocusedSettingRow( - label = "HDR passthrough", - value = onOffLabel(hdrEnabled), - enabled = enabled, - showsChevron = false, - onActivate = { onHdrEnabledChanged(!hdrEnabled) }, - ) + right = { + HudEyebrow("Output") + Column { + HudFocusedSettingRow( + label = "HDR passthrough", + value = onOffLabel(hdrEnabled), + enabled = enabled, + showsChevron = false, + onActivate = { onHdrEnabledChanged(!hdrEnabled) }, + ) - // Off plays DV sources as their base layer (HDR10) — some - // users prefer HDR10 even on DV-capable displays. Profile 5 - // always plays as DV (no watchable base layer); applies from - // the next playback start. Apple parity (silo-apple e9bd775). - HudFocusedSettingRow( - label = "Dolby Vision", - // A toggle on a DV file restarts the session so the - // server can re-plan the layer; say so on the row (the - // subtitle track row's idiom) and swallow presses until - // the replacement is playing, so a second press can't - // queue a second restart behind the first. Swallow, not - // disable: a disabled row is not focusable, and taking - // focus off the row the viewer just pressed left the - // next press landing on nothing. - value = if (dolbyVisionSwitchInFlight) { - "${onOffLabel(dolbyVisionEnabled)} · Applying…" - } else { - onOffLabel(dolbyVisionEnabled) - }, - enabled = enabled, - showsChevron = false, - onActivate = { - if (!dolbyVisionSwitchInFlight) { - onDolbyVisionEnabledChanged(!dolbyVisionEnabled) - } - }, - ) - } + // Off plays DV sources as their base layer (HDR10) — some + // users prefer HDR10 even on DV-capable displays. Profile 5 + // always plays as DV (no watchable base layer); applies from + // the next playback start. Apple parity (prairie-apple e9bd775). + HudFocusedSettingRow( + label = "Dolby Vision", + // A toggle on a DV file restarts the session so the + // server can re-plan the layer; say so on the row (the + // subtitle track row's idiom) and swallow presses until + // the replacement is playing, so a second press can't + // queue a second restart behind the first. Swallow, not + // disable: a disabled row is not focusable, and taking + // focus off the row the viewer just pressed left the + // next press landing on nothing. + value = if (dolbyVisionSwitchInFlight) { + "${onOffLabel(dolbyVisionEnabled)} · Applying…" + } else { + onOffLabel(dolbyVisionEnabled) + }, + enabled = enabled, + showsChevron = false, + onActivate = { + if (!dolbyVisionSwitchInFlight) { + onDolbyVisionEnabledChanged(!dolbyVisionEnabled) + } + }, + ) } + HudSectionSpacer() + HudEyebrow("Automation") + Column { + // Three values, so Select cycles rather than toggles — + // the same one-press shape as the rows around it, without + // a picker sheet over the picture. Settings has the list. + HudFocusedSettingRow( + label = stringResource(R.string.settings_intro_skip_title), + value = stringResource(introSkipModeLabel(introSkipMode)), + enabled = enabled, + showsChevron = false, + onActivate = { onIntroSkipModeChanged(introSkipMode.next()) }, + ) - PaneColumn("Automation") { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - // Three values, so Select cycles rather than toggles — - // the same one-press shape as the rows around it, without - // a picker sheet over the picture. Settings has the list. - HudFocusedSettingRow( - label = stringResource(R.string.settings_intro_skip_title), - value = stringResource(introSkipModeLabel(introSkipMode)), - enabled = enabled, - showsChevron = false, - onActivate = { onIntroSkipModeChanged(introSkipMode.next()) }, - ) - - HudFocusedSettingRow( - label = "Auto-play next", - value = onOffLabel(autoPlayNext), - enabled = enabled, - showsChevron = false, - onActivate = { onAutoPlayNextChanged(!autoPlayNext) }, - ) + HudFocusedSettingRow( + label = "Auto-play next", + value = onOffLabel(autoPlayNext), + enabled = enabled, + showsChevron = false, + onActivate = { onAutoPlayNextChanged(!autoPlayNext) }, + ) - val activeSleep = sleepTimerState as? SleepTimerState.Active - HudFocusedSettingRow( - label = "Sleep timer", - value = activeSleep?.let { "Sleeping in ${formatSleepRemaining(it.remainingSeconds)}" } ?: "Off", - enabled = enabled, - onActivate = { - onPresentPicker( - HudPickerPresentation( - title = "Sleep Timer", - options = buildList { - if (activeSleep != null) { - add(HudPickerOption("cancel", "Cancel timer")) - } - add(HudPickerOption("off", "Off")) - addAll( - SLEEP_TIMER_PRESETS.map { minutes -> - HudPickerOption(minutes.toString(), sleepPresetLabel(minutes)) - }, - ) - }, - selectedId = if (activeSleep != null) "cancel" else "off", - onSelect = { id -> - when (id) { - "cancel", "off" -> onCancelSleepTimer() - else -> id.toIntOrNull()?.let(onStartSleepTimer) - } - }, - ), - ) - }, - ) - } + val activeSleep = sleepTimerState as? SleepTimerState.Active + HudFocusedSettingRow( + label = "Sleep timer", + value = activeSleep?.let { "Sleeping in ${formatSleepRemaining(it.remainingSeconds)}" } ?: "Off", + enabled = enabled, + onActivate = { + onPresentPicker( + HudPickerPresentation( + title = "Sleep timer", + options = buildList { + if (activeSleep != null) { + add(HudPickerOption("cancel", "Cancel timer")) + } + add(HudPickerOption("off", "Off")) + addAll( + SLEEP_TIMER_PRESETS.map { minutes -> + HudPickerOption(minutes.toString(), sleepPresetLabel(minutes)) + }, + ) + }, + selectedId = if (activeSleep != null) "cancel" else "off", + onSelect = { id -> + when (id) { + "cancel", "off" -> onCancelSleepTimer() + else -> id.toIntOrNull()?.let(onStartSleepTimer) + } + }, + ), + ) + }, + ) } - } - } + }, + ) } /** @@ -1437,17 +1485,22 @@ private fun HudAudioPane( // value 500dp from its label — "Audio track ……… English · DTS · 5.1" — // and left the card two-thirds empty. The right column is read-only // output facts the viewer would otherwise have to dig out of Stats. - Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(HudPaneColumnGap), - ) { - PaneColumn( - "Track", - modifier = Modifier - .weight(1f) - .verticalScroll(rememberScrollState()), - ) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + // Output — what the device is actually doing with the track. Mode is + // the fact behind the delay row's "Unavailable during passthrough": + // bitstream passthrough hands the codec to the receiver untouched, so + // there is no PCM to delay. + val outputRows = buildList<Pair<String, String>> { + audioFormatShortName(stats.audioCodec)?.let { add("Codec" to it) } + add("Mode" to if (audioDelayEnabled) "Decoded to PCM" else "Passthrough") + stats.audioDecoderName + ?.takeIf { audioDelayEnabled } + ?.let { add("Decoder" to it.removePrefix("OMX.").removePrefix("c2.")) } + } + HudColumns( + modifier = modifier, + left = { + HudEyebrow("Track") + Column { val selectedTrack = audioTracks.firstOrNull { it.isSelected } val catalogAudio = activeVersion?.audioTracks.orEmpty() val formatting = org.prairieserver.prairie.tv.ui.screens.detail.TvPlaybackFormatting @@ -1491,7 +1544,7 @@ private fun HudAudioPane( onActivate = { onPresentPicker( HudPickerPresentation( - title = "Audio Track", + title = "Audio track", // Ids are catalog ordinals, the server's audio // contract, so an undelivered row stays // selectable and survives the round trip. @@ -1518,7 +1571,7 @@ private fun HudAudioPane( onActivate = { onPresentPicker( delayPicker( - title = "Audio Delay", + title = "Audio delay", current = audioDelayMs, from = -1_000, to = 1_000, @@ -1529,34 +1582,16 @@ private fun HudAudioPane( }, ) } - } - - // Output — what the device is actually doing with the track. Mode is - // the fact behind the delay row's "Unavailable during passthrough": - // bitstream passthrough hands the codec to the receiver untouched, so - // there is no PCM to delay. - val outputRows = buildList<Pair<String, String>> { - audioFormatShortName(stats.audioCodec)?.let { add("Codec" to it) } - add("Mode" to if (audioDelayEnabled) "Decoded to PCM" else "Passthrough") - stats.audioDecoderName - ?.takeIf { audioDelayEnabled } - ?.let { add("Decoder" to it.removePrefix("OMX.").removePrefix("c2.")) } - } - PaneColumn( - "Output", - modifier = Modifier - .weight(1f) - .verticalScroll(rememberScrollState()), - ) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - outputRows.forEach { (label, value) -> - // Same row metrics as the setting rows on the left, so the - // two columns rule up; not focusable, nothing to open. - HudReadOnlyRow(label = label, value = value) - } + }, + right = { + HudEyebrow("Output") + outputRows.forEach { (label, value) -> + // Same row metrics as the setting rows on the left, so the + // two columns rule up; not focusable, nothing to open. + HudReadOnlyRow(label = label, value = value) } - } - } + }, + ) } /** @@ -1569,28 +1604,17 @@ private fun HudReadOnlyRow(label: String, value: String) { Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 10.dp, vertical = 8.dp), + .heightIn(min = HudRowHeight) + .padding(horizontal = HudRowPadding, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = label, - color = Color.White, - style = MaterialTheme.typography.bodyLarge.copy( - fontSize = HudBodyTextSize, - lineHeight = HudBodyLineHeight, - fontWeight = FontWeight.Medium, - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + // Fixed gap, weighted value: a lone weighted spacer collapses to 0dp + // once the two texts fill the row ("SubtitlesArabic — SRT · Exter…"). + Text(text = label, style = TvPlayerType.Row, maxLines = 1, overflow = TextOverflow.Ellipsis) Spacer(modifier = Modifier.width(12.dp)) Text( text = value, - color = Color.White.copy(alpha = 0.72f), - style = MaterialTheme.typography.bodyLarge.copy( - fontSize = HudBodyTextSize, - lineHeight = HudBodyLineHeight, - ), + style = TvPlayerType.Value, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.End, @@ -1600,22 +1624,30 @@ private fun HudReadOnlyRow(label: String, value: String) { } /** - * Subtitles pane — track selection + delay + appearance rendered as tvOS - * row→dialog rows, plus the Android-only Search / AI-Translate rows preserved - * from the old drawer. Text-color / background-color stay as swatch chips since - * tvOS draws color swatches inline in its picker too. + * Subtitles pane. The left column lists every track inline, so switching + * subtitles is one press rather than row → picker → row. The right column + * shows a sample on the live picture, then Style (Size, Appearance, Timing) + * and the Android-only Find more rows. Appearance and Timing drill in place: + * [page] swaps the right column, and Back steps out (handled by the HUD). */ @Composable private fun HudSubtitlesPane( presentation: TvSubtitleHudPresentation, + page: HudSubtitlePage, + onPageChange: (HudSubtitlePage) -> Unit, subtitleDelayMs: Int, subtitleDelayEnabled: Boolean, onSubtitleDelayChanged: (Int) -> Unit, appearance: SubtitleAppearance, onAppearanceChanged: (SubtitleAppearance) -> Unit, + showTextOpacity: Boolean, onPaneShown: () -> Unit, onSearchSubtitles: (() -> Unit)?, onTranslateWithAi: (() -> Unit)?, + timing: SubtitleTimingActions?, + syncStatus: (SubtitleIdentity) -> String?, + onSyncSubtitle: (String) -> Unit, + onResetTiming: (String) -> Unit, entryFocusRequester: FocusRequester, enabled: Boolean, onPresentPicker: (HudPickerPresentation) -> Unit, @@ -1631,426 +1663,611 @@ private fun HudSubtitlesPane( val geometryEnabled = enabled && applicability.geometryApplies val stylingEnabled = enabled && applicability.stylingApplies - val subtitleTrackFocus = remember { FocusRequester() } - val subtitleTextColorFocus = remember { FocusRequester() } - val subtitleBackgroundColorFocus = remember { FocusRequester() } - val subtitleOutlineColorFocus = remember { FocusRequester() } - - Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(HudPaneColumnGap), - ) { - // Tracks + sync column. - PaneColumn( - "Tracks", - modifier = Modifier - .weight(1f) - .verticalScroll(rememberScrollState()), - ) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - val checkedRow = presentation.rows.firstOrNull { row -> row.checked } - val applyingRow = presentation.rows.firstOrNull { row -> row.applying } - val focusedRow = presentation.rows.firstOrNull { row -> row.focused } - HudFocusedSettingRow( - label = "Subtitles", - value = applyingRow?.let { "${it.label} · Applying…" } - ?: checkedRow?.label - ?: "Off", - enabled = enabled, - focusRequester = subtitleTrackFocus, - entryFocusRequester = entryFocusRequester, - rightFocusRequester = subtitleTextColorFocus, - onActivate = { - onPresentPicker( - HudPickerPresentation( - title = "Subtitle Track", - options = presentation.rows.map { row -> - HudPickerOption( - id = row.stableId, - label = if (row.applying) { - "${row.label} · Applying…" - } else { - row.label - }, - ) - }, - selectedId = checkedRow?.stableId - ?: presentation.rows.firstOrNull()?.stableId.orEmpty(), - focusedId = focusedRow?.stableId - ?: checkedRow?.stableId - ?: presentation.rows.firstOrNull()?.stableId.orEmpty(), - closeOnSelect = false, - onFocused = presentation.onFocused, - onSelect = { stableId -> - presentation.rows - .firstOrNull { row -> row.stableId == stableId } - ?.let { row -> presentation.onSelect(row.identity) } - }, - ), - ) - }, - ) - - HudFocusedSettingRow( - label = "Delay", - value = if (subtitleDelayEnabled) { - delayLabel(subtitleDelayMs) - } else { - "Unavailable for burned-in subtitles" - }, - enabled = enabled && subtitleDelayEnabled, - rightFocusRequester = subtitleTextColorFocus, - onActivate = { - onPresentPicker( - delayPicker( - title = "Subtitle Delay", - current = subtitleDelayMs, - from = -2_000, - to = 2_000, - step = 100, - onSet = onSubtitleDelayChanged, - ), - ) - }, - ) - - HudFocusedSettingRow( - label = "Size", - value = FONT_SIZES.firstOrNull { it.first == appearance.fontSize }?.second - ?: appearance.fontSize.name, - enabled = geometryEnabled, - rightFocusRequester = subtitleTextColorFocus, - onActivate = { - onPresentPicker( - HudPickerPresentation( - title = "Subtitle Size", - options = FONT_SIZES.map { HudPickerOption(it.first.name, it.second) }, - selectedId = appearance.fontSize.name, - onSelect = { id -> - FONT_SIZES.firstOrNull { it.first.name == id }?.let { - onAppearanceChanged(appearance.copy(fontSize = it.first)) - } - }, - ), - ) - }, - ) - - HudFocusedSettingRow( - label = "Font", - value = FONT_FAMILIES.firstOrNull { it.first == appearance.fontFamily }?.second - ?: appearance.fontFamily, - enabled = stylingEnabled, - rightFocusRequester = subtitleTextColorFocus, - onActivate = { - onPresentPicker( - HudPickerPresentation( - title = "Subtitle Font", - options = FONT_FAMILIES.map { HudPickerOption(it.first, it.second) }, - selectedId = appearance.fontFamily, - onSelect = { id -> - FONT_FAMILIES.firstOrNull { it.first == id }?.let { - onAppearanceChanged(appearance.copy(fontFamily = it.first)) - } - }, - ), - ) - }, - ) - - HudFocusedSettingRow( - label = "Background", - value = BACKGROUND_STYLES.firstOrNull { it.first == appearance.backgroundStyle }?.second - ?: appearance.backgroundStyle.name, - enabled = stylingEnabled, - rightFocusRequester = subtitleBackgroundColorFocus, - onActivate = { - onPresentPicker( - HudPickerPresentation( - title = "Subtitle Background", - options = BACKGROUND_STYLES.map { HudPickerOption(it.first.name, it.second) }, - selectedId = appearance.backgroundStyle.name, - onSelect = { id -> - BACKGROUND_STYLES.firstOrNull { it.first.name == id }?.let { - onAppearanceChanged(appearance.copy(backgroundStyle = it.first)) - } - }, - ), - ) - }, - ) - - HudFocusedSettingRow( - label = "Opacity", - value = "${appearance.backgroundOpacity}%", - enabled = stylingEnabled, - rightFocusRequester = subtitleTextColorFocus, - onActivate = { - onPresentPicker( - HudPickerPresentation( - title = "Background Opacity", - options = OPACITY_STEPS.map { HudPickerOption(it.toString(), "$it%") }, - selectedId = appearance.backgroundOpacity.toString(), - onSelect = { id -> - id.toIntOrNull()?.let { - onAppearanceChanged(appearance.copy(backgroundOpacity = it)) - } - }, - ), - ) - }, - ) - - HudFocusedSettingRow( - label = "Outline", - value = onOffLabel(appearance.textOutline), - enabled = stylingEnabled, - // The outline-color swatch (subtitleOutlineColorFocus) is only - // composed when textOutline is on. Right-nav must not target a - // detached requester when it's off, so gate the target on it. - rightFocusRequester = subtitleOutlineColorFocus.takeIf { appearance.textOutline }, - showsChevron = false, - onActivate = { onAppearanceChanged(appearance.copy(textOutline = !appearance.textOutline)) }, - ) - - HudFocusedSettingRow( - label = "Position", - value = POSITIONS.firstOrNull { it.first == appearance.position }?.second - ?: appearance.position.name, - enabled = geometryEnabled, - rightFocusRequester = subtitleTextColorFocus, - onActivate = { - onPresentPicker( - HudPickerPresentation( - title = "Subtitle Position", - options = POSITIONS.map { HudPickerOption(it.first.name, it.second) }, - selectedId = appearance.position.name, - onSelect = { id -> - POSITIONS.firstOrNull { it.first.name == id }?.let { - onAppearanceChanged(appearance.copy(position = it.first)) - } - }, - ), - ) - }, - ) + // Moving between the root and a page removes the focused row, so focus is + // handed on explicitly: into a page's first row, or back to the row that + // opened it. Observed through the rows' own focus, not the pane's. + val appearanceRowFocus = remember { FocusRequester() } + val timingRowFocus = remember { FocusRequester() } + val pageEntryFocus = remember { FocusRequester() } + var focusedPageRow by remember { mutableStateOf<String?>(null) } + var lastPage by remember { mutableStateOf(page) } + LaunchedEffect(page) { + val previous = lastPage + lastPage = page + if (previous == page) return@LaunchedEffect + val (target, key) = when (page) { + HudSubtitlePage.Root -> when (previous) { + HudSubtitlePage.Timing -> timingRowFocus to "root:timing" + else -> appearanceRowFocus to "root:appearance" + } + else -> pageEntryFocus to "page:entry" + } + requestFocusUntilObserved( + maxAttempts = TvFrameRelocationMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = target::requestFocus, + isFocused = { focusedPageRow == key }, + ) + } + fun Modifier.trackPageFocus(key: String) = onFocusChanged { state -> + if (state.isFocused) { + focusedPageRow = key + } else if (focusedPageRow == key) { + focusedPageRow = null + } + } - applicability.note?.let { note -> - Text( - text = note, - color = Color.White.copy(alpha = 0.62f), - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + HudColumns( + modifier = modifier, + leftHeader = { + val trackCount = presentation.rows.count { row -> row.identity != SubtitleIdentity.Off } + HudEyebrow(if (trackCount > 0) "Tracks · $trackCount" else "Tracks") + }, + left = { + // The entry row is the checked track, so Down from the rail lands + // on the current choice; the first row when nothing is checked. + val entryRow = presentation.rows.firstOrNull { row -> row.checked } ?: presentation.rows.firstOrNull() + presentation.rows.forEach { row -> + key(row.stableId) { + HudSubtitleTrackRow( + row = row, + syncStatus = syncStatus(row.identity), + enabled = enabled, + focusRequester = entryFocusRequester.takeIf { row == entryRow }, + onFocused = { presentation.onFocused(row.stableId) }, + onSelect = { presentation.onSelect(row.identity) }, ) } } - } - - // Colors + Android-only acquisition column. - PaneColumn( - "Style & sources", - modifier = Modifier - .weight(1f) - .verticalScroll(rememberScrollState()), - ) { + }, + right = { HudSubtitlePreview(appearance = appearance) + when (page) { + HudSubtitlePage.Root -> { + HudEyebrow("Style") + HudFocusedSettingRow( + label = "Size", + value = TvSubtitleAppearanceOptions.fontSizeLabel(appearance.fontSize), + enabled = geometryEnabled, + onActivate = { + onPresentPicker( + HudPickerPresentation( + title = "Subtitle size", + options = FONT_SIZES.map { HudPickerOption(it.first.name, it.second) }, + selectedId = appearance.fontSize.name, + onSelect = { id -> + FONT_SIZES.firstOrNull { it.first.name == id }?.let { + onAppearanceChanged(appearance.copy(fontSize = it.first)) + } + }, + ), + ) + }, + ) + HudFocusedSettingRow( + label = "Appearance", + value = subtitleAppearanceSummary(appearance), + // Position is geometry, so the page is useful even + // when only geometry applies (image subtitles). + enabled = geometryEnabled, + focusRequester = appearanceRowFocus, + modifier = Modifier.trackPageFocus("root:appearance"), + onActivate = { onPageChange(HudSubtitlePage.Appearance) }, + ) + HudFocusedSettingRow( + label = "Timing", + value = subtitleTimingSummary(subtitleDelayMs, subtitleDelayEnabled, timing), + enabled = enabled && (subtitleDelayEnabled || timing != null), + focusRequester = timingRowFocus, + modifier = Modifier.trackPageFocus("root:timing"), + onActivate = { onPageChange(HudSubtitlePage.Timing) }, + ) + applicability.note?.let { note -> HudNote(note) } + if (onSearchSubtitles != null || onTranslateWithAi != null) { + HudSectionSpacer() + HudEyebrow("Find more") + if (onSearchSubtitles != null) { + HudActionRow( + label = "Search online", + icon = Icons.Rounded.Search, + enabled = enabled, + onClick = onSearchSubtitles, + ) + } + if (onTranslateWithAi != null) { + HudActionRow( + label = "Translate with AI", + icon = Icons.Rounded.AutoAwesome, + enabled = enabled, + onClick = onTranslateWithAi, + ) + } + } + } - // There is deliberately no "No background" toggle here. It was a - // second control over backgroundStyle, which the Background picker - // in the left column already exposes as "No background" — and - // toggling it off could not know what the style had been, so it - // hard-coded Box and silently destroyed the user's choice - // (Drop Shadow -> On -> Off left you on Box, persisted immediately). - // tvOS has no such toggle either: TVPlayerInfoHUD offers a single - // Style picker plus a Background color row. - - // Color swatches stay inline — tvOS draws color swatches directly, - // and a row→dialog of colors would lose the at-a-glance palette. - StyleSection("Text color", dimmed = !applicability.stylingApplies) { - TEXT_COLOR_SWATCHES.forEachIndexed { index, hex -> - StyleColorSwatch( - hex = hex, - label = TvSubtitleAppearanceOptions.fontColorLabel(hex), - selected = appearance.fontColor.equals(hex, ignoreCase = true), + HudSubtitlePage.Appearance -> { + HudEyebrow("Appearance") + HudFocusedSettingRow( + label = "Font", + value = TvSubtitleAppearanceOptions.fontFamilyLabel(appearance.fontFamily), enabled = stylingEnabled, - focusRequester = if (index == 0) subtitleTextColorFocus else null, - leftFocusRequester = subtitleTrackFocus, - ) { - onAppearanceChanged(appearance.copy(fontColor = hex)) + // Entry is Position when styling is out (image tracks): + // a disabled row cannot take focus. + focusRequester = pageEntryFocus.takeIf { stylingEnabled }, + modifier = Modifier.trackPageFocus(if (stylingEnabled) "page:entry" else "page:font"), + onActivate = { + onPresentPicker( + HudPickerPresentation( + title = "Subtitle font", + options = FONT_FAMILIES.map { HudPickerOption(it.first, it.second) }, + selectedId = appearance.fontFamily, + onSelect = { id -> + FONT_FAMILIES.firstOrNull { it.first == id }?.let { + onAppearanceChanged(appearance.copy(fontFamily = it.first)) + } + }, + ), + ) + }, + ) + HudSwatchRow( + label = "Text color", + swatches = TEXT_COLOR_SWATCHES, + selectedHex = appearance.fontColor, + swatchLabel = TvSubtitleAppearanceOptions::fontColorLabel, + enabled = stylingEnabled, + onSelect = { hex -> onAppearanceChanged(appearance.copy(fontColor = hex)) }, + ) + if (showTextOpacity) { + HudFocusedSettingRow( + label = "Text opacity", + value = "${appearance.textOpacity}%", + enabled = stylingEnabled, + onActivate = { + onPresentPicker( + HudPickerPresentation( + title = "Text opacity", + options = TvSubtitleAppearanceOptions.percentOptions( + TEXT_OPACITY_STEPS, + appearance.textOpacity, + ).map { HudPickerOption(it.toString(), "$it%") }, + selectedId = appearance.textOpacity.toString(), + onSelect = { id -> + id.toIntOrNull()?.let { + onAppearanceChanged(appearance.copy(textOpacity = it)) + } + }, + ), + ) + }, + ) } - } - } - StyleSection("Background color", dimmed = !applicability.stylingApplies) { - BACKGROUND_COLOR_SWATCHES.forEachIndexed { index, hex -> - StyleColorSwatch( - hex = hex, - label = TvSubtitleAppearanceOptions.backgroundColorLabel(hex), - selected = appearance.backgroundColor.equals(hex, ignoreCase = true), + HudFocusedSettingRow( + label = "Background", + value = BACKGROUND_STYLES.firstOrNull { it.first == appearance.backgroundStyle }?.second + ?: appearance.backgroundStyle.name, enabled = stylingEnabled, - focusRequester = if (index == 0) subtitleBackgroundColorFocus else null, - leftFocusRequester = subtitleTrackFocus, - ) { - onAppearanceChanged(appearance.copy(backgroundColor = hex)) + onActivate = { + onPresentPicker( + HudPickerPresentation( + title = "Subtitle background", + options = BACKGROUND_STYLES.map { HudPickerOption(it.first.name, it.second) }, + selectedId = appearance.backgroundStyle.name, + onSelect = { id -> + BACKGROUND_STYLES.firstOrNull { it.first.name == id }?.let { + onAppearanceChanged(appearance.copy(backgroundStyle = it.first)) + } + }, + ), + ) + }, + ) + HudSwatchRow( + label = "Background color", + swatches = BACKGROUND_COLOR_SWATCHES, + selectedHex = appearance.backgroundColor, + swatchLabel = TvSubtitleAppearanceOptions::backgroundColorLabel, + enabled = stylingEnabled, + onSelect = { hex -> onAppearanceChanged(appearance.copy(backgroundColor = hex)) }, + ) + HudFocusedSettingRow( + label = "Background opacity", + value = "${appearance.backgroundOpacity}%", + enabled = stylingEnabled, + onActivate = { + onPresentPicker( + HudPickerPresentation( + title = "Background opacity", + options = TvSubtitleAppearanceOptions.percentOptions( + OPACITY_STEPS, + appearance.backgroundOpacity, + ).map { HudPickerOption(it.toString(), "$it%") }, + selectedId = appearance.backgroundOpacity.toString(), + onSelect = { id -> + id.toIntOrNull()?.let { + onAppearanceChanged(appearance.copy(backgroundOpacity = it)) + } + }, + ), + ) + }, + ) + HudFocusedSettingRow( + label = "Outline", + value = onOffLabel(appearance.textOutline), + enabled = stylingEnabled, + showsChevron = false, + onActivate = { onAppearanceChanged(appearance.copy(textOutline = !appearance.textOutline)) }, + ) + if (appearance.textOutline) { + HudSwatchRow( + label = "Outline color", + swatches = OUTLINE_COLOR_SWATCHES, + selectedHex = appearance.textOutlineColor, + swatchLabel = TvSubtitleAppearanceOptions::outlineColorLabel, + enabled = stylingEnabled, + onSelect = { hex -> onAppearanceChanged(appearance.copy(textOutlineColor = hex)) }, + ) } + HudFocusedSettingRow( + label = "Position", + value = POSITIONS.firstOrNull { it.first == appearance.position }?.second + ?: appearance.position.name, + enabled = geometryEnabled, + focusRequester = pageEntryFocus.takeIf { !stylingEnabled }, + modifier = Modifier.trackPageFocus(if (!stylingEnabled) "page:entry" else "page:position"), + onActivate = { + onPresentPicker( + HudPickerPresentation( + title = "Subtitle position", + options = POSITIONS.map { HudPickerOption(it.first.name, it.second) }, + selectedId = appearance.position.name, + onSelect = { id -> + POSITIONS.firstOrNull { it.first.name == id }?.let { + onAppearanceChanged(appearance.copy(position = it.first)) + } + }, + ), + ) + }, + ) + applicability.note?.let { note -> HudNote(note) } } - } - if (appearance.textOutline) { - StyleSection("Outline color", dimmed = !applicability.stylingApplies) { - OUTLINE_COLOR_SWATCHES.forEachIndexed { index, hex -> - StyleColorSwatch( - hex = hex, - label = TvSubtitleAppearanceOptions.outlineColorLabel(hex), - selected = appearance.textOutlineColor.equals(hex, ignoreCase = true), - enabled = stylingEnabled, - focusRequester = if (index == 0) subtitleOutlineColorFocus else null, - leftFocusRequester = subtitleTrackFocus, - ) { - onAppearanceChanged(appearance.copy(textOutlineColor = hex)) + + HudSubtitlePage.Timing -> { + HudEyebrow("Timing") + HudFocusedSettingRow( + label = "Delay", + value = if (subtitleDelayEnabled) { + delayLabel(subtitleDelayMs) + } else { + "Unavailable for burned-in subtitles" + }, + enabled = enabled && subtitleDelayEnabled, + focusRequester = pageEntryFocus.takeIf { subtitleDelayEnabled }, + modifier = Modifier.trackPageFocus(if (subtitleDelayEnabled) "page:entry" else "page:delay"), + onActivate = { + onPresentPicker( + delayPicker( + title = "Subtitle delay", + current = subtitleDelayMs, + from = -2_000, + to = 2_000, + step = 100, + onSet = onSubtitleDelayChanged, + ), + ) + }, + ) + if (timing != null) { + // Swallow, not disable: a running sync or a refusal + // would otherwise strand focus on a row that just + // stopped being focusable. + HudFocusedSettingRow( + label = "Sync to audio", + value = subtitleTimingValue(timing), + enabled = enabled, + showsChevron = false, + focusRequester = pageEntryFocus.takeIf { !subtitleDelayEnabled }, + modifier = Modifier.trackPageFocus(if (!subtitleDelayEnabled) "page:entry" else "page:sync"), + onActivate = { + if (!timing.forbidden && timing.actionsEnabled && timing.canSync) { + onSyncSubtitle(timing.key) + } + }, + ) + if (timing.canReset) { + HudFocusedSettingRow( + label = "Reset timing", + value = "", + enabled = enabled, + showsChevron = false, + onActivate = { + if (!timing.forbidden && timing.actionsEnabled) onResetTiming(timing.key) + }, + ) } + HudSubtitleTimingDetail(timing) } } } + }, + ) +} + +/** "White · Box": the two appearance facts a viewer recognizes at a glance. */ +private fun subtitleAppearanceSummary(appearance: SubtitleAppearance): String { + val color = TvSubtitleAppearanceOptions.fontColorLabel(appearance.fontColor) + val background = when (appearance.backgroundStyle) { + SubtitleBackgroundStylePreset.None -> "No background" + SubtitleBackgroundStylePreset.Box -> "Box" + SubtitleBackgroundStylePreset.Shadow -> "Shadow" + SubtitleBackgroundStylePreset.Outline -> "Outline" + } + return "$color · $background" +} + +/** The Timing row's value: the delay in seconds, or what a sync is doing. */ +private fun subtitleTimingSummary( + delayMs: Int, + delayEnabled: Boolean, + timing: SubtitleTimingActions?, +): String = when { + timing != null && (timing.busy || timing.inProgress) -> "Working…" + delayEnabled -> delaySecondsLabel(delayMs) + timing != null -> subtitleTimingValue(timing) + else -> "Unavailable" +} + +/** 0 -> "0.0 s", 300 -> "+0.3 s", -1200 -> "−1.2 s" (true minus sign). */ +private fun delaySecondsLabel(valueMs: Int): String { + val seconds = String.format(java.util.Locale.ROOT, "%.1f", kotlin.math.abs(valueMs) / 1000.0) + return when { + valueMs > 0 -> "+$seconds s" + valueMs < 0 -> "−$seconds s" + else -> "$seconds s" + } +} + +/** + * A subtitle track's label split for a row: the language as the title, the + * tags worth scanning for (Forced, SDH, AI) as chips, the rest as detail. + * Labels arrive as "English — SRT · Forced · External" from + * [subtitleChoiceLabel], or "English • SRT" from a player-only track. + */ +internal data class TvSubtitleRowParts(val title: String, val chips: List<String>, val detail: String?) + +internal fun tvSubtitleRowParts(label: String): TvSubtitleRowParts { + val separator = listOf(" — ", " • ").firstOrNull { it in label } + val title = separator?.let { label.substringBefore(it) } ?: label + val facts = separator + ?.let { label.substringAfter(it) } + ?.split(" · ", " • ") + ?.map(String::trim) + ?.filter(String::isNotEmpty) + .orEmpty() + val chips = facts.mapNotNull { fact -> TvSubtitleChipNames[fact.lowercase(java.util.Locale.US)] } + val detail = facts + .filter { fact -> fact.lowercase(java.util.Locale.US) !in TvSubtitleChipNames } + .joinToString(" · ") + .ifBlank { null } + return TvSubtitleRowParts(title = title.trim(), chips = chips.distinct(), detail = detail) +} + +private val TvSubtitleChipNames = mapOf( + "forced" to "Forced", + "sdh" to "SDH", + "cc" to "CC", + "ai translation" to "AI", +) + +/** + * One inline subtitle track: a check column, the language with its chips, + * and the format and source on the right. Select switches to it at once. + */ +@Composable +private fun HudSubtitleTrackRow( + row: TvSubtitleHudRow, + syncStatus: String?, + enabled: Boolean, + focusRequester: FocusRequester?, + onFocused: () -> Unit, + onSelect: () -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + val bringIntoViewRequester = remember { BringIntoViewRequester() } + val scope = rememberCoroutineScope() + val isFocused by interactionSource.collectIsFocusedAsState() + val colors = hudRowColors(focused = isFocused, current = row.checked) + val parts = remember(row.label) { tvSubtitleRowParts(row.label) } + val detail = listOfNotNull( + row.status ?: syncStatus, + parts.detail, + ).joinToString(" · ").ifBlank { null } - // Android-only sidecar acquisition rows — kept from the old drawer. - if (onSearchSubtitles != null || onTranslateWithAi != null) { - Column( - modifier = Modifier.padding(top = 8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - if (onSearchSubtitles != null) { - HudActionRow( - label = "Search subtitles", - enabled = enabled, - leftFocusRequester = subtitleTrackFocus, - onClick = onSearchSubtitles, - ) - } - if (onTranslateWithAi != null) { - HudActionRow( - label = "Translate with AI", - enabled = enabled, - leftFocusRequester = subtitleTrackFocus, - onClick = onTranslateWithAi, - ) - } + // The checked row is revealed when the pane opens; focus reveals the rest. + LaunchedEffect(Unit) { if (row.checked) bringIntoViewRequester.bringIntoView() } + + Row( + modifier = Modifier + .fillMaxWidth() + .bringIntoViewRequester(bringIntoViewRequester) + .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) + .onFocusChanged { state -> + if (state.isFocused) { + onFocused() + scope.launch { bringIntoViewRequester.bringIntoView() } } } + .hudRow(colors.background) + .clickable(enabled = enabled, interactionSource = interactionSource, indication = null) { onSelect() } + .semantics { selected = row.checked } + .padding(horizontal = HudRowPadding, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + HudCheckSlot(checked = row.checked, tint = colors.content) + // The title side takes what the detail leaves; the detail is short + // ("SRT · External") and capped, so a long name ellipsizes first. + Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { + Text( + text = parts.title, + style = TvPlayerType.Row, + color = colors.content, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + parts.chips.forEach { chip -> + Spacer(modifier = Modifier.width(6.dp)) + HudChip(chip, onPaper = isFocused) + } + } + if (detail != null) { + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = detail, + style = TvPlayerType.RowDetail, + color = colors.detail, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.End, + modifier = Modifier.widthIn(max = 168.dp), + ) } } } +/** + * The subtitle sample: a 58dp strip that cuts a window in the panel, so the + * line is drawn over the picture it will appear on. Updates live as the + * appearance changes. + */ @Composable private fun HudSubtitlePreview( appearance: SubtitleAppearance, modifier: Modifier = Modifier, ) { val safe = appearance.sanitized() - val shape = RoundedCornerShape(6.dp) val decoration = TvSubtitleAppearanceOptions.previewDecoration(safe) - val fontSize = TvSubtitleAppearanceOptions.previewFontSizeSp(safe.fontSize).sp + val fontSize = TvSubtitleAppearanceOptions.previewFontSizeSp(safe.fontSize).coerceAtMost(22f).sp val fontFamily = TvSubtitleAppearanceOptions.previewFontFamily(safe.fontFamily) - val foreground = hexToColor(safe.fontColor) + val foreground = hexToColor(safe.fontColor).copy( + alpha = TvSubtitleAppearanceOptions.previewOpacityAlpha(safe.textOpacity, floor = 1), + ) val outline = hexToColor(safe.textOutlineColor) val backgroundColor = hexToColor(safe.backgroundColor).copy( alpha = if (safe.backgroundStyle == SubtitleBackgroundStylePreset.Box) { - safe.backgroundOpacity.coerceIn(0, 100) / 100f + TvSubtitleAppearanceOptions.previewOpacityAlpha(safe.backgroundOpacity, floor = 0) } else { 0f }, ) - val textStyle = MaterialTheme.typography.bodyLarge.copy( + val textStyle = TvPlayerType.Row.copy( fontSize = fontSize, lineHeight = fontSize * 1.18f, fontFamily = fontFamily, fontWeight = FontWeight.SemiBold, shadow = if (decoration.shadow) { - Shadow( - color = outline.copy(alpha = 0.9f), - offset = Offset(1f, 2f), - blurRadius = 5f, - ) + Shadow(color = outline.copy(alpha = 0.9f), offset = Offset(1f, 2f), blurRadius = 5f) } else { null }, ) + val window = LocalHudPanelWindow.current + DisposableEffect(window) { onDispose { window?.report(null) } } - Column( + Box( modifier = modifier + .padding(bottom = HudSectionGap) .fillMaxWidth() - .padding(top = 5.dp), - verticalArrangement = Arrangement.spacedBy(5.dp), + .height(58.dp) + .onGloballyPositioned { window?.report(it) } + .clip(RoundedCornerShape(HudPreviewCorner)) + // A light veil keeps the sample legible over a bright frame. + .background(Color.Black.copy(alpha = 0.14f)) + .padding(horizontal = 10.dp, vertical = 7.dp), + contentAlignment = TvSubtitleAppearanceOptions.previewAlignment(safe.position), ) { - Text( - text = "Example", - style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.82f), - ) + val boxed = safe.backgroundStyle == SubtitleBackgroundStylePreset.Box Box( modifier = Modifier - .fillMaxWidth() - .height(76.dp) - .clip(shape) - .background(DarkSurfaceElevated.copy(alpha = 0.72f)) - .padding(horizontal = 10.dp, vertical = 7.dp), - contentAlignment = TvSubtitleAppearanceOptions.previewAlignment(safe.position), + .clip(RoundedCornerShape(3.dp)) + .background(backgroundColor) + .padding(horizontal = if (boxed) 8.dp else 0.dp, vertical = if (boxed) 2.dp else 0.dp), + contentAlignment = Alignment.Center, ) { - Box( - modifier = Modifier - .clip(RoundedCornerShape(3.dp)) - .background(backgroundColor) - .padding( - horizontal = if (safe.backgroundStyle == SubtitleBackgroundStylePreset.Box) 7.dp else 0.dp, - vertical = if (safe.backgroundStyle == SubtitleBackgroundStylePreset.Box) 2.dp else 0.dp, - ), - contentAlignment = Alignment.Center, - ) { - if (decoration.outline) { - listOf(-1 to -1, -1 to 1, 1 to -1, 1 to 1).forEach { (x, y) -> - Text( - text = "Subtitle example", - color = outline, - style = textStyle.copy(shadow = null), - maxLines = 1, - modifier = Modifier.offset(x.dp, y.dp), - ) - } + if (decoration.outline) { + listOf(-1 to -1, -1 to 1, 1 to -1, 1 to 1).forEach { (x, y) -> + Text( + text = HudPreviewLine, + color = outline, + style = textStyle.copy(shadow = null), + maxLines = 1, + modifier = Modifier.offset(x.dp, y.dp), + ) } - Text( - text = "Subtitle example", - color = foreground, - style = textStyle, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) } + Text( + text = HudPreviewLine, + color = foreground, + style = textStyle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } } } -@OptIn(ExperimentalLayoutApi::class) +private const val HudPreviewLine = "Subtitles will look like this." + +/** + * A row of colour swatches under its label. Swatches stay inline, as on + * tvOS: a picker of colours would lose the at-a-glance palette. + */ +@OptIn(ExperimentalLayoutApi::class, ExperimentalComposeUiApi::class) @Composable -private fun StyleSection( - title: String, - dimmed: Boolean = false, - content: @Composable () -> Unit, +private fun HudSwatchRow( + label: String, + swatches: List<String>, + selectedHex: String, + swatchLabel: (String) -> String, + enabled: Boolean, + onSelect: (String) -> Unit, ) { Column( - verticalArrangement = Arrangement.spacedBy(5.dp), modifier = Modifier - .padding(top = 5.dp) + .fillMaxWidth() + .padding(horizontal = HudRowPadding, vertical = 6.dp) // Same 0.35 alpha HudFocusedSettingRow uses for a disabled row. - .graphicsLayer { alpha = if (dimmed) 0.35f else 1f }, + .graphicsLayer { alpha = if (enabled) 1f else 0.35f }, + verticalArrangement = Arrangement.spacedBy(7.dp), ) { - Text( - text = title, - style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.82f), - ) + Text(text = label, style = TvPlayerType.Row, maxLines = 1) + // Moving into the row lands on the current colour, not on whichever + // swatch happens to sit under the row the viewer came from. + val selectedFocus = remember { FocusRequester() } + val hasSelected = swatches.any { it.equals(selectedHex, ignoreCase = true) } FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(5.dp), - verticalArrangement = Arrangement.spacedBy(5.dp), - ) { content() } + modifier = Modifier + .fillMaxWidth() + .focusProperties { + enter = { if (hasSelected) selectedFocus else FocusRequester.Default } + } + .focusGroup(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + swatches.forEach { hex -> + val selected = selectedHex.equals(hex, ignoreCase = true) + StyleColorSwatch( + hex = hex, + label = swatchLabel(hex), + selected = selected, + enabled = enabled, + focusRequester = selectedFocus.takeIf { selected }, + ) { onSelect(hex) } + } + } } } @@ -2061,7 +2278,6 @@ private fun StyleColorSwatch( selected: Boolean, enabled: Boolean, focusRequester: FocusRequester? = null, - leftFocusRequester: FocusRequester? = null, onClick: () -> Unit, ) { val interactionSource = remember { MutableInteractionSource() } @@ -2069,23 +2285,24 @@ private fun StyleColorSwatch( val swatchColor = hexToColor(hex) val isLightSwatch = isLightHexColor(hex) val checkTint = if (isLightSwatch) Color.Black.copy(alpha = 0.78f) else Color.White - val ring = when { - isFocused && isLightSwatch -> Color.Black.copy(alpha = 0.78f) - isFocused -> Color.White - selected && isLightSwatch -> Color.Black.copy(alpha = 0.62f) - selected -> Color.White.copy(alpha = 0.85f) - else -> Color.White.copy(alpha = 0.25f) - } + // Focus is a Paper ring outside the swatch, so it reads on any colour. Box( modifier = Modifier - .size(24.dp) - .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) - .focusProperties { - if (leftFocusRequester != null) left = leftFocusRequester - } + .size(32.dp) + .border( + width = 2.dp, + color = if (isFocused) TvPlayerChrome.Paper else Color.Transparent, + shape = CircleShape, + ) + .padding(4.dp) .clip(CircleShape) .background(swatchColor) - .border(width = if (isFocused || selected) 2.dp else 1.dp, color = ring, shape = CircleShape) + .border( + width = 1.dp, + color = if (selected && !isLightSwatch) Color.White.copy(alpha = 0.85f) else Color.White.copy(alpha = 0.22f), + shape = CircleShape, + ) + .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) .clickable(enabled = enabled, interactionSource = interactionSource, indication = null) { onClick() } .semantics { contentDescription = if (selected) "$label, selected" else label @@ -2095,7 +2312,7 @@ private fun StyleColorSwatch( ) { if (selected) { Icon( - imageVector = Icons.Filled.Check, + imageVector = Icons.Rounded.Check, contentDescription = null, tint = checkTint, modifier = Modifier.size(14.dp), @@ -2104,6 +2321,16 @@ private fun StyleColorSwatch( } } +/** A line of explanation under the rows it qualifies. Never takes focus. */ +@Composable +private fun HudNote(text: String) { + Text( + text = text, + style = TvPlayerType.RowDetail, + modifier = Modifier.padding(horizontal = HudRowPadding, vertical = 6.dp), + ) +} + private fun hexToColor(hex: String): Color = try { val cleaned = if (hex.startsWith("#")) hex.drop(1) else hex Color(0xFF000000.toInt() or (cleaned.toLong(16).toInt() and 0x00FFFFFF)) @@ -2128,6 +2355,7 @@ private val FONT_FAMILIES = TvSubtitleAppearanceOptions.FONT_FAMILIES private val BACKGROUND_STYLES = TvSubtitleAppearanceOptions.BACKGROUND_STYLES private val POSITIONS = TvSubtitleAppearanceOptions.POSITIONS private val OPACITY_STEPS = TvSubtitleAppearanceOptions.OPACITY_STEPS +private val TEXT_OPACITY_STEPS = TvSubtitleAppearanceOptions.TEXT_OPACITY_STEPS private val TEXT_COLOR_SWATCHES = TvSubtitleAppearanceOptions.TEXT_COLOR_SWATCHES private val BACKGROUND_COLOR_SWATCHES = TvSubtitleAppearanceOptions.BACKGROUND_COLOR_SWATCHES private val OUTLINE_COLOR_SWATCHES = TvSubtitleAppearanceOptions.OUTLINE_COLOR_SWATCHES @@ -2141,46 +2369,140 @@ private fun delayLabel(valueMs: Int): String = } /** - * Full-width action row for HUD panes — a true click target: an explicit Select - * press is required (focus-driven commit would fire dialogs during plain - * traversal). + * Full-width action row for HUD panes: an icon, a label, a chevron. A true + * click target: an explicit Select press is required (focus-driven commit + * would fire dialogs during plain traversal). */ @Composable private fun HudActionRow( label: String, + icon: ImageVector, enabled: Boolean = true, - focusRequester: FocusRequester? = null, - leftFocusRequester: FocusRequester? = null, onClick: () -> Unit, ) { val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() - - val bg = if (isFocused) Color.White.copy(alpha = 0.94f) else Color.White.copy(alpha = 0.06f) - val fg = if (isFocused) Color.Black else Color.White.copy(alpha = 0.86f) + val colors = hudRowColors(focused = isFocused) Row( modifier = Modifier .fillMaxWidth() - .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) - .focusProperties { - if (leftFocusRequester != null) left = leftFocusRequester - } - .clip(RoundedCornerShape(12.dp)) - .background(bg) + .hudRow(colors.background) .clickable(enabled = enabled, interactionSource = interactionSource, indication = null) { onClick() } - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding(horizontal = HudRowPadding, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { + Icon(imageVector = icon, contentDescription = null, tint = colors.content, modifier = Modifier.size(19.dp)) + Spacer(modifier = Modifier.width(9.dp)) Text( text = label, - color = fg, - style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), + style = TvPlayerType.Row, + color = colors.content, + maxLines = 1, + overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) + HudChevron(colors.chevron) + } +} + +/** What a row looks like at rest, when it is the current choice, and focused. */ +private data class HudRowColors( + val background: Color, + val content: Color, + val detail: Color, + val chevron: Color, +) + +private fun hudRowColors(focused: Boolean, current: Boolean = false): HudRowColors = when { + focused -> HudRowColors( + background = TvPlayerChrome.Paper, + content = TvPlayerChrome.Ink, + detail = TvPlayerChrome.InkMuted, + chevron = TvPlayerChrome.Ink.copy(alpha = 0.45f), + ) + current -> HudRowColors( + background = TvPlayerChrome.Selected, + content = TvPlayerChrome.Paper, + detail = TvPlayerChrome.Graphite, + chevron = TvPlayerChrome.Faint, + ) + else -> HudRowColors( + background = Color.Transparent, + content = TvPlayerChrome.Paper, + detail = TvPlayerChrome.Graphite, + chevron = TvPlayerChrome.Faint, + ) +} + +/** The shared row ground: 34dp tall, a 10dp radius, filled by state. */ +private fun Modifier.hudRow(background: Color): Modifier = + heightIn(min = HudRowHeight) + .clip(RoundedCornerShape(HudRowCorner)) + .background(background) + +/** The leading check column, kept even when empty so labels line up. */ +@Composable +private fun HudCheckSlot(checked: Boolean, tint: Color) { + Box(modifier = Modifier.width(HudCheckColumn + 8.dp), contentAlignment = Alignment.CenterStart) { + if (checked) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = tint, + modifier = Modifier.size(19.dp), + ) + } } } +@Composable +private fun HudChevron(tint: Color) { + Icon( + imageVector = Icons.Rounded.ChevronRight, + contentDescription = null, + tint = tint, + modifier = Modifier + .padding(start = 4.dp) + .offset(x = 4.dp) + .size(18.dp), + ) +} + +/** + * Fades content at whichever edge it can scroll past, instead of cutting a + * row in half at the panel's edge. Nothing fades when nothing scrolls. + */ +private fun Modifier.hudFadingEdges( + state: ScrollableState, + top: Dp = 16.dp, + bottom: Dp = 36.dp, +): Modifier = graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + if (state.canScrollBackward) { + drawRect( + brush = Brush.verticalGradient(0f to Color.Transparent, 1f to Color.Black, endY = top.toPx()), + size = size.copy(height = top.toPx()), + blendMode = BlendMode.DstIn, + ) + } + if (state.canScrollForward) { + val height = bottom.toPx() + drawRect( + brush = Brush.verticalGradient( + 0f to Color.Black, + 1f to Color.Transparent, + startY = size.height - height, + endY = size.height, + ), + topLeft = Offset(0f, size.height - height), + size = size.copy(height = height), + blendMode = BlendMode.DstIn, + ) + } + } + private fun PlayerStatsSnapshot.hasHudRows(): Boolean = hudRows().isNotEmpty() || healthRows().isNotEmpty() || !events.isEmpty @@ -2223,25 +2545,25 @@ private fun HudEmptyStatePane(message: String, modifier: Modifier = Modifier) { Box( modifier = modifier .fillMaxWidth() - .heightIn(min = HudCardMinHeight - HudPanelPadding * 2) + .heightIn(min = 110.dp) .padding(18.dp), contentAlignment = Alignment.Center, ) { - Text( - text = message, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Text(text = message, style = TvPlayerType.Body) } } /** - * Chapters pane — renders [VersionChapter]s from the active FileVersion as a - * focus-driven picker. Selecting a chapter seeks the player to its start time. + * Chapters pane — the active FileVersion's chapters as a table: number, + * title, start, length. The current chapter carries a play glyph, a tint and + * a progress line, and the list opens on it with focus there, so the chapters + * around "now" are one press away. Selecting a chapter seeks to its start. */ @Composable private fun HudChaptersPane( chapters: List<VersionChapter>, + positionSec: Double, + durationSec: Double, onSelectChapter: (Int) -> Unit, entryFocusRequester: FocusRequester, modifier: Modifier = Modifier, @@ -2250,68 +2572,155 @@ private fun HudChaptersPane( HudEmptyStatePane("No chapters in this title", modifier) return } - LazyColumn( - modifier = modifier - .fillMaxWidth() - .heightIn(max = 210.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - itemsIndexed( - chapters, - key = { _, chapter -> "${chapter.index}:${chapter.startSeconds}:${chapter.title}" }, - contentType = { _, _ -> "hud-chapter" }, - ) { idx, ch -> - HudChapterRow( - chapter = ch, - onSelect = { onSelectChapter(idx) }, - focusRequester = entryFocusRequester.takeIf { idx == 0 }, - ) + val currentIndex = chapters.indexOfLast { it.startSeconds <= positionSec }.coerceAtLeast(0) + val current = chapters[currentIndex] + val currentEnd = chapterEndSeconds(chapters, currentIndex, durationSec) + // Three chapters of context above the current one, as in the mockup. + val listState = rememberLazyListState(initialFirstVisibleItemIndex = (currentIndex - 3).coerceAtLeast(0)) + + Column(modifier = modifier.fillMaxWidth()) { + HudEyebrow( + text = if (chapters.size == 1) "1 chapter" else "${chapters.size} chapters", + trailing = if (currentEnd > positionSec) { + "${chapterTitle(current)} · ${formatTime(currentEnd - positionSec)} left in this chapter" + } else { + null + }, + ) + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxWidth() + .hudFadingEdges(listState, top = 30.dp, bottom = 40.dp), + ) { + itemsIndexed( + chapters, + key = { _, chapter -> "${chapter.index}:${chapter.startSeconds}:${chapter.title}" }, + contentType = { _, _ -> "hud-chapter" }, + ) { idx, ch -> + val end = chapterEndSeconds(chapters, idx, durationSec) + HudChapterRow( + number = idx + 1, + chapter = ch, + lengthSec = (end - ch.startSeconds).coerceAtLeast(0.0), + progress = if (idx == currentIndex && end > ch.startSeconds) { + ((positionSec - ch.startSeconds) / (end - ch.startSeconds)).toFloat().coerceIn(0f, 1f) + } else { + null + }, + onSelect = { onSelectChapter(idx) }, + focusRequester = entryFocusRequester.takeIf { idx == currentIndex }, + ) + } } } } +/** A chapter's end: its own end time, else the next start, else the runtime. */ +private fun chapterEndSeconds(chapters: List<VersionChapter>, index: Int, durationSec: Double): Double { + val chapter = chapters[index] + return when { + chapter.endSeconds > chapter.startSeconds -> chapter.endSeconds + index + 1 < chapters.size -> chapters[index + 1].startSeconds + else -> durationSec + } +} + +private fun chapterTitle(chapter: VersionChapter): String = + chapter.title.ifBlank { "Chapter ${chapter.index + 1}" } + @Composable private fun HudChapterRow( + number: Int, chapter: VersionChapter, + lengthSec: Double, + /** Set on the current chapter only: how far into it playback is. */ + progress: Float?, onSelect: () -> Unit, focusRequester: FocusRequester? = null, ) { val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() + val isCurrent = progress != null + val colors = hudRowColors(focused = isFocused, current = isCurrent) + val numberColor = if (isFocused) TvPlayerChrome.InkMuted else TvPlayerChrome.Graphite - val bg = if (isFocused) Color.White.copy(alpha = 0.12f) else Color.Transparent - val fg = if (isFocused) Color.White else Color.White.copy(alpha = 0.86f) - - Row( + Box( modifier = Modifier .fillMaxWidth() .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) - .clip(RoundedCornerShape(12.dp)) - .background(bg) + .heightIn(min = 35.dp) + .clip(RoundedCornerShape(HudRowCorner)) + .background(colors.background) .clickable(enabled = true, interactionSource = interactionSource, indication = null) { onSelect() } - .padding(horizontal = 14.dp, vertical = 9.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + .semantics { selected = isCurrent }, ) { - Text( - text = formatTime(chapter.startSeconds), - color = fg.copy(alpha = 0.72f), - style = MaterialTheme.typography.bodyMedium.copy( - fontSize = HudBodyTextSize, - lineHeight = HudBodyLineHeight, - fontWeight = FontWeight.Medium, - ), - ) - Text( - text = chapter.title.ifBlank { "Chapter ${chapter.index + 1}" }, - color = fg, - style = MaterialTheme.typography.bodyLarge.copy( - fontSize = HudBodyTextSize, - lineHeight = HudBodyLineHeight, - fontWeight = FontWeight.SemiBold, - ), - modifier = Modifier.weight(1f), - ) + Row( + modifier = Modifier + .matchParentSize() + .padding(horizontal = HudRowPadding), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(modifier = Modifier.width(42.dp)) { + if (isCurrent && !isFocused) { + Icon( + imageVector = Icons.Rounded.PlayArrow, + contentDescription = "Now playing", + tint = TvPlayerChrome.Paper, + modifier = Modifier.size(18.dp), + ) + } else { + Text( + text = number.toString().padStart(2, '0'), + style = TvPlayerType.Figures.copy(fontWeight = FontWeight.SemiBold), + color = numberColor, + ) + } + } + Text( + text = chapterTitle(chapter), + style = TvPlayerType.Row.copy(fontWeight = if (isCurrent) FontWeight.SemiBold else FontWeight.Medium), + color = colors.content, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = formatTime(chapter.startSeconds), + style = TvPlayerType.Figures, + color = colors.detail, + textAlign = TextAlign.End, + maxLines = 1, + modifier = Modifier.width(70.dp), + ) + Text( + text = formatTime(lengthSec), + style = TvPlayerType.Figures, + color = colors.detail, + textAlign = TextAlign.End, + maxLines = 1, + modifier = Modifier.width(62.dp), + ) + } + if (progress != null) { + // Under the title column only, so it reads as the chapter's own bar. + Box( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(start = HudRowPadding + 42.dp, end = HudRowPadding + 140.dp, bottom = 3.dp) + .fillMaxWidth() + .height(2.dp) + .clip(RoundedCornerShape(1.dp)) + .background(if (isFocused) TvPlayerChrome.Ink.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.14f)), + ) { + Box( + modifier = Modifier + .fillMaxWidth(progress) + .height(2.dp) + .background(if (isFocused) TvPlayerChrome.Ink else TvPlayerChrome.Paper), + ) + } + } } } @@ -2324,6 +2733,10 @@ internal data class HudPickerOption( val id: String, val label: String, val colorHex: String? = null, + /** A short second line under the label, such as a subtitle's sync status. */ + val detail: String? = null, + /** A short value at the row's end, such as "1×" beside "Normal" or a bitrate. */ + val trailing: String? = null, ) /** @@ -2332,6 +2745,8 @@ internal data class HudPickerOption( */ internal data class HudPickerPresentation( val title: String, + /** Names where the picker came from ("Video"); the HUD fills in its tab. */ + val eyebrow: String? = null, val options: List<HudPickerOption>, val selectedId: String, val focusedId: String = selectedId, @@ -2340,6 +2755,51 @@ internal data class HudPickerPresentation( val onSelect: (String) -> Unit, ) +/** + * The Timing row's value: the refusal or failure when there is one, else the + * sync state. The refusal is shortened to fit the row; the line under it says + * it in full. + */ +private fun subtitleTimingValue(timing: SubtitleTimingActions): String = when { + timing.forbidden -> "Not allowed" + timing.busy -> "Working…" + else -> timing.error ?: timing.statusLabel ?: "Not synced" +} + +/** + * What the Timing row cannot fit: a running sync's progress and phase, the + * last result, what a sync does, or why the actions are missing. It never + * takes focus. + */ +@Composable +private fun HudSubtitleTimingDetail(timing: SubtitleTimingActions) { + val modifier = Modifier.padding(horizontal = HudRowPadding, vertical = 4.dp) + val percent = timing.percent + if (timing.inProgress && percent != null) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) { + TvSyncProgressBar(percent = percent) + timing.phaseLabel?.let { HudTimingText(it) } + } + } + val message = when { + timing.forbidden -> SubtitleTimingActions.FORBIDDEN_MESSAGE to null + timing.error != null -> timing.error.orEmpty() to Color(0xFFFCA5A5) + timing.result != null -> timing.result?.let { it.text to if (it.warning) Color(0xFFFDE68A) else null } + else -> timing.note?.let { it to null } + } + message?.let { (text, color) -> HudTimingText(text, color, modifier) } +} + +@Composable +private fun HudTimingText(text: String, color: Color? = null, modifier: Modifier = Modifier) { + Text( + text = text, + color = color ?: TvPlayerChrome.Graphite, + style = TvPlayerType.RowDetail, + modifier = modifier, + ) +} + private fun delayPicker( title: String, current: Int, @@ -2359,10 +2819,9 @@ private fun delayPicker( } /** - * Drill-in setting row: label + current value + chevron, with an inverted - * capsule on focus (white fill / dark text). Activating (Select) runs - * [onActivate], which the caller wires to present a [HudPickerDialog]. Mirrors - * tvOS `HUDFocusedSettingRow`. + * Drill-in setting row: label, current value, chevron, inverting to Paper on + * focus. Activating (Select) runs [onActivate], which the caller wires to + * present a [HudPickerDialog] or a page. Mirrors tvOS `HUDFocusedSettingRow`. */ @Composable internal fun HudFocusedSettingRow( @@ -2377,13 +2836,12 @@ internal fun HudFocusedSettingRow( * [focusRequester] so a pane can keep its own handle on the row too. */ entryFocusRequester: FocusRequester? = null, - leftFocusRequester: FocusRequester? = null, - rightFocusRequester: FocusRequester? = null, /** * False for a toggle row: Select flips the value in place, so there is no * drill-in to advertise. Mirrors tvOS HUDToggleRow (showsChevron: false). */ showsChevron: Boolean = true, + modifier: Modifier = Modifier, onActivate: () -> Unit, ) { val interactionSource = remember { MutableInteractionSource() } @@ -2394,25 +2852,17 @@ internal fun HudFocusedSettingRow( val selfFocusRequester = remember { FocusRequester() } val registerPickerReturnFocus = LocalHudPickerReturnFocus.current - val bg = if (isFocused) Color.White else Color.Transparent - val labelColor = if (isFocused) Color.Black else Color.White - val valueColor = if (isFocused) Color.Black.copy(alpha = 0.78f) else Color.White.copy(alpha = 0.72f) - val chevronColor = if (isFocused) Color.Black.copy(alpha = 0.55f) else Color.White.copy(alpha = 0.45f) + val colors = hudRowColors(focused = isFocused) val rowAlpha = if (enabled) 1f else 0.35f Row( - modifier = Modifier + modifier = modifier .fillMaxWidth() .focusRequester(selfFocusRequester) .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) .then(if (entryFocusRequester != null) Modifier.focusRequester(entryFocusRequester) else Modifier) - .focusProperties { - if (leftFocusRequester != null) left = leftFocusRequester - if (rightFocusRequester != null) right = rightFocusRequester - } .graphicsLayer { alpha = rowAlpha } - .clip(RoundedCornerShape(8.dp)) - .background(bg) + .hudRow(colors.background) .clickable( enabled = enabled, interactionSource = interactionSource, @@ -2421,7 +2871,7 @@ internal fun HudFocusedSettingRow( registerPickerReturnFocus?.invoke(selfFocusRequester) onActivate() } - .padding(horizontal = 10.dp, vertical = 8.dp), + .padding(horizontal = HudRowPadding, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { // A single weighted spacer used to be the only thing between label and @@ -2434,12 +2884,8 @@ internal fun HudFocusedSettingRow( // and ellipsizes when the row is cramped. Text( text = label, - color = labelColor, - style = MaterialTheme.typography.bodyLarge.copy( - fontSize = HudBodyTextSize, - lineHeight = HudBodyLineHeight, - fontWeight = FontWeight.Medium, - ), + style = TvPlayerType.Row, + color = colors.content, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -2447,7 +2893,7 @@ internal fun HudFocusedSettingRow( Row( modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(5.dp, Alignment.End), + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End), ) { if (colorHex != null) { Box( @@ -2464,35 +2910,26 @@ internal fun HudFocusedSettingRow( // fill = false keeps short values grouped against the right edge. Text( text = value, - color = valueColor, - style = MaterialTheme.typography.bodyLarge.copy( - fontSize = HudBodyTextSize, - lineHeight = HudBodyLineHeight, - fontWeight = FontWeight.SemiBold, - ), + style = TvPlayerType.Value, + color = colors.detail, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false), ) - if (showsChevron) { - Icon( - imageVector = Icons.Filled.ChevronRight, - contentDescription = null, - tint = chevronColor, - modifier = Modifier.size(11.dp), - ) - } + if (showsChevron) HudChevron(colors.chevron) } } } /** - * Centered modal option list mirroring tvOS `HUDPickerDialog`: a dark card with - * a title and a scrollable list of options; the selected option shows a - * checkmark, focus auto-lands on the selected option and scrolls it into view, - * Select commits the option + closes, and Back closes (handled by the HUD's - * key handler). Each option commits on explicit Select (click), not focus, so - * D-pad traversal doesn't change the value. + * Centered modal option list mirroring tvOS `HUDPickerDialog`: an opaque card + * with an eyebrow naming where it came from, a title, and the options, each + * with a leading check column so the check stays visible on a focused row. + * The card sizes to its options up to the screen, and scrolls past that. + * Focus auto-lands on the selected option and scrolls it into view, Select + * commits the option and closes, and Back closes (handled by the HUD's key + * handler). Options commit on explicit Select, not focus, so D-pad traversal + * doesn't change the value. */ @OptIn(ExperimentalComposeUiApi::class) @Composable @@ -2502,12 +2939,13 @@ internal fun HudPickerDialog( modifier: Modifier = Modifier, ) { val options = presentation.options + // An action menu has no current choice, so nothing is marked selected. val selectedIndex = options.indexOfFirst { it.id == presentation.selectedId } - .coerceAtLeast(0) val focusedIndex = options.indexOfFirst { it.id == presentation.focusedId } .takeIf { it >= 0 } - ?: selectedIndex + ?: selectedIndex.coerceAtLeast(0) val focusRequester = remember { FocusRequester() } + val listScroll = rememberScrollState() // Auto-focus the selected option on appear. Because every option is in the // focus graph, Compose's scroll container brings that focused row onscreen. @@ -2520,32 +2958,31 @@ internal fun HudPickerDialog( modifier = modifier .then(optionFocusModifier) .width(360.dp) - .heightIn(max = 220.dp) - .clip(RoundedCornerShape(14.dp)) - .background(DarkSurfaceElevated.copy(alpha = 0.98f)) - .border(0.5.dp, Color.White.copy(alpha = 0.18f), RoundedCornerShape(14.dp)) + .heightIn(max = 470.dp) + .tvDialogSurface(RoundedCornerShape(HudPanelCorner)) .focusGroup() .focusProperties { exit = { FocusRequester.Cancel } } - .padding(horizontal = 18.dp, vertical = 14.dp), + .padding(start = 14.dp, end = 14.dp, top = 20.dp, bottom = 14.dp), ) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - text = presentation.title, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.headlineSmall.copy( - fontSize = HudTitleTextSize, - lineHeight = HudTitleLineHeight, - fontWeight = FontWeight.SemiBold, - ), - ) + Column { + Column(modifier = Modifier.padding(start = HudRowPadding, end = HudRowPadding, bottom = 12.dp)) { + presentation.eyebrow?.let { eyebrow -> + Text( + text = eyebrow.uppercase(), + style = TvPlayerType.Eyebrow, + modifier = Modifier.padding(bottom = 4.dp), + ) + } + Text(text = presentation.title, style = TvPlayerType.DialogTitle) + } // Fully compose this small modal list so every D-pad destination is // present in the focus graph. A lazy list made below-fold rows look // like the end of the modal and either trapped or leaked focus. Column( modifier = Modifier - .heightIn(max = 160.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(4.dp), + .weight(1f, fill = false) + .hudFadingEdges(listScroll) + .verticalScroll(listScroll), ) { options.forEachIndexed { index, option -> key(option.id) { @@ -2578,23 +3015,12 @@ private fun HudPickerOptionRow( val bringIntoViewRequester = remember { BringIntoViewRequester() } val scope = rememberCoroutineScope() val isFocused by interactionSource.collectIsFocusedAsState() - - val bg = when { - isFocused -> Color.White - isSelected -> Color.White.copy(alpha = 0.14f) - else -> Color.Transparent - } - val fg = when { - isFocused -> Color.Black - isSelected -> Color.White - else -> Color.White - } + val colors = hudRowColors(focused = isFocused, current = isSelected) Row( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(bg) + .hudRow(colors.background) .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) .bringIntoViewRequester(bringIntoViewRequester) .onFocusChanged { state -> @@ -2605,37 +3031,46 @@ private fun HudPickerOptionRow( } .clickable(interactionSource = interactionSource, indication = null) { onSelect() } .semantics { this.selected = isSelected } - .padding(horizontal = 10.dp, vertical = 8.dp), + .padding(horizontal = HudRowPadding, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(7.dp), ) { + HudCheckSlot(checked = isSelected, tint = colors.content) if (option.colorHex != null) { Box( modifier = Modifier - .size(11.dp) + .padding(end = 8.dp) + .size(12.dp) .clip(CircleShape) .background(hexToColor(option.colorHex)) .border(0.5.dp, Color.White.copy(alpha = 0.45f), CircleShape), ) } - Text( - text = option.label, - color = fg, - style = MaterialTheme.typography.bodyLarge.copy( - fontSize = HudBodyTextSize, - lineHeight = HudBodyLineHeight, - fontWeight = FontWeight.Medium, - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - if (isSelected) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = "Selected", - tint = fg, - modifier = Modifier.size(10.dp), + Column(modifier = Modifier.weight(1f)) { + Text( + text = option.label, + style = TvPlayerType.Row, + color = colors.content, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + option.detail?.let { detail -> + Text( + text = detail, + style = TvPlayerType.RowDetail, + color = colors.detail, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp), + ) + } + } + option.trailing?.let { trailing -> + Text( + text = trailing, + style = TvPlayerType.Value, + color = colors.detail, + maxLines = 1, + modifier = Modifier.padding(start = 12.dp), ) } } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScreen.kt index d3eb5ebff4..b3e3fe0031 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScreen.kt @@ -2,6 +2,7 @@ package org.prairieserver.prairie.tv.ui.screens.player +import org.prairieserver.prairie.playback.SubtitleSyncNotice import android.app.Activity import android.content.ComponentName import android.graphics.Rect @@ -20,9 +21,12 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio @@ -32,11 +36,15 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bedtime +import androidx.compose.material.icons.filled.Shuffle import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -50,20 +58,25 @@ import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupProperties import androidx.compose.ui.zIndex import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver @@ -89,6 +102,7 @@ import kotlin.math.roundToInt import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import org.koin.compose.koinInject @@ -114,6 +128,9 @@ import org.prairieserver.prairie.common.player.PlayWhenReadyReconciliationGate import org.prairieserver.prairie.common.player.PlaybackCapabilityDetector import org.prairieserver.prairie.common.player.PlaybackPreflightListener import org.prairieserver.prairie.common.player.PlayerNotice +import org.prairieserver.prairie.common.player.video.markerSkipTarget +import org.prairieserver.prairie.common.player.video.MarkerSkipTarget +import org.prairieserver.prairie.common.player.video.ManualMarkerKind import org.prairieserver.prairie.common.player.SessionState import org.prairieserver.prairie.common.player.PrairiePlaybackService import org.prairieserver.prairie.common.player.SleepTimerState @@ -121,12 +138,14 @@ import org.prairieserver.prairie.common.player.SubtitleManager import org.prairieserver.prairie.common.player.VideoPlayerMediaSpec import org.prairieserver.prairie.common.player.subtitlesForVideoMediaMount import org.prairieserver.prairie.common.player.videoMountToken +import org.prairieserver.prairie.common.player.backend.VideoPlaybackBackend import org.prairieserver.prairie.common.player.backend.VideoPlaybackBackendFactory import org.prairieserver.prairie.common.player.validatedColorRangeFallback import org.prairieserver.prairie.common.player.video.PlaybackRuntimeCorrectionMetrics import org.prairieserver.prairie.common.player.video.PlaybackStartupStallDetector import org.prairieserver.prairie.common.player.video.PostResumeVideoStallDetector import org.prairieserver.prairie.common.player.video.VideoPlayerTrackEntry +import org.prairieserver.prairie.common.ui.components.ThumbhashImage import org.prairieserver.prairie.playback.subtitleLabelIndicatesHearingImpaired import org.prairieserver.prairie.domain.player.IntroAutoSkipState import org.prairieserver.prairie.model.playback.PlaybackExecutionPlan @@ -135,27 +154,25 @@ import org.prairieserver.prairie.model.playback.executableMedia3ClientTransforma import org.prairieserver.prairie.model.playback.activeOriginalHttpClaims import org.prairieserver.prairie.model.settings.SubtitleAppearance import org.prairieserver.prairie.model.settings.SubtitlePositionPreset -import org.prairieserver.prairie.model.watchtogether.RoomPlaybackState import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.watchtogether.RoomTransportIntent +import org.prairieserver.prairie.watchtogether.roomTransportAuthorized import org.prairieserver.prairie.player.formatSubtitleTrackDisplayLabel import org.prairieserver.prairie.tv.R import org.prairieserver.prairie.tv.cast.PrairieCastVolumeState import org.prairieserver.prairie.tv.cast.TvPrairieCastPlayerAdapter import org.prairieserver.prairie.tv.cast.TvPrairieCastReceiver +import org.prairieserver.prairie.tv.data.preferences.PlaybackQuality import org.prairieserver.prairie.tv.ui.components.TvErrorScreen import org.prairieserver.prairie.tv.ui.components.TvLoadingScreen -import org.prairieserver.prairie.tv.ui.components.rememberTvDialogInitialFocus +import org.prairieserver.prairie.tv.ui.components.TvMediaCard import org.prairieserver.prairie.tv.ui.focus.TvContentInitialFocusMaxAttempts import org.prairieserver.prairie.tv.ui.focus.TvFocusLog import org.prairieserver.prairie.tv.ui.focus.claimFocusOrReport import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved +import org.prairieserver.prairie.tv.ui.theme.TvSmoothBringIntoViewSpec private const val CONTROLS_AUTO_HIDE_MS = 5_000L -// slow) under NonCancellable while holding engineSwitchMutex, so this must be -// long enough not to abort a legitimately slow init, yet short enough to -// recover from a wedged init instead of stranding a permanent black screen and -// blocking every later switch behind the held mutex. 25s splits that range. -private const val ENGINE_SWITCH_TIMEOUT_MS = 25_000L // Pre-revision-9 relative seeks: 10s back, 30s forward, matching tvOS // (gobackward.10 / goforward.30). A server with the profile-wide // player.video_skip_* settings replaces them (see resolvedSkip*Ms below). @@ -249,10 +266,19 @@ fun TvPlayerScreen( contentId: String, libraryId: Int? = null, onExit: () -> Unit, + // Watch Party host Stop: the room is back in its lobby and this device is + // still a member. Receives the room id. + onReturnToWatchPartyLobby: (String) -> Unit, + // The Watch Party ended under this player (or its membership is gone); + // the hub explains why and offers Rejoin. + onWatchPartyEnded: () -> Unit = onExit, + // Up Next On Deck pick: replace this player with one for that item. + onPlayOnDeckItem: (contentId: String, itemType: String) -> Unit = { _, _ -> }, preferredFileId: Int? = null, preferredQuality: String? = null, - // Watch Together room binding. When non-null, a [TvRoomSyncController] - // binds this player to the synced room for the lifetime of the screen. + // Watch Party room. When non-null, [WatchPartyPlayback] binds this player + // to the room for the lifetime of the screen and the room's playback + // context, not the route's content, decides what plays. roomId: String? = null, resumePositionOverride: Double? = null, // Pre-playback track selections chosen on the detail screen (null = auto; @@ -267,11 +293,24 @@ fun TvPlayerScreen( // Consecutive auto-advance count (pass-out protection); 0 = manual start. autoAdvanceCount: Int = 0, episodeSelectionHandoff: org.prairieserver.prairie.common.player.video.EpisodeSelectionHandoff? = null, + // False while Navigation fades this screen in or out. True Black Bars + // keeps the black plate meanwhile (see `clearBars`). + navigationSettled: Boolean = true, + // The running shuffle this item is a pick of; picks play from the beginning. + shuffleId: String? = null, + // The phone launch that opened this player, checked when it registers. + castLaunchId: String? = null, + // Called when that launch went stale before this player registered. It + // runs stopPlayback and leaves only while this player is still on top. + onStaleCastLaunch: (stopPlayback: () -> Unit) -> Unit = { stopPlayback -> + stopPlayback() + onExit() + }, // Scope the ViewModel key by fileId too so switching 4K <-> 1080p on // the detail screen and replaying actually spins up a fresh player // session instead of reusing the cached one bound to the first fileId. viewModel: TvPlayerViewModel = koinViewModel( - key = "tv-player-$contentId-$libraryId-${preferredFileId ?: "auto"}-${preferredQuality ?: "quality-auto"}-${roomId ?: "solo"}-${resumePositionOverride ?: "server"}-${initialAudioTrackIndex ?: "a"}-${initialSubtitleTrackIndex ?: "s"}", + key = "tv-player-$contentId-$libraryId-${preferredFileId ?: "auto"}-${preferredQuality ?: "quality-auto"}-${roomId ?: "solo"}-${resumePositionOverride ?: "server"}-${initialAudioTrackIndex ?: "a"}-${initialSubtitleTrackIndex ?: "s"}-${shuffleId ?: "in-order"}", parameters = { parametersOf( TvPlayerLaunchArgs( @@ -287,6 +326,7 @@ fun TvPlayerScreen( initialSubtitleAutoResolved = initialSubtitleAutoResolved, autoAdvanceCount = autoAdvanceCount, episodeSelectionHandoff = episodeSelectionHandoff, + shuffleId = shuffleId, ), ) }, @@ -322,6 +362,7 @@ fun TvPlayerScreen( // swap. Mirrors phone PlayerScreen. The MediaController is kept for transport. val sessionPlayer by activePlayerHolder.player.collectAsState() val notice by viewModel.notice.collectAsState() + val hudSubtitleSync by viewModel.hudSubtitleSync.collectAsState() val remoteMessage by viewModel.remoteMessage.collectAsState() LaunchedEffect(remoteMessage?.id) { if (remoteMessage != null) { @@ -331,9 +372,11 @@ fun TvPlayerScreen( } val sessionState by viewModel.sessionState.collectAsState() val introSkipState by viewModel.introSkipState.collectAsState() + val activeMarkerSkipTarget by viewModel.manualMarkerSkipTarget.collectAsState() val introSkipCountdownRun by viewModel.introSkipCountdownRun.collectAsState() val introSkipTimerRunning by viewModel.introSkipTimerRunning.collectAsState() val subtitleAppearance by viewModel.subtitleAppearance.collectAsState() + val subtitleTextOpacitySupported by viewModel.subtitleTextOpacitySupported.collectAsState() val playbackSpeed by viewModel.playbackSpeed.collectAsState() val sleepTimerState by viewModel.sleepTimerState.collectAsState() val introSkipMode by viewModel.introSkipMode.collectAsState() @@ -343,15 +386,12 @@ fun TvPlayerScreen( val hdrEnabled by viewModel.hdrEnabled.collectAsState() val dolbyVisionEnabled by viewModel.dolbyVisionEnabled.collectAsState() val forceHdrPassthrough by viewModel.forceHdrPassthrough.collectAsState() + val trueBlackBars by viewModel.trueBlackBars.collectAsState() val dolbyVisionSwitchInFlight by viewModel.dolbyVisionSwitchInFlight.collectAsState() val subtitleSearch by viewModel.subtitleSearch.collectAsState() val aiTranslate by viewModel.aiTranslate.collectAsState() val lifecycleOwner = LocalLifecycleOwner.current val latestOnExit by rememberUpdatedState(onExit) - val latestSiloCastPlaybackSpeed by rememberUpdatedState(playbackSpeed) - val latestSiloCastSubtitleDelayMs by rememberUpdatedState(subtitleDelayMs) - val latestSiloCastHdrEnabled by rememberUpdatedState(hdrEnabled) - val latestSiloCastSubtitleAppearance by rememberUpdatedState(subtitleAppearance) val context = LocalContext.current val hdrDisplayController = remember { HdrDisplayController() } // Bind the playback display before anything plans: the ViewModel's @@ -388,6 +428,9 @@ fun TvPlayerScreen( // the inflated subtitleView after the AndroidView factory runs. Mirrors // the phone PlayerScreen's `playerViewRef` pattern. var playerViewRef by remember { mutableStateOf<PlayerView?>(null) } + // Latched on the first rendered frame and kept for this screen's life, so + // remounts (subtitle changes, recovery) don't repaint the bars. + var pictureShown by remember { mutableStateOf(false) } var idleOverlayFocusRequest by remember { mutableStateOf(TvIdleOverlayFocusRequest()) } val cleanPlaybackSeekScope = rememberCoroutineScope() var pendingCleanSeekDirection by remember { mutableStateOf(0) } @@ -424,39 +467,17 @@ fun TvPlayerScreen( var playerRootBounds by remember { mutableStateOf<Rect?>(null) } var pictureInPictureSourceRect by remember { mutableStateOf<Rect?>(null) } - // Watch Together binding. Built once per roomId; null for solo playback. - // The process RoomSession owns the WS; this controller owns only the - // screen's RoomSyncEngine and requests durable teardown on explicit leave. - val watchTogetherRepository: org.prairieserver.prairie.repository.WatchTogetherRepository = koinInject() - val roomSession: org.prairieserver.prairie.watchtogether.RoomSession = koinInject() - val roomScope = rememberCoroutineScope() - val roomController = remember(roomId) { - roomId?.takeIf { it.isNotBlank() }?.let { id -> - TvRoomSyncController( - roomId = id, - repository = watchTogetherRepository, - roomSession = roomSession, - viewModel = viewModel, - scope = roomScope, - ) - } - } - DisposableEffect(roomController) { - roomController?.start() - // Repo teardown happens on explicit leave (Leave affordance) or - // room_closed; only this replaceable controller's child jobs are - // canceled on disposal. - onDispose { roomController?.dispose() } - } - val roomSnapshot by (roomController?.room ?: kotlinx.coroutines.flow.MutableStateFlow(null)) - .collectAsState() - val roomClosedReason by (roomController?.closedReason ?: kotlinx.coroutines.flow.MutableStateFlow(null)) - .collectAsState() - var showLeaveDialog by remember { mutableStateOf(false) } + // Watch Party binding. Built once per roomId; null for solo playback. + // The process RoomSession owns the room socket; this screen owns only its + // playback binding, and disposing it never leaves the room. + val watchParty = rememberTvWatchParty(roomId, viewModel) + val roomSnapshot: RoomSnapshot? = watchParty?.playback?.room?.collectAsState()?.value + // Back in a party opens the party panel over the retained player. + var showPartyPanel by remember { mutableStateOf(false) } // Per-session playback control socket (admin remote control). Bound for the // lifetime of a sessionId; reconnects on its own and never interrupts - // playback. Separate from the Watch Together socket above. + // playback. Separate from the Watch Party room socket above. val playbackRealtimeClient: org.prairieserver.prairie.network.PlaybackRealtimeClient = koinInject() LaunchedEffect(state.sessionId) { val id = state.sessionId ?: return@LaunchedEffect @@ -465,6 +486,11 @@ fun TvPlayerScreen( client = playbackRealtimeClient, viewModel = viewModel, scope = this, // cancelled when sessionId changes / screen leaves + // Admin transport follows the room; Stop ends only this device's + // engagement (the exit follows through remoteStopRequests). + roomTransport = watchParty?.let { party -> + { action -> party.onRemoteTransport(action, exit = viewModel::remoteStop) } + }, ).start() } @@ -510,94 +536,34 @@ fun TvPlayerScreen( ) } } - val latestSiloCastMediaController by rememberUpdatedState(mediaController) - val latestSiloCastSessionPlayer by rememberUpdatedState(sessionPlayer) - DisposableEffect(siloCastReceiver, viewModel, contentId) { - val adapter = TvPrairieCastPlayerAdapter( - play = { - // Watch Together is authoritative for transport: suppress - // PrairieCast transport while in a room so a caster can't desync - // members, mirroring the realtime remote path's - // remoteTransportSuppressed gate. Non-room casting is unchanged. - if (!viewModel.remoteTransportSuppressed) { - viewModel.setPaused(false) - latestSiloCastMediaController?.play() - } - }, - pause = { - if (!viewModel.remoteTransportSuppressed) { - viewModel.setPaused(true) - latestSiloCastMediaController?.pause() - } - }, - playPause = { if (!viewModel.remoteTransportSuppressed) viewModel.onPlayPause() }, - seek = { seconds -> - if (!viewModel.remoteTransportSuppressed) { - viewModel.seekImmediate(seconds) - } - }, - stop = { if (!viewModel.remoteTransportSuppressed) viewModel.remoteStop() }, - selectAudio = { index -> viewModel.remoteSelectAudio(index.toInt()) }, - selectSubtitle = { index -> viewModel.remoteSelectSubtitle(index?.toInt() ?: -1) }, - setPlaybackSpeed = { speed -> - viewModel.onSetPlaybackSpeed(speed) - latestSiloCastMediaController?.playbackParameters = PlaybackParameters(speed.toFloat()) - }, - setQuality = { qualityId -> - val player = latestSiloCastMediaController ?: latestSiloCastSessionPlayer - if (player != null && selectVideoQuality(player, qualityId)) { - val resolution = viewModel.uiState.value.videoQualities - .firstOrNull { it.id == qualityId } - ?.resolution - viewModel.onVideoQualitySelectionApplied(resolution) - } - }, - setVideoGravity = { value -> - viewModel.onVideoFillModeChanged(value.toSiloCastVideoFillMode()) - }, - setHdrEnabled = viewModel::onSetHdrEnabled, - setSubtitleSyncMs = viewModel::onSubtitleDelayChanged, - setSubtitlePosition = { value -> - viewModel.onSetSubtitleAppearance( - latestSiloCastSubtitleAppearance.copy(position = value.toSiloCastSubtitlePosition()), - ) - }, - setVolume = { volume -> - latestSiloCastMediaController?.let { controller -> - val next = volume.toFloat().coerceIn(0f, 1f) - siloCastReceiver.recordPlayerVolume(next.toDouble()) - controller.volume = next - } - }, - setMuted = { muted -> - latestSiloCastMediaController?.let { controller -> - siloCastReceiver.recordPlayerMuted(muted, controller.volume.toDouble()) - controller.volume = if (muted) 0f else siloCastReceiver.retainedPlayerVolume().toFloat() - } - }, - playNext = viewModel::playNextEpisodeNow, - ) - val registration = siloCastReceiver.registerPlayer(adapter) { - val volumeState = siloCastReceiver.resolvePlayerVolume( - currentVolume = latestSiloCastMediaController?.volume?.toDouble(), - ) - viewModel.uiState.value.toPrairieCastPlaybackState( - playbackSpeed = latestSiloCastPlaybackSpeed, - hdrEnabled = latestSiloCastHdrEnabled, - subtitleDelayMs = latestSiloCastSubtitleDelayMs, - subtitleAppearance = latestSiloCastSubtitleAppearance, - volumeState = volumeState, - ) - } - onDispose { registration.close() } - } - val stopPlaybackAndExit = { + var staleCastLaunch by remember(castLaunchId) { mutableStateOf(false) } + TvPrairieCastPlayerRegistration( + siloCastReceiver = siloCastReceiver, + viewModel = viewModel, + contentId = contentId, + castLaunchId = castLaunchId, + onStaleCastLaunch = { staleCastLaunch = true }, + watchParty = watchParty, + mediaController = mediaController, + playbackSpeed = playbackSpeed, + subtitleDelayMs = subtitleDelayMs, + hdrEnabled = hdrEnabled, + subtitleAppearance = subtitleAppearance, + ) + /** + * The one terminal exit. It leaves the Watch Party when [leaveRoom] + * (explicit host-close paths depart first; leaving is idempotent). Host + * Stop and an ended party exit without leaving: the first keeps the + * membership for the lobby, the second has nothing left to leave. + * + * Lambdas rather than local functions: every caller captures one value + * instead of the exit's dependencies, which keeps this composable's + * method within ART's JIT limit. + */ + val exitPlayer: (Boolean, (() -> Unit)?) -> Unit = { leaveRoom, destination -> if (!exitRequested) { exitRequested = true - // Every terminal player exit must release the process-owned room - // session. Explicit host-close paths enqueue close first, then this - // idempotent local departure follows behind it. - roomController?.leave(closeRoom = false) + if (leaveRoom) watchParty?.leave() mediaController?.let { controller -> viewModel.stopSessionForExitAsync( positionMs = controller.currentPosition, @@ -607,16 +573,60 @@ fun TvPlayerScreen( controller.stop() controller.clearMediaItems() } ?: viewModel.stopSessionForExitAsync() - latestOnExit() + (destination ?: latestOnExit)() } } + val stopPlaybackAndExit = { exitPlayer(true, null) } + val latestOnPlayOnDeckItem by rememberUpdatedState(onPlayOnDeckItem) + val latestOnStaleCastLaunch by rememberUpdatedState(onStaleCastLaunch) + // The phone's launch went stale just before this player registered. The + // callback checks this player is still on top right before stopping what + // it started, so a newer launch's player is never stopped or popped. + LaunchedEffect(staleCastLaunch) { + if (staleCastLaunch) latestOnStaleCastLaunch { exitPlayer(false) {} } + } // A remote "stop"/"terminate" command tears the screen down like a Back press. LaunchedEffect(Unit) { viewModel.remoteStopRequests.collect { stopPlaybackAndExit() } } - val latestIntroSkipState by rememberUpdatedState(introSkipState) + val latestOnReturnToWatchPartyLobby by rememberUpdatedState(onReturnToWatchPartyLobby) + val latestOnWatchPartyEnded by rememberUpdatedState(onWatchPartyEnded) + if (watchParty != null && roomId != null) { + TvWatchPartyEffects( + watchParty = watchParty, + roomId = roomId, + viewModel = viewModel, + player = mediaController, + onReturnToLobby = { exitPlayer(false) { latestOnReturnToWatchPartyLobby(roomId) } }, + onPartyEnded = { exitPlayer(false) { latestOnWatchPartyEnded() } }, + ) + } + // Members who may not seek get no intro pill: its only action is a seek. + val canSeekInRoom = watchParty == null || roomTransportAuthorized(roomSnapshot, RoomTransportIntent.Seek) + val canPlayPauseInRoom = watchParty == null || + roomTransportAuthorized(roomSnapshot, RoomTransportIntent.PlayPause) + val visibleIntroSkipState = if (canSeekInRoom) introSkipState else IntroAutoSkipState.Hidden + val latestIntroSkipState by rememberUpdatedState(visibleIntroSkipState) + // After repeated stalls in a room: a one-line offer to step down the same + // file's quality ladder, taken with OK while the transport is hidden. + val qualityOffer = remember { TvWatchPartyQualityOfferState() } + val lowerQuality = tvWatchPartyLowerQuality( + watchParty = watchParty, + offerState = qualityOffer, + videoQualities = state.videoQualities, + selectedFileResolution = state.selectedFileResolution, + ) + val latestLowerQuality by rememberUpdatedState(lowerQuality) + + val qualityOfferVisible = lowerQuality != null && + !state.showControls && !state.hudOpen && !state.showNextUp + val manualSkipTarget = tvManualMarkerSkipTarget( + activeMarkerSkipTarget, canSeekInRoom, visibleIntroSkipState.isVisible, + state.isLoading || state.error != null, qualityOfferVisible, + ) + val latestManualSkipTarget by rememberUpdatedState(manualSkipTarget) val latestRoomSnapshot by rememberUpdatedState(roomSnapshot) - val latestShowLeaveDialog by rememberUpdatedState(showLeaveDialog) + val latestShowPartyPanel by rememberUpdatedState(showPartyPanel) val latestShowQuickSubtitlePicker by rememberUpdatedState(showQuickSubtitlePicker) val selectTvSubtitle: (SubtitleIdentity) -> Unit = { identity -> subtitleFocusedStableId = tvSubtitleOptionStableId(identity) @@ -652,14 +662,13 @@ fun TvPlayerScreen( if (!latestIntroSkipState.isVisible) return false // The controller decides where Select goes — the intro's end for the // `ask` offer, its start for `always`'s undo — and resolves the intro. - // In a room the gate is checked BEFORE asking, so a guest's refused + // In a room the pill only shows to members who may seek, and its Skip + // is a room seek; permission is checked BEFORE resolving, so a refused // press leaves the pill (and the intro) exactly as it was. - if (roomController != null) { - if (tvRoomTransportGate(latestRoomSnapshot, TvTransportIntent.Seek) != TransportGate.Send) { - return true - } + if (watchParty != null) { + if (!watchParty.canSeek()) return true val target = viewModel.onSelectIntroPrompt() ?: return false - roomController.onUserSeek(target) + watchParty.seek(target) } else { val soloTarget = viewModel.onSelectIntroPrompt() ?: return false viewModel.seekImmediate(soloTarget) @@ -673,6 +682,18 @@ fun TvPlayerScreen( return true } + fun handleManualMarkerSelect() { + val current = viewModel.uiState.value + val target = markerSkipTarget(current.position, current.recap, current.credits) ?: return + if (watchParty != null) { + if (!watchParty.canSeek()) return + watchParty.seek(target.endSeconds) + } else { + viewModel.seekImmediate(target.endSeconds) + } + if (current.showControls) requestIdleOverlayFocus(TvIdleOverlayFocusTarget.Scrubber) + } + fun armQuickSkipCapture() { quickSkipCaptureJob?.cancel() quickSkipCaptureGeneration = if (quickSkipCaptureGeneration == Long.MAX_VALUE) { @@ -699,23 +720,18 @@ fun TvPlayerScreen( ): Boolean { val controller = mediaController ?: return true val playerState = viewModel.uiState.value - if (roomController != null && - tvRoomTransportGate(snapshot, TvTransportIntent.Seek) != TransportGate.Send - ) { - return true - } val duration = playerState.duration.takeIf { it > 0.0 } ?: if (playerState.playbackPlan == null) controller.duration / 1000.0 else 0.0 - val targetSec = if (roomController == null) { + val targetSec = if (watchParty == null) { viewModel.onSkipBy(deltaMs / 1000.0) } else { (playerState.position + deltaMs / 1000.0) .coerceAtLeast(0.0) .let { if (duration > 0.0) it.coerceAtMost(duration) else it } } - if (roomController != null) { - roomController.onUserSeek(targetSec) - } + // In a room the skip is a request; a refused one changes nothing here + // and the room's notice explains why. + if (watchParty != null && !watchParty.seek(targetSec)) return true if (revealControls) { if (!playerState.showControls) { requestIdleOverlayFocus(TvIdleOverlayFocusTarget.Scrubber) @@ -727,7 +743,7 @@ fun TvPlayerScreen( // press reads as the bar twitching. Room seeks commit per press with no // accumulator, so there the per-press delta IS the total. val burstDeltaSec = viewModel.quickSkipBurstOriginSec - ?.takeIf { roomController == null } + ?.takeIf { watchParty == null } ?.let { targetSec - it } ?: (deltaMs / 1000.0) skipSeekFeedback = SkipSeekFeedback( @@ -736,7 +752,7 @@ fun TvPlayerScreen( durationSec = duration.coerceAtLeast(0.0), nonce = (skipSeekFeedback?.nonce ?: 0) + 1, ) - if (captureQuickSkipBurst && roomController == null) { + if (captureQuickSkipBurst && watchParty == null) { armQuickSkipCapture() } return true @@ -764,9 +780,13 @@ fun TvPlayerScreen( fun beginCleanPlaybackSeek(direction: Int, snapshot: RoomSnapshot?) { pendingCleanSeekBecameHold = true if (cleanSeekRate != 0 || mediaController == null) return - if (roomController != null && - tvRoomTransportGate(snapshot, TvTransportIntent.Seek) != TransportGate.Send - ) { + if (watchParty != null && !watchParty.canSeek()) { + // Guests never seek: say so instead of scanning a preview nobody can commit. + viewModel.showPlayerMessage( + org.prairieserver.prairie.common.player.watchparty.watchPartyNoticeText( + org.prairieserver.prairie.watchtogether.RoomPlaybackNotice.Denied(RoomTransportIntent.Seek), + ), + ) return } @@ -876,10 +896,8 @@ fun TvPlayerScreen( fun commitCleanPlaybackSeek(snapshot: RoomSnapshot?) { val targetSec = cleanSeekPreviewSec stopCleanPlaybackSeek() - if (roomController != null) { - if (tvRoomTransportGate(snapshot, TvTransportIntent.Seek) == TransportGate.Send) { - roomController.onUserSeek(targetSec) - } + if (watchParty != null) { + watchParty.seek(targetSec) } else { viewModel.seekImmediate(targetSec) } @@ -941,32 +959,28 @@ fun TvPlayerScreen( // dispatchKeyEvent, so a countdown Back would otherwise fall // through to hiding the controls or exiting the player. latestIntroSkipState.isVisible -> viewModel.onDismissIntroPrompt() + lowerQuality != null && !state.showControls -> qualityOffer.dismissed = true showQuickSubtitlePicker -> showQuickSubtitlePicker = false // On the Up-Next overlay, Back exits the player (matches tvOS, where // the Up-Next "Back" button dismisses the whole player). state.showNextUp -> stopPlaybackAndExit() state.hudOpen -> viewModel.closeHUD() - showLeaveDialog -> showLeaveDialog = false + showPartyPanel -> showPartyPanel = false // While PLAYING, Back steps controls -> hidden before exiting. // While PAUSED, hiding controls would just strand a frozen frame, // so Back falls through to the exit (or room-leave) flow instead — // Apple parity (silo-apple f12a928). state.showControls && !state.isPaused -> viewModel.setControlsVisible(false) - // In a room: Back surfaces the Leave affordance. Host gets a - // close-confirm dialog (closing tears down the room for everyone); - // a guest leaves immediately. - roomController != null && roomSnapshot?.isHost == true -> showLeaveDialog = true - roomController != null -> { - roomController.leave(closeRoom = false) - stopPlaybackAndExit() - } + // In a party: Back opens the party panel over the retained + // player (Leave, and for the host Stop and End). Never an exit. + watchParty != null -> showPartyPanel = true else -> { stopPlaybackAndExit() } } } - DisposableEffect(viewModel, roomController) { + DisposableEffect(viewModel, watchParty) { val handler: (KeyEvent) -> Boolean = handler@{ event -> val playerState = viewModel.uiState.value if (event.keyCode == KeyEvent.KEYCODE_BACK) { @@ -1094,7 +1108,7 @@ fun TvPlayerScreen( viewModel.setControlsVisible(true) } if (latestShowQuickSubtitlePicker || - playerState.hudOpen || latestShowLeaveDialog || + playerState.hudOpen || latestShowPartyPanel || // The Up-Next overlay is a focus-trapping Compose surface that // owns its own remote input (Play Now / Keep Watching / Back) — // don't let the transport bridge toggle play/pause underneath it. @@ -1161,6 +1175,36 @@ fun TvPlayerScreen( return@handler handleIntroPromptSelect() } + if (latestManualSkipTarget != null && !playerState.showControls && + !playerState.isScrubbing && cleanSeekRate == 0 && + event.keyCode in setOf(KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER) + ) { + if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) handleManualMarkerSelect() + return@handler true + } + + // The lower-quality offer owns OK and Back while the transport is + // hidden, like the intro pill. + val offeredQuality = latestLowerQuality + if (offeredQuality != null && !playerState.showControls && event.repeatCount == 0) { + when (event.keyCode) { + KeyEvent.KEYCODE_DPAD_CENTER, + KeyEvent.KEYCODE_ENTER, + KeyEvent.KEYCODE_NUMPAD_ENTER, + -> { + if (event.action == KeyEvent.ACTION_DOWN) { + qualityOffer.dismissed = true + switchTvPlaybackQuality(viewModel, watchParty, offeredQuality.id) + } + return@handler true + } + KeyEvent.KEYCODE_BACK -> { + if (event.action == KeyEvent.ACTION_DOWN) qualityOffer.dismissed = true + return@handler true + } + } + } + // Back while PLAYING with the transport overlay up: hide the // overlay HERE, at the key-dispatch bridge, before Compose's // focus system can eat the press as a button focus-deselection @@ -1173,7 +1217,7 @@ fun TvPlayerScreen( !playerState.hudOpen && !playerState.showNextUp && !latestShowQuickSubtitlePicker && - !latestShowLeaveDialog + !latestShowPartyPanel ) { if (event.action == KeyEvent.ACTION_UP) { // While scrubbing, Back cancels the in-flight scrub (drop the @@ -1189,17 +1233,12 @@ fun TvPlayerScreen( when (action) { TvPlayerRemoteKeyAction.PlayPause -> { - val canPlayPauseInRoom = roomController == null || - tvRoomTransportGate( - latestRoomSnapshot, - TvTransportIntent.PlayPause, - ) == TransportGate.Send - if (canPlayPauseInRoom) { - if (roomController != null) { - roomController.onUserPlayPause() - } else { - viewModel.onPlayPause() - } + // In a room every press goes to the room, which applies, + // refuses with a notice, or resumes a local hold. + if (watchParty != null) { + watchParty.togglePlayPause() + } else { + viewModel.onPlayPause() } viewModel.setControlsVisible(true) requestIdleOverlayFocus(TvIdleOverlayFocusTarget.Transport) @@ -1247,15 +1286,6 @@ fun TvPlayerScreen( onDispose { TvPlayerRemoteKeyBridge.clear(handler) } } - // room_closed (TERMINAL only — host left / explicit close) → stop + exit - // back to detail. Transient server `error` frames never reach here (they - // flow on the repo's errors stream and do NOT eject the user). - LaunchedEffect(roomClosedReason) { - if (roomClosedReason != null && roomController != null) { - stopPlaybackAndExit() - } - } - // A subtitle or audio change that failed has to say so. Stage, validation, // commit, rollback and mount failures all populated subtitleFailureMessage // and nothing ever read it: "Applying…" simply vanished and the tick @@ -1268,19 +1298,6 @@ fun TvPlayerScreen( viewModel.onSubtitleFailureShown(state.subtitleFailureId) } - // Surface transient Watch Together server rejections (e.g. a guest seek the - // server refuses) as a brief Toast. These flow on the repo errors stream and - // do NOT eject the user. Only collected while bound to a room. - LaunchedEffect(roomController) { - if (roomController != null) { - watchTogetherRepository.errors.collect { message -> - if (message.isNotBlank()) { - Toast.makeText(context, message, Toast.LENGTH_SHORT).show() - } - } - } - } - // Apply capability-aware track selection presets. Re-runs on HDMI // hot-plug / AVR power cycle so Atmos and DV stay preferred as the sink // reports them. Also re-runs when the user flips the HDR toggle in the @@ -1367,9 +1384,10 @@ fun TvPlayerScreen( onDispose { window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } } - val latestLifecycleRoomSnapshot by rememberUpdatedState(roomSnapshot) - // Lifecycle pausing — send pause to the service when we're backgrounded. + // In a Watch Party leaving the foreground is a local hold instead: the + // player pauses, nothing is reported or asked of the room, and the + // membership survives. Coming back resyncs through the room binding. DisposableEffect( lifecycleOwner, mediaController, @@ -1380,6 +1398,7 @@ fun TvPlayerScreen( when (event) { Lifecycle.Event.ON_PAUSE, Lifecycle.Event.ON_STOP -> if (!isInPictureInPictureMode) { + if (watchParty != null) viewModel.holdRoomPlayback(TvRoomHold.Background) mediaController?.let { controller -> if (controller.playWhenReady) { if ( @@ -1397,26 +1416,15 @@ fun TvPlayerScreen( // destination too: the session cannot be resumed after // teardown, and keeping this route mounted would show a // stopped/black player when the activity resumes. - if (event == Lifecycle.Event.ON_STOP && !exitRequested) { + // + // Not in a Watch Party: the room needs this session, and + // backgrounding must never leave the room. + if (event == Lifecycle.Event.ON_STOP && !exitRequested && watchParty == null) { stopPlaybackAndExit() } } - Lifecycle.Event.ON_RESUME -> if (roomController != null) { - val desired = latestLifecycleRoomSnapshot?.isPaused?.not() - mediaController?.let { controller -> - if ( - desired != null && - (controller.playWhenReady != desired || - playWhenReadyReconciliationGate.hasPendingChanges) - ) { - if ( - playWhenReadyReconciliationGate - .requestProgrammaticChange(desired) - ) { - controller.playWhenReady = desired - } - } - } + Lifecycle.Event.ON_RESUME -> if (watchParty != null) { + viewModel.releaseRoomPlayback(TvRoomHold.Background) } else -> Unit } @@ -1482,6 +1490,7 @@ fun TvPlayerScreen( ) { val mountToken = eventTime.videoMountToken() ?: return if (mountToken != viewModel.uiState.value.transportMountNonce) return + pictureShown = true startupStallDetector.onFirstFrameRendered() postResumeStallDetector.onFirstFrameRendered() viewModel.onFirstVideoFrameRendered(mountToken) @@ -1509,22 +1518,59 @@ fun TvPlayerScreen( val provenance = playWhenReadyReconciliationGate .onPlayWhenReadyChanged(playWhenReady, reason) provenance.followUpProgrammaticValue?.let { controller.playWhenReady = it } - if (!provenance.shouldReconcile) return - roomController - ?.onExternalPlayWhenReadyChanged(playWhenReady) - ?.let { authoritative -> - if (controller.playWhenReady != authoritative) { - if ( - playWhenReadyReconciliationGate - .requestProgrammaticChange(authoritative) - ) { - controller.playWhenReady = authoritative - } - } - } + val party = watchParty ?: return + // Audio focus loss and a noisy route are local holds: + // nothing is sent to the room. + if (!playWhenReady && + (reason == Player.PLAY_WHEN_READY_CHANGE_REASON_AUDIO_FOCUS_LOSS || + reason == Player.PLAY_WHEN_READY_CHANGE_REASON_AUDIO_BECOMING_NOISY) + ) { + party.sample(controller) + viewModel.holdRoomPlayback(TvRoomHold.AudioFocus) + return + } + if (!provenance.shouldReconcile) { + party.sample(controller) + return + } + // A deliberate MediaSession play/pause: the room decides, + // and the player goes back to its room-applied state now. + val restore = party.onExternalPlayWhenReady(playWhenReady) + if (controller.playWhenReady != restore && + playWhenReadyReconciliationGate.requestProgrammaticChange(restore) + ) { + controller.playWhenReady = restore + } + // Sample only after the restore (the controller masks it + // at once), so a report never carries the outside change. + party.sample(controller) + } + + override fun onPlaybackSuppressionReasonChanged(playbackSuppressionReason: Int) { + watchParty?.sample(controller) + } + + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int, + ) { + val party = watchParty ?: return + // A seek this screen did not issue (notification, headset, + // MediaSession seek back/forward, any other controller). + // Mounts replace the item and never arrive as a seek. + if (reason == Player.DISCONTINUITY_REASON_SEEK && + oldPosition.mediaItemIndex == newPosition.mediaItemIndex + ) { + party.onSeekDiscontinuity(oldPosition.positionMs, newPosition.positionMs) + } + // Restoring an outside seek marks it pending before the + // room can observe the unaccepted target. + party.sample(controller) } override fun onIsPlayingChanged(isPlaying: Boolean) { + watchParty?.sample(controller) viewModel.onPlayingChanged(isPlaying) val live = viewModel.uiState.value val key = live.sessionId?.let { sessionId -> @@ -1544,6 +1590,9 @@ fun TvPlayerScreen( } } override fun onPlaybackStateChanged(playbackState: Int) { + // Readiness is evaluated on every signal that can mean + // playable media, not only on the sampling tick. + watchParty?.sample(controller) // Buffering during normal playback flips the centered // spinner. This complements the lifecycle's Reconnecting // state which the player can't observe (server-outage @@ -1566,6 +1615,11 @@ fun TvPlayerScreen( // Quality is a server-transcode ladder built by the VM at // session load (tvOS parity), not the adaptive variants. viewModel.onTracksChanged(audio, subtitle, video) + // Read the live tracks, not the event's: a callback queued + // before a remount still describes the outgoing item. + if (controller.currentTracks.groups.any { it.type == C.TRACK_TYPE_TEXT && it.isSelected }) { + viewModel.onMountedSubtitleSelected() + } } override fun onVideoSizeChanged(videoSize: VideoSize) { // MediaController doesn't expose ExoPlayer's `videoFormat` @@ -1603,38 +1657,6 @@ fun TvPlayerScreen( } } - // Position polling — lifecycle-bounded so it doesn't outlive the screen. - LaunchedEffect(mediaController, state.sessionId, lifecycleOwner) { - val controller = mediaController ?: return@LaunchedEffect - val timelineWindow = Timeline.Window() - lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - while (isActive && state.sessionId != null) { - viewModel.onPositionChanged( - controller.currentPosition, - controller.duration.coerceAtLeast(0L), - ) - // Mounted-transport extent for the VM's native-first seek - // decision (see TvPlayerViewModel.mountedSeekableSourceRange). - // A seekable window with a known length can serve any target - // it spans without a server reanchor. - if (!controller.currentTimeline.isEmpty) { - controller.currentTimeline.getWindow( - controller.currentMediaItemIndex, - timelineWindow, - ) - viewModel.onPlayerWindowChanged( - isSeekable = timelineWindow.isSeekable, - windowEndPlayerMs = if (timelineWindow.durationUs != C.TIME_UNSET) { - timelineWindow.durationUs / 1000 - } else { - -1L - }, - ) - } - delay(500) - } - } - } LaunchedEffect( mediaController, state.sessionId, @@ -1691,10 +1713,11 @@ fun TvPlayerScreen( renderedOutputBufferCount = decoderCounters?.renderedOutputBufferCount, )) { PostResumeVideoStallDetector.Signal.SeekBack -> { - controller.seekTo( - (controller.currentPosition - PostResumeVideoStallDetector.SEEK_BACK_MS) - .coerceAtLeast(0L), - ) + val target = (controller.currentPosition - PostResumeVideoStallDetector.SEEK_BACK_MS) + .coerceAtLeast(0L) + // A local recovery action, never a room seek. + watchParty?.recordIssuedSeek(target) + controller.seekTo(target) viewModel.onRuntimeCorrection( "runtime_correction_applied", "client_post_resume_video_recovery_v1", @@ -1706,6 +1729,7 @@ fun TvPlayerScreen( val resume = controller.playWhenReady controller.stop() controller.prepare() + watchParty?.recordIssuedSeek(position) controller.seekTo(position) if (resume) controller.play() viewModel.onRuntimeCorrection( @@ -1739,26 +1763,6 @@ fun TvPlayerScreen( } // Prepare the player when a stream URL becomes available. - // Applies a local audio switch: the track is already in the mounted stream, - // so it only needs selecting on the player. The ViewModel does not commit - // on the strength of this call -- AudioTrackManager returns Unit and does - // nothing silently if the group is gone -- it waits for onTracksChanged to - // show the target selected. - LaunchedEffect(videoBackend) { - val backend = videoBackend ?: return@LaunchedEffect - viewModel.pendingLocalAudioSelection.collect { request -> - request ?: return@collect - backend.selectAudioTrack( - VideoPlayerTrackEntry( - index = request.targetOrdinal, - label = "", - language = null, - isSelected = true, - ), - ) - } - } - LaunchedEffect( videoBackend, state.sessionId, @@ -1830,6 +1834,7 @@ fun TvPlayerScreen( backend.mount(mediaSpec, playWhenReady = !viewModel.uiState.value.isPaused) mountedTransportNonce = state.transportMountNonce viewModel.onTransportMountApplied(state.transportMountNonce) + viewModel.onSubtitleCuesMounted(state.subtitleCueRevisions) } // Subtitle refresh (search download / AI completion): Media3 cannot add @@ -1882,65 +1887,20 @@ fun TvPlayerScreen( activeClaims = plan?.activeOriginalHttpClaims().orEmpty(), ) backend.refresh(mediaSpec) + viewModel.onSubtitleCuesMounted(state.subtitleCueRevisions) } - // The single path from the subtitle transaction adapter to the player. - // Every request carries the owner that armed it, so the acknowledgement can - // never be dropped for want of one. Mirrors the seekRequests idiom. - LaunchedEffect(videoBackend) { - val backend = videoBackend ?: return@LaunchedEffect - viewModel.subtitleMountRequests.collect { request -> - if (!viewModel.canApplySubtitleMount(request)) return@collect - if (request.trackIndex == -1) { - if (backend.selectSubtitle(null)) { - viewModel.onSubtitleSelectionApplied(request) - } else { - viewModel.onSubtitleSelectionFailed(request) - } - return@collect - } - val selectedTrack = viewModel.uiState.value.subtitleTracks - .firstOrNull { it.index == request.trackIndex } - ?.toVideoTrackEntry() - if (selectedTrack != null && backend.selectSubtitle(selectedTrack)) { - viewModel.onSubtitleSelectionApplied(request) - } else { - viewModel.onSubtitleSelectionFailed(request) - } - } - } - - // Remote set_audio_track / set_subtitle_track are latched and resolved in - // the ViewModel after stable track identities exist. Only the transaction - // adapter may emit a backend subtitle mount request. - - // Mirror user-intent pause state into the player. Kept separate from the - // onPlayingChanged listener so a transient buffering stall can't flip the - // pause icon or cancel the auto-hide timer. - LaunchedEffect(mediaController, state.isPaused, playWhenReadyReconciliationGate) { - val controller = mediaController ?: return@LaunchedEffect - val desired = !state.isPaused - if (controller.playWhenReady != desired) { - if (playWhenReadyReconciliationGate.requestProgrammaticChange(desired)) { - controller.playWhenReady = desired - } - } - } - - LaunchedEffect(mediaController) { - val controller = mediaController ?: return@LaunchedEffect - viewModel.seekRequests.collect { targetSec -> - controller.seekTo((targetSec * 1000).toLong()) - } - } - - // Apply per-profile playback speed to the MediaController. Uses - // PlaybackParameters because MediaController doesn't expose a direct - // setPlaybackSpeed setter that respects pitch correction defaults. - LaunchedEffect(mediaController, playbackSpeed) { - val controller = mediaController ?: return@LaunchedEffect - controller.playbackParameters = PlaybackParameters(playbackSpeed.toFloat()) - } + TvPlayerCommandEffects( + videoBackend = videoBackend, + mediaController = mediaController, + sessionId = state.sessionId, + lifecycleOwner = lifecycleOwner, + viewModel = viewModel, + watchParty = watchParty, + isPaused = state.isPaused, + playWhenReadyReconciliationGate = playWhenReadyReconciliationGate, + playbackSpeed = playbackSpeed, + ) // Apply user subtitle styling whenever the PlayerView mounts or the // appearance flow emits a new value. Mirrors the phone PlayerScreen. @@ -1965,6 +1925,29 @@ fun TvPlayerScreen( subtitleManager.applyAppearance(pv, subtitleAppearance) } + // While the controls are up the cue rises above them (they reach ~207dp + // up the screen) instead of landing on the title and scrubber; while the + // options panel is open the cue is hidden, so nothing reads through it. + val subtitleDensity = androidx.compose.ui.platform.LocalDensity.current + val controlsCoverCues = state.showControls && !state.hudOpen && !state.showNextUp + val cueReachPx = if (controlsCoverCues) with(subtitleDensity) { 216.dp.roundToPx() } else 0 + val latestCueReachPx by rememberUpdatedState(cueReachPx) + LaunchedEffect(playerViewRef, cueReachPx, state.hudOpen) { + val subtitleView = playerViewRef?.subtitleView ?: return@LaunchedEffect + subtitleView.visibility = if (state.hudOpen) android.view.View.INVISIBLE else android.view.View.VISIBLE + liftSubtitlesClearOfControls(subtitleView, cueReachPx) + } + // The subtitle manager resizes and moves the canvas on its own (video + // size, aspect, letterbox), so re-lift after each layout too. + DisposableEffect(playerViewRef) { + val subtitleView = playerViewRef?.subtitleView ?: return@DisposableEffect onDispose { } + val listener = android.view.View.OnLayoutChangeListener { view, _, _, _, _, _, _, _, _ -> + view.post { liftSubtitlesClearOfControls(view, latestCueReachPx) } + } + subtitleView.addOnLayoutChangeListener(listener) + onDispose { subtitleView.removeOnLayoutChangeListener(listener) } + } + // The video branch of the player's `when` below — the only state in which // the PlayerView is mounted and video-scoped overlays should draw. val videoActive = state.streamUrl != null && state.error == null @@ -2040,10 +2023,26 @@ fun TvPlayerScreen( } } + // True Black Bars (prairie-android#475): once the picture is up, clear the + // player to transparent instead of painting it black, so anything not drawn + // over (the bars, Up Next, an error) shows the output's own black. Clearing, + // not just skipping the plate, also wipes the window's theme background and + // stale pixels nothing else repaints, such as the HUD after it hides. The + // plate stays until the first frame, and while Navigation fades the + // screen: the fade draws it into a layer, where clearing would let the + // other screen show through. + val clearBars = trueBlackBars && pictureShown && navigationSettled + Box( modifier = Modifier .fillMaxSize() - .background(Color.Black) + .then( + if (clearBars) { + Modifier.drawBehind { drawRect(Color.Transparent, blendMode = BlendMode.Clear) } + } else { + Modifier.background(Color.Black) + }, + ) .onGloballyPositioned { playerRootBounds = it.videoViewportBounds() } .focusRequester(rootFocus) .onFocusChanged { playerRootHasFocus = it.isFocused } @@ -2139,10 +2138,7 @@ fun TvPlayerScreen( // In a room, transport authority gates what the local // member may drive: a guest who can't seek gets a disabled // scrubber + skip; play/pause only under guest_play_pause. - val canSeekInRoom = roomController == null || - tvRoomTransportGate(roomSnapshot, TvTransportIntent.Seek) == TransportGate.Send - val canPlayPauseInRoom = roomController == null || - tvRoomTransportGate(roomSnapshot, TvTransportIntent.PlayPause) == TransportGate.Send + // (canSeekInRoom / canPlayPauseInRoom are computed above.) val bufferedAheadSec = ( (mediaController?.bufferedPosition ?: 0L) - (mediaController?.currentPosition ?: 0L) @@ -2150,9 +2146,11 @@ fun TvPlayerScreen( TvPlayerClockScope(viewModel) { clock -> TvPlayerIdleOverlay( title = state.title, - episodeTag = state.seasonNumber?.let { season -> - state.episodeNumber?.let { ep -> "S$season·E$ep" } - }, + eyebrow = tvPlayerEyebrow(state), + subtitlesValue = subtitlePresentation.rows + .firstOrNull { row -> row.checked } + ?.let { row -> tvSubtitleRowParts(row.label).title } + ?: "Off", positionSec = clock.position, durationSec = clock.duration, isPaused = state.isPaused, @@ -2172,10 +2170,13 @@ fun TvPlayerScreen( // command → engine applies the seek locally). Solo // playback seeks the MediaController directly. transportEnabled = canSeekInRoom, - playPauseEnabled = canPlayPauseInRoom, + // A local hold (audio focus, sleep timer) is the + // viewer's own to clear, whatever the room policy. + playPauseEnabled = canPlayPauseInRoom || viewModel.isRoomHeld, skipBackSeconds = seekIntervals.backSeconds, skipForwardSeconds = seekIntervals.forwardSeconds, - canToggleAfterCommit = roomController == null, + canToggleAfterCommit = watchParty == null, + playbackSpeed = if (watchParty != null) 1.0 else playbackSpeed, onSkipBack = { if (canSeekInRoom) { performRelativeSeek( @@ -2199,8 +2200,8 @@ fun TvPlayerScreen( onCommitScrub = { val targetSec = viewModel.commitScrub() if (!canSeekInRoom) return@TvPlayerIdleOverlay - if (roomController != null) { - roomController.onUserSeek(targetSec) + if (watchParty != null) { + watchParty.seek(targetSec) } else { // seekImmediate pre-writes position so a scrub // committed into the credits region isn't mistaken @@ -2212,9 +2213,11 @@ fun TvPlayerScreen( onCancelScrub = { viewModel.cancelScrub() }, focusRequest = idleOverlayFocusRequest, onPlayPause = { - if (!canPlayPauseInRoom) return@TvPlayerIdleOverlay - if (roomController != null) { - roomController.onUserPlayPause() + // In a room every press goes to the room, which + // applies it or refuses with a notice, like the + // remote's play/pause key. + if (watchParty != null) { + watchParty.togglePlayPause() } else { viewModel.onPlayPause() } @@ -2239,29 +2242,17 @@ fun TvPlayerScreen( null }, onClose = { - when { - roomController != null && roomSnapshot?.isHost == true -> - showLeaveDialog = true - roomController != null -> { - roomController.leave(closeRoom = false) - stopPlaybackAndExit() - } - else -> stopPlaybackAndExit() - } + if (watchParty != null) showPartyPanel = true else stopPlaybackAndExit() }, ) } } if (!isInPictureInPictureMode && state.hudOpen) { - // Floating top-center card — no full-screen scrim so video - // stays visible behind it. Mirrors tvOS TVPlayerInfoHUD. - Box( - modifier = Modifier - .fillMaxSize() - .padding(top = 56.dp), - contentAlignment = androidx.compose.ui.Alignment.TopCenter, - ) { + // Floating top-center rail and card over a light scrim, so + // the picture stays watchable behind it. The HUD fills the + // screen and places itself; mirrors tvOS TVPlayerInfoHUD. + Box(modifier = Modifier.fillMaxSize()) { TvPlayerClockScope(viewModel) { clock -> TvPlayerHud( title = state.title, @@ -2274,6 +2265,8 @@ fun TvPlayerScreen( fileVersions = state.fileVersions, selectedFileId = state.selectedFileId ?: state.mediaFileId, onSelectFileVersion = viewModel::onSelectFileVersion, + // A Watch Party plays one exact file at 1x. + versionAndSpeedControlsVisible = watchParty == null, subtitleTracks = state.subtitleTracks, subtitleUrls = state.subtitleUrls, subtitlePresentation = subtitlePresentation, @@ -2289,10 +2282,10 @@ fun TvPlayerScreen( onSelectVideoQuality = { id -> // Server-transcode quality ladder (tvOS parity): // re-request the session at the chosen rung. - viewModel.switchQuality(id) + switchTvPlaybackQuality(viewModel, watchParty, id) }, onVideoFillModeChanged = viewModel::onVideoFillModeChanged, - playbackSpeed = playbackSpeed, + playbackSpeed = if (watchParty != null) 1.0 else playbackSpeed, onPlaybackSpeedChanged = viewModel::onSetPlaybackSpeed, sleepTimerState = sleepTimerState, onStartSleepTimer = viewModel::onStartSleepTimer, @@ -2310,7 +2303,11 @@ fun TvPlayerScreen( onSubtitleDelayChanged = viewModel::onSubtitleDelayChanged, subtitleAppearance = subtitleAppearance, onSubtitleAppearanceChanged = viewModel::onSetSubtitleAppearance, + subtitleTextOpacitySupported = subtitleTextOpacitySupported, onSubtitlesPaneShown = viewModel::onSubtitlesPaneShown, + subtitleSync = hudSubtitleSync, + onSyncSubtitle = viewModel::requestSubtitleSync, + onResetSubtitleTiming = viewModel::resetSubtitleTiming, onSearchSubtitles = if (state.mediaFileId != null) { { viewModel.closeHUD() @@ -2338,14 +2335,11 @@ fun TvPlayerScreen( chapters = state.chapters, onSelectChapter = { idx -> viewModel.onSeekToChapter(idx)?.let { sec -> - if (roomController != null) { - // In a room, route through the same gated - // path as scrub-commit / performRelativeSeek: - // transport authority decides (a guest is a - // no-op) and a permitted seek broadcasts. - if (tvRoomTransportGate(roomSnapshot, TvTransportIntent.Seek) == TransportGate.Send) { - roomController.onUserSeek(sec) - } + if (watchParty != null) { + // A chapter jump is a room seek: the + // host's is sent; anyone else's is + // refused with a notice. + watchParty.seek(sec) } else { // Solo: seekImmediate pre-writes position so a // chapter jump into credits isn't mistaken for @@ -2392,26 +2386,24 @@ fun TvPlayerScreen( !state.hudOpen && !state.showNextUp ) { // Controls hidden: align the transient line with the REAL - // scrubber track's position inside the idle overlay, which - // stacks (bottom-up): 40dp overlay padding + 33dp transport - // cluster + 16dp gap + 8dp spacer + 16dp gap = 113dp to the - // scrubber COLUMN's bottom — plus ~6dp because the 3.5dp - // track is centered in the column's lower box (41dp minus - // label row), not flush with its bottom. + // scrubber track inside the idle overlay, which stacks + // (bottom-up): 26dp inset + 44dp transport + 16dp gap + + // 18dp clock row + 8dp gap = 112dp to the 28dp bar box, whose + // track is centred in it — ~126dp — less half the chip's own + // 4dp track. // // Controls visible: the live scrubber already reports - // position, so the chip drops its own track and rises into - // the 42dp gap between that column's top (113 + 41) and the - // title block at 196dp. Centred, so it clears the + // position, so the chip drops its own track and rises above + // the title block (~207dp). Centred, so it clears the // left-aligned title at any title width. - // Horizontal 80dp matches the track width in both cases. + // Horizontal 48dp matches the track width in both cases. Box( modifier = Modifier .fillMaxSize() .padding( - start = 80.dp, - end = 80.dp, - bottom = if (state.showControls) 154.dp else 119.dp, + start = TvPlayerChrome.SideInset, + end = TvPlayerChrome.SideInset, + bottom = if (state.showControls) 226.dp else 124.dp, ), contentAlignment = Alignment.BottomCenter, ) { @@ -2475,19 +2467,22 @@ fun TvPlayerScreen( TvPlayerOverlays( isInPictureInPictureMode = isInPictureInPictureMode, notice = notice, + subtitleSyncNotice = viewModel.subtitleSyncNotice, remoteMessage = remoteMessage, roomSnapshot = roomSnapshot, - roomActive = roomController != null, + roomActive = watchParty != null, showControls = state.showControls, hudOpen = state.hudOpen, - showLeaveDialog = showLeaveDialog, showNextUp = state.showNextUp, nextEpisode = state.nextEpisode, + shuffle = state.shuffle, nextUpVideoEnded = state.nextUpVideoEnded, nextUpCountdownSeconds = state.nextUpCountdownSeconds, nextUpCountdownTotalSeconds = state.nextUpCountdownTotalSeconds, autoPlayNextEnabled = autoPlayNextEnabled, - introSkipState = introSkipState, + introSkipState = visibleIntroSkipState, + manualSkipTarget = manualSkipTarget, + onManualMarkerSelect = { handleManualMarkerSelect() }, introSkipCountdownRun = introSkipCountdownRun, introSkipTimerRunning = introSkipTimerRunning, introSkipTotalSeconds = viewModel.introSkipTotalSeconds, @@ -2502,19 +2497,289 @@ fun TvPlayerScreen( showNextUp = state.showNextUp, isInPictureInPictureMode = isInPictureInPictureMode, ), - onCloseRoom = { - showLeaveDialog = false - roomController?.leave(closeRoom = true) - stopPlaybackAndExit() - }, - onCancelLeaveDialog = { showLeaveDialog = false }, onPlayNextNow = viewModel::playNextEpisodeNow, onKeepWatching = viewModel::dismissNextUp, + onPickAnother = viewModel::pickAnotherShuffle, + onStopShuffling = { + // Stop shuffling leaves the player, back to where the shuffle started. + viewModel.stopShuffling() + stopPlaybackAndExit() + }, onToggleAutoPlayNext = { viewModel.onSetAutoPlayNext(!autoPlayNextEnabled) }, onExitPlayback = { stopPlaybackAndExit() }, onNextUpVideoBoundsChanged = { nextUpVideoBounds = it }, onIntroPromptSelect = { handleIntroPromptSelect() }, + onDeckItems = state.onDeckItems, + onPlayOnDeckItem = { item -> + // Leave like Back (the session finishes and saves its + // position), then open the pick in a fresh player. + viewModel.onOnDeckItemChosen() + exitPlayer(true) { latestOnPlayOnDeckItem(item.contentId, item.type) } + }, + qualityOfferLabel = lowerQuality?.label?.takeIf { qualityOfferVisible }, + ) + if (watchParty != null) { + TvWatchPartyPlayerOverlays( + watchParty = watchParty, + panelOpen = showPartyPanel, + isInPictureInPictureMode = isInPictureInPictureMode, + playerFocus = rootFocus, + playerFocused = playerRootHasFocus, + onPanelOpenChange = { showPartyPanel = it }, + exit = stopPlaybackAndExit, + ) + } + } +} + +/** + * The position poll and the ViewModel's requests to the player: local audio + * switches, subtitle mounts, the pause mirror, seeks, and the playback speed. + * + * Split out of [TvPlayerScreen] to keep that composable's generated method + * within ART's JIT limit (see [TvPlayerOverlays]). + */ +@Composable +private fun TvPlayerCommandEffects( + videoBackend: VideoPlaybackBackend?, + mediaController: MediaController?, + sessionId: String?, + lifecycleOwner: androidx.lifecycle.LifecycleOwner, + viewModel: TvPlayerViewModel, + watchParty: TvWatchPartyScreenController?, + isPaused: Boolean, + playWhenReadyReconciliationGate: PlayWhenReadyReconciliationGate, + playbackSpeed: Double, +) { + // Position polling — lifecycle-bounded so it doesn't outlive the screen. + LaunchedEffect(mediaController, sessionId, lifecycleOwner) { + val controller = mediaController ?: return@LaunchedEffect + val timelineWindow = Timeline.Window() + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + while (isActive && sessionId != null) { + viewModel.onPositionChanged( + controller.currentPosition, + controller.duration.coerceAtLeast(0L), + ) + // Mounted-transport extent for the VM's native-first seek + // decision (see TvPlayerViewModel.mountedSeekableSourceRange). + // A seekable window with a known length can serve any target + // it spans without a server reanchor. + if (!controller.currentTimeline.isEmpty) { + controller.currentTimeline.getWindow( + controller.currentMediaItemIndex, + timelineWindow, + ) + viewModel.onPlayerWindowChanged( + isSeekable = timelineWindow.isSeekable, + windowEndPlayerMs = if (timelineWindow.durationUs != C.TIME_UNSET) { + timelineWindow.durationUs / 1000 + } else { + -1L + }, + ) + } + delay(500) + } + } + } + + // Applies a local audio switch: the track is already in the mounted stream, + // so it only needs selecting on the player. The ViewModel does not commit + // on the strength of this call -- AudioTrackManager returns Unit and does + // nothing silently if the group is gone -- it waits for onTracksChanged to + // show the target selected. + LaunchedEffect(videoBackend) { + val backend = videoBackend ?: return@LaunchedEffect + viewModel.pendingLocalAudioSelection.collect { request -> + request ?: return@collect + backend.selectAudioTrack( + VideoPlayerTrackEntry( + index = request.targetOrdinal, + label = "", + language = null, + isSelected = true, + ), + ) + } + } + + // The single path from the subtitle transaction adapter to the player. + // Every request carries the owner that armed it, so the acknowledgement can + // never be dropped for want of one. Mirrors the seekRequests idiom. + LaunchedEffect(videoBackend) { + val backend = videoBackend ?: return@LaunchedEffect + viewModel.subtitleMountRequests.collect { request -> + if (!viewModel.canApplySubtitleMount(request)) return@collect + if (request.trackIndex == -1) { + if (backend.selectSubtitle(null)) { + viewModel.onSubtitleSelectionApplied(request) + } else { + viewModel.onSubtitleSelectionFailed(request) + } + return@collect + } + val selectedTrack = viewModel.uiState.value.subtitleTracks + .firstOrNull { it.index == request.trackIndex } + ?.toVideoTrackEntry() + if (selectedTrack != null && backend.selectSubtitle(selectedTrack)) { + viewModel.onSubtitleSelectionApplied(request) + } else { + viewModel.onSubtitleSelectionFailed(request) + } + } + } + + // Remote set_audio_track / set_subtitle_track are latched and resolved in + // the ViewModel after stable track identities exist. Only the transaction + // adapter may emit a backend subtitle mount request. + + // Mirror user-intent pause state into the player. Kept separate from the + // onPlayingChanged listener so a transient buffering stall can't flip the + // pause icon or cancel the auto-hide timer. + LaunchedEffect(mediaController, isPaused, playWhenReadyReconciliationGate) { + val controller = mediaController ?: return@LaunchedEffect + val desired = !isPaused + if (controller.playWhenReady != desired) { + if (playWhenReadyReconciliationGate.requestProgrammaticChange(desired)) { + controller.playWhenReady = desired + } + } + } + + LaunchedEffect(mediaController) { + val controller = mediaController ?: return@LaunchedEffect + viewModel.seekRequests.collect { targetSec -> + val targetMs = (targetSec * 1000).toLong() + watchParty?.recordIssuedSeek(targetMs) + controller.seekTo(targetMs) + } + } + + // Apply per-profile playback speed to the MediaController (a Watch Party + // runs at 1x or the room's correction rate instead). + TvPlaybackSpeedEffect(mediaController, watchParty != null, viewModel, playbackSpeed) +} + +/** + * Registers this player with the PrairieCast receiver for the screen's lifetime. + * + * Split out of [TvPlayerScreen] to keep that composable's generated method + * within ART's JIT limit (see [TvPlayerOverlays]). + */ +@Composable +private fun TvPrairieCastPlayerRegistration( + siloCastReceiver: TvPrairieCastReceiver, + viewModel: TvPlayerViewModel, + contentId: String, + castLaunchId: String?, + onStaleCastLaunch: () -> Unit, + watchParty: TvWatchPartyScreenController?, + mediaController: MediaController?, + playbackSpeed: Double, + subtitleDelayMs: Int, + hdrEnabled: Boolean, + subtitleAppearance: SubtitleAppearance, +) { + val latestPrairieCastPlaybackSpeed by rememberUpdatedState(playbackSpeed) + val latestPrairieCastSubtitleDelayMs by rememberUpdatedState(subtitleDelayMs) + val latestPrairieCastHdrEnabled by rememberUpdatedState(hdrEnabled) + val latestPrairieCastSubtitleAppearance by rememberUpdatedState(subtitleAppearance) + val latestSiloCastMediaController by rememberUpdatedState(mediaController) + DisposableEffect(siloCastReceiver, viewModel, contentId) { + // In a Watch Party a phone's transport is one more input: the TV + // membership's permissions decide, so a host's phone controls the + // room and a guest's is refused with a notice. Stop ends only this + // device's engagement. Solo casting is unchanged. + val adapter = TvPrairieCastPlayerAdapter( + play = { + if (watchParty != null) { + watchParty.setPlaying(play = true) + } else { + viewModel.setPaused(false) + latestSiloCastMediaController?.play() + } + }, + pause = { + if (watchParty != null) { + watchParty.setPlaying(play = false) + } else { + viewModel.setPaused(true) + latestSiloCastMediaController?.pause() + } + }, + playPause = { + if (watchParty != null) watchParty.togglePlayPause() else viewModel.onPlayPause() + }, + seek = { seconds -> + if (watchParty != null) watchParty.seek(seconds) else viewModel.seekImmediate(seconds) + }, + stop = { + watchParty?.leave() + viewModel.remoteStop() + }, + selectAudio = { index -> viewModel.remoteSelectAudio(index.toInt()) }, + selectSubtitle = { index -> viewModel.remoteSelectSubtitle(index?.toInt() ?: -1) }, + setPlaybackSpeed = { speed -> + // Refused in a Watch Party, and never written to the preference. + tvPrairieCastPlaybackSpeed(inWatchParty = watchParty != null, requested = speed)?.let { applied -> + viewModel.onSetPlaybackSpeed(applied) + latestSiloCastMediaController?.playbackParameters = PlaybackParameters(applied.toFloat()) + } + }, + // The phone offers the same server quality ladder as the HUD, so its pick + // is applied the same way: the server re-plans the stream at that rung. + setQuality = { qualityId -> + switchTvPlaybackQuality( + viewModel, + watchParty, + qualityId.takeUnless { it == VIDEO_QUALITY_AUTO_ID } ?: PlaybackQuality.Auto.wireValue, + ) + }, + setVideoGravity = { value -> + viewModel.onVideoFillModeChanged(value.toPrairieCastVideoFillMode()) + }, + setSubtitleSyncMs = viewModel::onSubtitleDelayChanged, + setSubtitlePosition = { value -> + viewModel.onSetSubtitleAppearance( + latestPrairieCastSubtitleAppearance.copy(position = value.toPrairieCastSubtitlePosition()), + ) + }, + setVolume = { volume -> + latestSiloCastMediaController?.let { controller -> + val next = volume.toFloat().coerceIn(0f, 1f) + siloCastReceiver.recordPlayerVolume(next.toDouble()) + controller.volume = next + } + }, + setMuted = { muted -> + latestSiloCastMediaController?.let { controller -> + siloCastReceiver.recordPlayerMuted(muted, controller.volume.toDouble()) + controller.volume = if (muted) 0f else siloCastReceiver.retainedPlayerVolume().toFloat() + } + }, + playNext = viewModel::playNextEpisodeNow, + // A phone's launch never silently replaces the party player. + launchRefusal = { watchParty?.refuseLaunch() }, ) + val registration = siloCastReceiver.registerPlayer( + adapter = adapter, + launchId = castLaunchId, + onStaleLaunch = onStaleCastLaunch, + ) { + val volumeState = siloCastReceiver.resolvePlayerVolume( + currentVolume = latestSiloCastMediaController?.volume?.toDouble(), + ) + viewModel.uiState.value.toPrairieCastPlaybackState( + // A Watch Party plays at 1x; its correction rate is never shown. + playbackSpeed = if (watchParty != null) 1.0 else latestPrairieCastPlaybackSpeed, + hdrEnabled = latestPrairieCastHdrEnabled, + subtitleDelayMs = latestPrairieCastSubtitleDelayMs, + subtitleAppearance = latestPrairieCastSubtitleAppearance, + volumeState = volumeState, + ) + } + onDispose { registration.close() } } } @@ -2527,7 +2792,10 @@ fun TvPlayerScreen( @Composable private fun TvPlayerIdleOverlay( title: String, - episodeTag: String?, + // "MOVIE · 2026" or "SEVERANCE · S2:E4" above the title. + eyebrow: String?, + // What the Subtitles button says when focused ("English", "Off"). + subtitlesValue: String?, positionSec: Double, durationSec: Double, isPaused: Boolean, @@ -2573,6 +2841,7 @@ private fun TvPlayerIdleOverlay( * playback applies both locally and in order, so it keeps the behaviour. */ canToggleAfterCommit: Boolean = true, + playbackSpeed: Double = 1.0, ) { val scrubberFocus = remember { FocusRequester() } val playPauseFocus = remember { FocusRequester() } @@ -2650,42 +2919,73 @@ private fun TvPlayerIdleOverlay( } }, ) { - // Bottom gradient scrim — 240dp tall, ~0.55 black at the bottom edge, - // fading to transparent so video content above stays visible. + // Bottom gradient scrim — 270dp tall, darkest at the bottom edge where + // the controls sit, clear above so the paused picture stays visible. Box( modifier = Modifier .fillMaxWidth() - .height(240.dp) + .height(270.dp) .align(Alignment.BottomCenter) .background( Brush.verticalGradient( 0.00f to Color.Transparent, - 0.40f to Color.Black.copy(alpha = 0.30f), - 1.00f to Color.Black.copy(alpha = 0.55f), + 0.56f to Color.Black.copy(alpha = 0.55f), + 1.00f to Color.Black.copy(alpha = 0.86f), ), ), ) + // Bottom-up: 26dp inset, 44dp transport, 16dp, scrubber (28dp bar + // + 8dp + clock row), 16dp, then the eyebrow and title. The skip + // indicator and the subtitle lift below are placed from these numbers. Column( modifier = Modifier .fillMaxWidth() .align(Alignment.BottomCenter) - .padding(horizontal = 80.dp, vertical = 40.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, + .padding( + start = TvPlayerChrome.SideInset, + end = TvPlayerChrome.SideInset, + bottom = TvPlayerChrome.BottomInset, + ), ) { + if (title.isNotBlank()) { + if (!eyebrow.isNullOrBlank()) { + androidx.tv.material3.Text( + text = eyebrow.uppercase(), + style = TvPlayerType.Eyebrow, + maxLines = 1, + ) + } + androidx.tv.material3.Text( + text = title, + style = TvPlayerType.Title, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp, bottom = 16.dp), + ) + } + + // Prairie: trickplay tile above the scrubber while scrubbing. if (isScrubbing) { val tile = org.prairieserver.prairie.playback.resolveTrickplayTile( trickplay, scrubPreviewSec, ) if (tile != null) { - org.prairieserver.prairie.common.player.TrickplayTileImage( - tile = tile, - previewWidth = 240.dp, - ) + Box( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp), + contentAlignment = Alignment.Center, + ) { + org.prairieserver.prairie.common.player.TrickplayTileImage( + tile = tile, + previewWidth = 240.dp, + ) + } } } + // Interactive scrubber — capsule track with chapter ticks, interval // skip, hold-to-auto-seek, and Select to commit. tvOS spec §4.1. TvPlayerScrubber( @@ -2725,6 +3025,7 @@ private fun TvPlayerIdleOverlay( onRequestFocus = scrubberFocus, onPlayPause = onPlayPause, canToggleAfterCommit = canToggleAfterCommit, + playbackSpeed = playbackSpeed, onMoveDownToTransport = { playPauseFocus.claimFocusOrReport( target = "player_transport", @@ -2734,10 +3035,11 @@ private fun TvPlayerIdleOverlay( onExitWhenIdle = onClose, ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(16.dp)) TvPlayerTransportCluster( modifier = Modifier.onFocusChanged { transportHasFocus = it.hasFocus }, + subtitlesValue = subtitlesValue, isPlaying = !isPaused, skipBackSeconds = skipBackSeconds, skipForwardSeconds = skipForwardSeconds, @@ -2758,43 +3060,21 @@ private fun TvPlayerIdleOverlay( ) } - // Quiet bottom-left title footer above the scrubber column (tvOS - // titleFooter idiom) — series / title / episode tag, shadowed, no box, - // no "Playing" literal. Sits above the transport stack's top padding. - if (title.isNotBlank()) { - Column( - modifier = Modifier - .align(Alignment.BottomStart) - .padding(start = 80.dp, bottom = 196.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - androidx.tv.material3.Text( - text = title, - color = Color.White, - style = androidx.tv.material3.MaterialTheme.typography.titleMedium.copy( - shadow = androidx.compose.ui.graphics.Shadow( - color = Color.Black.copy(alpha = 0.55f), - offset = androidx.compose.ui.geometry.Offset(0f, 1f), - blurRadius = 6f, - ), - ), - ) - if (episodeTag != null) { - androidx.tv.material3.Text( - text = episodeTag, - color = Color.White.copy(alpha = 0.62f), - style = androidx.tv.material3.MaterialTheme.typography.labelMedium, - ) - } - } - } - } + } +} +/** + * The small line above the player title: "SEVERANCE · S2:E4" for an episode + * (whose title is the episode's own), "MOVIE · 2026" for a film. + */ +internal fun tvPlayerEyebrow(state: TvPlayerViewModel.UiState): String? { + val series = state.seriesTitle?.takeIf { it.isNotBlank() } + if (series != null) { + val code = state.seasonNumber?.let { season -> state.episodeNumber?.let { "S$season:E$it" } } + return listOfNotNull(series, code).joinToString(" · ") } + val kind = "Movie".takeIf { state.contentType == "movie" } + return listOfNotNull(kind, state.year?.toString()).joinToString(" · ").ifBlank { null } } private fun formatSleepCountdown(seconds: Int): String { @@ -2860,14 +3140,12 @@ private fun TvQuickSubtitlePicker( } /** - * Top-end Watch Together status pill. Shows the live member count, a "Waiting - * for members…" line while the room sits on the wait barrier, and the join - * code for a member who can manage the room (host). + * Top-end Watch Party pill: the live member count, and the party code for a + * member who can manage the room (host). */ @Composable private fun TvRoomIndicator( memberCount: Int, - waiting: Boolean, joinCode: String?, ) { Column( @@ -2879,20 +3157,13 @@ private fun TvRoomIndicator( verticalArrangement = Arrangement.spacedBy(2.dp), ) { androidx.tv.material3.Text( - text = "Watch Together · $memberCount in room", + text = "Watch Party · $memberCount watching", color = Color.White, style = androidx.tv.material3.MaterialTheme.typography.labelLarge, ) if (joinCode != null) { androidx.tv.material3.Text( - text = "Code $joinCode", - color = Color.White.copy(alpha = 0.80f), - style = androidx.tv.material3.MaterialTheme.typography.labelMedium, - ) - } - if (waiting) { - androidx.tv.material3.Text( - text = "Waiting for members…", + text = "Party code $joinCode", color = Color.White.copy(alpha = 0.80f), style = androidx.tv.material3.MaterialTheme.typography.labelMedium, ) @@ -2915,6 +3186,7 @@ private fun TvRoomIndicator( * surface; the pass-out gate now manifests as the overlay appearing WITHOUT a * countdown ring (the user must explicitly choose). */ +@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable private fun TvPlayerNextUpOverlay( nextEpisode: NextEpisodeState?, @@ -2927,6 +3199,14 @@ private fun TvPlayerNextUpOverlay( onToggleAutoPlay: () -> Unit, onBack: () -> Unit, onVideoBoundsChanged: (Rect) -> Unit, + // Set while a shuffle plays: the next item is a random pick, and the + // viewer can pick another or stop shuffling. + shuffle: TvShuffleUiState? = null, + onPickAnother: () -> Unit = {}, + onStopShuffling: () -> Unit = {}, + // Other items in progress, shown under the panel (tvOS On Deck parity). + onDeckItems: List<TvOnDeckItem> = emptyList(), + onPlayOnDeckItem: (TvOnDeckItem) -> Unit = {}, ) { val primaryFocus = remember { FocusRequester() } var upNextHasFocus by remember { mutableStateOf(false) } @@ -2939,170 +3219,428 @@ private fun TvPlayerNextUpOverlay( ) } - Box( + var overlayCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) } + var paneCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) } + // onGloballyPositioned hands back the same coordinates object on every + // layout, so its state never changes; these bounds do, and reading them + // in the hole redraws the cut-out whenever the pane moves. + var paneBoundsInRoot by remember { mutableStateOf(androidx.compose.ui.geometry.Rect.Zero) } + BoxWithConstraints( modifier = Modifier .onFocusChanged { upNextHasFocus = it.hasFocus } + .onGloballyPositioned { overlayCoordinates = it } .fillMaxSize(), ) { - Row( - modifier = Modifier - .align(Alignment.Center) - .fillMaxWidth() - .padding(horizontal = 80.dp), - horizontalArrangement = Arrangement.spacedBy(48.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - // The existing PlayerView is measured and placed into this pane. - // It stays mounted while the successor prepares. + // tvOS grid, five cards wide so everything fits one screen: On Deck + // cards span the content column, the preview covers the first two. + val cardSpacing = 20.dp + val onDeckCardWidth = (maxWidth - 160.dp - cardSpacing * 4) / 5 + // tvOS backgroundArtwork: the next episode's still, else the first + // On Deck item's art. Art spoiler protection hides is skipped. + val spoilers = org.prairieserver.prairie.common.cards.LocalEpisodeSpoilerPrefs.current + val backdrop = nextEpisode + ?.takeUnless { it.isEpisode && spoilers.hidesImage(it.isUnwatched, it.stillIsEpisodeStill) } + ?.stillUrl?.takeIf { it.isNotBlank() }?.let { it to null } + ?: onDeckItems.firstOrNull { !spoilers.hidesImage(it.unwatchedEpisode, it.artIsEpisodeStill) } + ?.let { it.artUrl to it.artThumbhash } + TvNextUpBackdrop( + url = backdrop?.first, + thumbhash = backdrop?.second, + hole = { + paneBoundsInRoot + val overlay = overlayCoordinates + val pane = paneCoordinates + if (overlay != null && pane != null && overlay.isAttached && pane.isAttached) { + overlay.localBoundingBoxOf(pane) + } else { + null + } + }, + ) + // One fixed screen, never scrolled: the video is a separate surface + // placed at the pane's bounds, and it cannot follow a scroll. Only the + // panel may scroll, and only when a focused control is off screen (TV's + // default focus pivot would scroll it on every move). + CompositionLocalProvider(LocalBringIntoViewSpec provides TvSmoothBringIntoViewSpec) { + Column(modifier = Modifier.fillMaxSize()) { Box( modifier = Modifier - .weight(1f) - .aspectRatio(16f / 9f) - .onGloballyPositioned { onVideoBoundsChanged(it.videoViewportBounds()) } - .clip(RoundedCornerShape(8.dp)) - .border( - width = 1.dp, - color = Color.White.copy(alpha = 0.16f), - shape = RoundedCornerShape(8.dp), - ), - ) - - // Next-episode panel. - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(20.dp), + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center, ) { - val eyebrow = when { - nextEpisode == null -> if (videoEnded) "Finished" else "More To Watch" - videoEnded -> "Playing Next" - else -> "Up Next" - } - androidx.tv.material3.Text( - text = eyebrow.uppercase(), - color = Color.White.copy(alpha = 0.52f), - style = androidx.tv.material3.MaterialTheme.typography.labelLarge, - ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 80.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(cardSpacing), + verticalAlignment = Alignment.CenterVertically, + ) { + // The existing PlayerView is measured and placed into this pane. + // It stays mounted while the successor prepares. + Box( + modifier = Modifier + .width(onDeckCardWidth * 2 + cardSpacing) + .aspectRatio(16f / 9f) + .onGloballyPositioned { + paneCoordinates = it + paneBoundsInRoot = it.boundsInRoot() + onVideoBoundsChanged(it.videoViewportBounds()) + } + .clip(RoundedCornerShape(8.dp)) + .border( + width = 1.dp, + color = Color.White.copy(alpha = 0.16f), + shape = RoundedCornerShape(8.dp), + ), + ) + + // Next-episode panel. It scrolls on its own when its content + // outgrows the space above On Deck (large font sizes, a + // shuffle's extra rows); the video pane beside it stays put. + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + shuffle?.scopeLabel?.let { scope -> + Row( + modifier = Modifier + .clip(RoundedCornerShape(percent = 50)) + .background(Color.White.copy(alpha = 0.10f)) + .padding(horizontal = 14.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + androidx.tv.material3.Icon( + imageVector = Icons.Filled.Shuffle, + contentDescription = null, + tint = Color.White.copy(alpha = 0.75f), + modifier = Modifier.size(16.dp), + ) + androidx.tv.material3.Text( + text = "Shuffling $scope", + color = Color.White.copy(alpha = 0.75f), + style = androidx.tv.material3.MaterialTheme.typography.labelLarge, + maxLines = 1, + ) + } + } + val eyebrow = when { + nextEpisode == null -> if (videoEnded) "Finished" else "More To Watch" + shuffle != null -> "Up Next at Random" + videoEnded -> "Playing Next" + else -> "Up Next" + } + androidx.tv.material3.Text( + text = eyebrow.uppercase(), + color = Color.White.copy(alpha = 0.52f), + style = androidx.tv.material3.MaterialTheme.typography.labelLarge, + ) + + if (nextEpisode != null) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + // A shuffled movie has no series line; its own title heads the panel. + val heading = nextEpisode.seriesTitle?.takeIf { it.isNotBlank() } + ?: nextEpisode.title.takeUnless { nextEpisode.isEpisode } + heading?.let { title -> + androidx.tv.material3.Text( + text = title, + color = Color.White, + style = androidx.tv.material3.MaterialTheme.typography.headlineSmall, + maxLines = 1, + ) + } + if (nextEpisode.isEpisode) Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + androidx.tv.material3.Text( + text = "S${nextEpisode.seasonNumber}·E${nextEpisode.episodeNumber}", + color = Color.White.copy(alpha = 0.62f), + style = androidx.tv.material3.MaterialTheme.typography.titleMedium, + ) + androidx.tv.material3.Text( + text = nextEpisode.title ?: "Next Episode", + color = Color.White, + style = androidx.tv.material3.MaterialTheme.typography.titleMedium, + maxLines = 1, + modifier = Modifier.weight(1f, fill = false), + ) + // Runtime shares the episode line to keep the panel short. + if (nextEpisode.runtimeMinutes > 0) { + androidx.tv.material3.Text( + text = "· ${nextEpisode.runtimeMinutes} min", + color = Color.White.copy(alpha = 0.46f), + style = androidx.tv.material3.MaterialTheme.typography.titleMedium, + ) + } + } + if (!nextEpisode.isEpisode && nextEpisode.runtimeMinutes > 0) { + androidx.tv.material3.Text( + text = "${nextEpisode.runtimeMinutes} min", + color = Color.White.copy(alpha = 0.46f), + style = androidx.tv.material3.MaterialTheme.typography.labelMedium, + ) + } + val hidesOverview = org.prairieserver.prairie.common.cards.LocalEpisodeSpoilerPrefs.current + .hidesOverview(nextEpisode.isUnwatched) + nextEpisode.overview?.takeIf { it.isNotBlank() && !hidesOverview }?.let { overview -> + androidx.tv.material3.Text( + text = overview, + color = Color.White.copy(alpha = 0.58f), + style = androidx.tv.material3.MaterialTheme.typography.bodyMedium, + maxLines = 2, + ) + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(20.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TvDialogActionRow( + title = "Play Now", + onClick = onPlayNow, + modifier = Modifier + .width(220.dp) + .focusRequester(primaryFocus), + ) + if (countdownSeconds != null) { + TvCountdownRing( + seconds = countdownSeconds, + totalSeconds = countdownTotalSeconds, + ) + } + } - if (nextEpisode != null) { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - nextEpisode.seriesTitle?.takeIf { it.isNotBlank() }?.let { seriesTitle -> + if (shuffle != null) { + TvDialogActionRow( + title = "Pick Another", + onClick = onPickAnother, + enabled = !shuffle.pickingAnother, + modifier = Modifier.width(260.dp), + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + if (!videoEnded) { + TvDialogActionRow( + title = "Keep Watching", + onClick = onKeepWatching, + modifier = Modifier.width(220.dp), + ) + } + TvDialogActionRow( + title = "Back", + onClick = onBack, + modifier = Modifier.width(140.dp), + ) + } + shuffle?.message?.let { message -> + androidx.tv.material3.Text( + text = message, + color = Color(0xFFFFB4AB), + style = androidx.tv.material3.MaterialTheme.typography.bodyMedium, + ) + } + // Interactive toggle (was a dead focusable): OK flips + // auto-play; focus inverts the pill so the D-pad stop is + // visible. + var autoPlayToggleFocused by remember { mutableStateOf(false) } + Row(verticalAlignment = Alignment.CenterVertically) { + androidx.tv.material3.Text( + text = "Auto-play is ${if (autoPlayEnabled) "On" else "Off"}", + color = if (autoPlayToggleFocused) Color.Black else Color.White.copy(alpha = 0.54f), + style = androidx.tv.material3.MaterialTheme.typography.labelMedium, + modifier = Modifier + .onFocusChanged { autoPlayToggleFocused = it.isFocused } + .clip(RoundedCornerShape(percent = 50)) + .background(if (autoPlayToggleFocused) Color.White else Color.Transparent) + .clickable { onToggleAutoPlay() } + .padding(horizontal = 12.dp, vertical = 4.dp), + ) + if (shuffle != null) { + var stopFocused by remember { mutableStateOf(false) } + androidx.tv.material3.Text( + text = "·", + color = Color.White.copy(alpha = 0.54f), + style = androidx.tv.material3.MaterialTheme.typography.labelMedium, + ) + androidx.tv.material3.Text( + text = "Stop shuffling", + color = if (stopFocused) Color.Black else Color.White.copy(alpha = 0.54f), + style = androidx.tv.material3.MaterialTheme.typography.labelMedium, + modifier = Modifier + .onFocusChanged { stopFocused = it.isFocused } + .clip(RoundedCornerShape(percent = 50)) + .background(if (stopFocused) Color.White else Color.Transparent) + .clickable { onStopShuffling() } + .padding(horizontal = 12.dp, vertical = 4.dp), + ) + } + } + } else { + // Finished / no-next-episode state. androidx.tv.material3.Text( - text = seriesTitle, + text = if (videoEnded) "End of playback" else "Almost finished", color = Color.White, style = androidx.tv.material3.MaterialTheme.typography.headlineSmall, - maxLines = 1, ) - } - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { androidx.tv.material3.Text( - text = "S${nextEpisode.seasonNumber}·E${nextEpisode.episodeNumber}", + text = if (shuffle != null) "Nothing else in this shuffle can play." else "No next episode is available.", color = Color.White.copy(alpha = 0.62f), - style = androidx.tv.material3.MaterialTheme.typography.titleMedium, - ) - androidx.tv.material3.Text( - text = nextEpisode.title ?: "Next Episode", - color = Color.White, - style = androidx.tv.material3.MaterialTheme.typography.titleMedium, - maxLines = 2, - ) - } - if (nextEpisode.runtimeMinutes > 0) { - androidx.tv.material3.Text( - text = "${nextEpisode.runtimeMinutes} min", - color = Color.White.copy(alpha = 0.46f), - style = androidx.tv.material3.MaterialTheme.typography.labelMedium, - ) - } - nextEpisode.overview?.takeIf { it.isNotBlank() }?.let { overview -> - androidx.tv.material3.Text( - text = overview, - color = Color.White.copy(alpha = 0.58f), style = androidx.tv.material3.MaterialTheme.typography.bodyMedium, - maxLines = 3, ) + if (!videoEnded) { + TvDialogActionRow( + title = "Keep Watching", + onClick = onKeepWatching, + modifier = Modifier + .width(260.dp) + .focusRequester(primaryFocus), + ) + TvDialogActionRow( + title = "Back", + onClick = onBack, + modifier = Modifier.width(160.dp), + ) + } else { + TvDialogActionRow( + title = "Back", + onClick = onBack, + modifier = Modifier + .width(160.dp) + .focusRequester(primaryFocus), + ) + } } } + } + } + if (onDeckItems.isNotEmpty()) { + TvNextUpOnDeckRow( + items = onDeckItems, + cardWidth = onDeckCardWidth, + cardSpacing = cardSpacing, + onPlay = onPlayOnDeckItem, + ) + } + } + } + } +} - Row( - horizontalArrangement = Arrangement.spacedBy(20.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - TvDialogActionRow( - title = "Play Now", - onClick = onPlayNow, - modifier = Modifier - .width(220.dp) - .focusRequester(primaryFocus), +/** + * Full-screen artwork behind the Up Next overlay (tvOS `backgroundArtwork`): + * dimmed so it tints rather than shows. `Modifier.blur` does nothing before + * Android 12 (the SHIELD runs 11), so the softness comes from decoding the + * image tiny and scaling it up. The [hole] — the mini-player pane — is + * cleared so the video drawn beneath this overlay stays visible. + */ +@Composable +private fun TvNextUpBackdrop(url: String?, thumbhash: String?, hole: () -> androidx.compose.ui.geometry.Rect?) { + if (url.isNullOrBlank() && thumbhash.isNullOrBlank()) return + val holeRadius = with(androidx.compose.ui.platform.LocalDensity.current) { 8.dp.toPx() } + Box( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + hole()?.let { rect -> + drawRoundRect( + color = Color.Black, + topLeft = rect.topLeft, + size = rect.size, + cornerRadius = androidx.compose.ui.geometry.CornerRadius(holeRadius), + blendMode = androidx.compose.ui.graphics.BlendMode.Clear, + ) + } + }, + ) { + ThumbhashImage( + url = url, + thumbhash = thumbhash, + contentDescription = null, + decodeSizePx = 48, + // Its own cache entry: under the URL alone, a full-size copy + // cached by another screen comes back sharp instead of tiny. + cacheKey = url?.let { "next-up-backdrop:$it" }, + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + scaleX = 1.12f + scaleY = 1.12f + }, + ) + // tvOS dims to ~4% (0.18 under a 0.78 scrim), which barely reads on a + // TV in a dim room; keep ~40% and let the scrim carry legibility. + Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.6f))) + // Deeper at the bottom so the On Deck row reads over the art. + Box( + Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + 0.55f to Color.Transparent, + 1f to Color.Black.copy(alpha = 0.85f), + ), + ), + ) + } +} + +/** The Up Next overlay's On Deck row: other items in progress (tvOS parity). */ +@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) +@Composable +private fun TvNextUpOnDeckRow( + items: List<TvOnDeckItem>, + cardWidth: androidx.compose.ui.unit.Dp, + cardSpacing: androidx.compose.ui.unit.Dp, + onPlay: (TvOnDeckItem) -> Unit, +) { + val spoilers = org.prairieserver.prairie.common.cards.LocalEpisodeSpoilerPrefs.current + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 28.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + androidx.tv.material3.Text( + text = "On Deck", + color = Color.White.copy(alpha = 0.86f), + style = androidx.tv.material3.MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 80.dp), + ) + // TV's default focus pivot slides the row so the focused card sits a + // third of the way across, shifting it left as focus arrives from the + // panel; scroll only when a card nears an edge. + CompositionLocalProvider(LocalBringIntoViewSpec provides TvSmoothBringIntoViewSpec) { + androidx.compose.foundation.lazy.LazyRow( + contentPadding = PaddingValues(horizontal = 80.dp), + horizontalArrangement = Arrangement.spacedBy(cardSpacing), + ) { + items(items.size, key = { items[it].contentId }) { index -> + val item = items[index] + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + TvMediaCard( + title = item.title, + hideArtwork = spoilers.hidesImage(item.unwatchedEpisode, item.artIsEpisodeStill), + posterUrl = item.artUrl, + posterThumbhash = item.artThumbhash, + onClick = { onPlay(item) }, + progress = item.progressFraction, + artworkAspectRatio = 16f / 9f, + width = cardWidth, ) - if (countdownSeconds != null) { - TvCountdownRing( - seconds = countdownSeconds, - totalSeconds = countdownTotalSeconds, + item.subtitle?.let { subtitle -> + androidx.tv.material3.Text( + text = subtitle, + color = Color.White.copy(alpha = 0.52f), + style = androidx.tv.material3.MaterialTheme.typography.labelMedium, + maxLines = 1, + modifier = Modifier.width(cardWidth), ) } } - - if (!videoEnded) { - TvDialogActionRow( - title = "Keep Watching", - onClick = onKeepWatching, - modifier = Modifier.width(260.dp), - ) - } - TvDialogActionRow( - title = "Back", - onClick = onBack, - modifier = Modifier.width(160.dp), - ) - // Interactive toggle (was a dead focusable): OK flips - // auto-play; focus inverts the pill so the D-pad stop is - // visible. - var autoPlayToggleFocused by remember { mutableStateOf(false) } - androidx.tv.material3.Text( - text = "Auto-play is ${if (autoPlayEnabled) "On" else "Off"}", - color = if (autoPlayToggleFocused) Color.Black else Color.White.copy(alpha = 0.54f), - style = androidx.tv.material3.MaterialTheme.typography.labelMedium, - modifier = Modifier - .onFocusChanged { autoPlayToggleFocused = it.isFocused } - .clip(RoundedCornerShape(percent = 50)) - .background(if (autoPlayToggleFocused) Color.White else Color.Transparent) - .clickable { onToggleAutoPlay() } - .padding(horizontal = 12.dp, vertical = 4.dp), - ) - } else { - // Finished / no-next-episode state. - androidx.tv.material3.Text( - text = if (videoEnded) "End of playback" else "Almost finished", - color = Color.White, - style = androidx.tv.material3.MaterialTheme.typography.headlineSmall, - ) - androidx.tv.material3.Text( - text = "No next episode is available.", - color = Color.White.copy(alpha = 0.62f), - style = androidx.tv.material3.MaterialTheme.typography.bodyMedium, - ) - if (!videoEnded) { - TvDialogActionRow( - title = "Keep Watching", - onClick = onKeepWatching, - modifier = Modifier - .width(260.dp) - .focusRequester(primaryFocus), - ) - TvDialogActionRow( - title = "Back", - onClick = onBack, - modifier = Modifier.width(160.dp), - ) - } else { - TvDialogActionRow( - title = "Back", - onClick = onBack, - modifier = Modifier - .width(160.dp) - .focusRequester(primaryFocus), - ) - } } } } @@ -3147,65 +3685,6 @@ private fun TvCountdownRing(seconds: Int, totalSeconds: Int) { } } -@Composable -private fun TvRoomCloseConfirmDialog( - onClose: () -> Unit, - onCancel: () -> Unit, -) { - val closeActionFocus = remember { FocusRequester() } - - Popup( - alignment = Alignment.Center, - onDismissRequest = onCancel, - properties = PopupProperties( - focusable = true, - dismissOnBackPress = true, - dismissOnClickOutside = false, - clippingEnabled = false, - ), - ) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.72f)), - contentAlignment = Alignment.Center, - ) { - Column( - modifier = Modifier - .clip(RoundedCornerShape(24.dp)) - .background(Color.Black.copy(alpha = 0.92f)) - .padding(40.dp) - .then(rememberTvDialogInitialFocus(closeActionFocus)), - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - androidx.tv.material3.Text( - text = "Close this room?", - color = Color.White, - style = androidx.tv.material3.MaterialTheme.typography.titleLarge, - ) - androidx.tv.material3.Text( - text = "Closing ends Watch Together for everyone in the room.", - color = Color.White.copy(alpha = 0.80f), - style = androidx.tv.material3.MaterialTheme.typography.bodyMedium, - ) - TvDialogActionRow( - title = "Close room for everyone", - onClick = onClose, - modifier = Modifier - .width(360.dp) - .focusRequester(closeActionFocus), - ) - TvDialogActionRow( - title = "Keep watching", - onClick = onCancel, - modifier = Modifier.width(360.dp), - ) - } - } - } -} - private fun PlayerTrackEntry.toVideoTrackEntry(): VideoPlayerTrackEntry = VideoPlayerTrackEntry( index = index, @@ -3308,89 +3787,23 @@ private fun Format.subtitleCodecOrMime(): String? = } /** - * A selectable video quality variant. Unlike [extractTrackEntries] (which - * collapses every video group to a single group-level entry), this flattens the - * individual formats *inside* the video group(s) — the real resolution / bitrate - * variants of the stream — so the HUD Quality picker can surface genuine - * options. [id] encodes `"<groupOrdinal>:<trackIndex>"`; the synthetic `"-1"` - * id means Auto (adaptive — clears any override). + * A row of the HUD Quality picker, built from the plan's quality menu by + * [authoritativePlaybackQualityOptions]. [id] is the `quality_preference` a + * pick sends: a server entry's label, or `auto`. */ data class VideoQualityOption( val id: String, val label: String, val isSelected: Boolean, val resolution: String? = null, + val bitrateLabel: String? = null, ) -internal const val VIDEO_QUALITY_AUTO_ID = "-1" - /** - * Flatten the current [Tracks] video group(s) into per-format quality options. - * Each format becomes a resolution/bitrate-labelled option. "Auto" is prepended - * and is selected whenever no single format override is active (adaptive). + * Prairie Cast's Auto id from before the menu carried its own `auto` row: still + * accepted from remotes, and reported when no row is selected. */ -internal fun extractVideoQualityOptions(tracks: Tracks): List<VideoQualityOption> { - val variants = mutableListOf<VideoQualityOption>() - val selectedFlags = mutableListOf<Boolean>() - var videoGroupOrdinal = 0 - for (group in tracks.groups) { - if (group.type != C.TRACK_TYPE_VIDEO) continue - val mediaGroup = group.mediaTrackGroup - for (trackIndex in 0 until mediaGroup.length) { - val format = mediaGroup.getFormat(trackIndex) - selectedFlags.add(group.isTrackSelected(trackIndex)) - variants.add( - VideoQualityOption( - id = "$videoGroupOrdinal:$trackIndex", - label = formatVideoQualityLabel(format, trackIndex), - isSelected = false, - resolution = format.height.takeIf { it > 0 }?.let { "${it}p" }, - ), - ) - } - videoGroupOrdinal++ - } - if (variants.isEmpty()) return emptyList() - - // An explicit single-variant override is in effect only when EXACTLY one - // variant is selected among multiple. Several selected (or none) = adaptive, - // so Auto is the active option. (A single-variant group is trivially "Auto" - // — there is nothing to switch.) - val hasMultipleVariants = variants.size > 1 - val selectedCount = selectedFlags.count { it } - val overrideActive = hasMultipleVariants && selectedCount == 1 - val selectedVariantIndex = if (overrideActive) selectedFlags.indexOfFirst { it } else -1 - - val resolved = variants.mapIndexed { idx, v -> - v.copy(isSelected = idx == selectedVariantIndex) - } - return buildList { - add( - VideoQualityOption( - id = VIDEO_QUALITY_AUTO_ID, - label = "Auto", - isSelected = !overrideActive, - ), - ) - addAll(resolved) - } -} - -private fun formatVideoQualityLabel(format: Format, trackIndex: Int): String { - val height = format.height.takeIf { it > 0 } - val resolution = when { - height != null -> "${height}p" - else -> null - } - val bitrate = format.bitrate.takeIf { it > 0 }?.let { bps -> - when { - bps >= 1_000_000 -> "%.1f Mbps".format(bps / 1_000_000.0) - else -> "%.0f Kbps".format(bps / 1_000.0) - } - } - val parts = listOfNotNull(resolution, bitrate) - return if (parts.isEmpty()) "Variant ${trackIndex + 1}" else parts.joinToString(" · ") -} +internal const val VIDEO_QUALITY_AUTO_ID = "-1" internal fun resizeModeForVideoFillMode(mode: VideoFillMode): Int = when (mode) { VideoFillMode.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT @@ -3434,40 +3847,50 @@ private fun TvPlayerViewModel.UiState.toPrairieCastPlaybackState( contentId = contentId, sessionId = sessionId, title = title, + // tvOS shape: "Series · S1 · E2", so remotes read the same for either TV. subtitle = listOfNotNull( + seriesTitle?.takeIf { it.isNotBlank() }, seasonNumber?.let { "S$it" }, episodeNumber?.let { "E$it" }, - ).joinToString(" ").ifBlank { null }, + ).joinToString(" · ").ifBlank { null }, isPlaying = isPlaying && !isPaused, isLoading = isLoading, isBuffering = isBuffering, currentTime = position, duration = duration, - audioTracks = audioTracks.map { it.toSiloCastTrack(kind = "audio") }, - subtitleTracks = subtitleTracks.map { it.toSiloCastTrack(kind = "subtitle") }, + audioTracks = audioTracks.map { it.toPrairieCastTrack(kind = "audio") }, + subtitleTracks = subtitleTracks.map { it.toPrairieCastTrack(kind = "subtitle") }, selectedAudioTrackId = audioTracks.firstOrNull { it.isSelected }?.index?.toLong(), selectedSubtitleTrackId = subtitleTracks.firstOrNull { it.isSelected }?.index?.toLong(), - qualityOptions = videoQualities.map { it.toSiloCastQualityOption() }, + qualityOptions = videoQualities.map { it.toPrairieCastQualityOption() }, activeQualityId = activeQualityId, isQualitySwitching = false, playbackSpeed = playbackSpeed, - videoGravity = videoFillMode.name.lowercase(), + // Wire values are tvOS VideoGravity raw values: fit, fill, stretch. + videoGravity = when (videoFillMode) { + VideoFillMode.Fit -> "fit" + VideoFillMode.Zoom -> "fill" + VideoFillMode.Stretch -> "stretch" + }, hdrEnabled = hdrEnabled, supportsVideoGravity = true, - supportsHDRToggle = true, + // Remotes no longer offer the toggle (Apple dropped it too); the key + // stays on the wire because older remotes require it. + supportsHDRToggle = false, subtitleSyncMs = subtitleDelayMs, - subtitlePosition = subtitleAppearance.position.toSiloCastPositionValue(), + subtitlePosition = subtitleAppearance.position.toPrairieCastPositionValue(), supportsSubtitleDelay = true, supportsSubtitlePosition = true, volume = volumeState.volume, isMuted = volumeState.isMuted, - hasNextEpisode = nextEpisode != null, - nextEpisodeTitle = nextEpisode?.title, + // A shuffle replaces the series order: the remote offers no sequential next. + hasNextEpisode = nextEpisode != null && shuffle == null, + nextEpisodeTitle = nextEpisode?.title?.takeIf { shuffle == null }, error = error, ) } -private fun PlayerTrackEntry.toSiloCastTrack(kind: String): PrairieCastTrack = +private fun PlayerTrackEntry.toPrairieCastTrack(kind: String): PrairieCastTrack = PrairieCastTrack( kind = kind, trackId = index.toLong(), @@ -3475,7 +3898,7 @@ private fun PlayerTrackEntry.toSiloCastTrack(kind: String): PrairieCastTrack = detail = language, ) -private fun VideoQualityOption.toSiloCastQualityOption(): PrairieCastQualityOption = +private fun VideoQualityOption.toPrairieCastQualityOption(): PrairieCastQualityOption = PrairieCastQualityOption( id = id, label = label, @@ -3483,20 +3906,20 @@ private fun VideoQualityOption.toSiloCastQualityOption(): PrairieCastQualityOpti ) // Apple SubtitlePositionPreset raw values: "bottom", "lower-third", "top". -private fun SubtitlePositionPreset.toSiloCastPositionValue(): String = when (this) { +private fun SubtitlePositionPreset.toPrairieCastPositionValue(): String = when (this) { SubtitlePositionPreset.Top -> "top" SubtitlePositionPreset.LowerThird -> "lower-third" SubtitlePositionPreset.Bottom -> "bottom" } -private fun String.toSiloCastVideoFillMode(): VideoFillMode = +private fun String.toPrairieCastVideoFillMode(): VideoFillMode = when (trim().lowercase()) { "zoom", "crop", "fill" -> VideoFillMode.Zoom "stretch" -> VideoFillMode.Stretch else -> VideoFillMode.Fit } -private fun String.toSiloCastSubtitlePosition(): SubtitlePositionPreset { +private fun String.toPrairieCastSubtitlePosition(): SubtitlePositionPreset { when (trim().lowercase()) { "top" -> return SubtitlePositionPreset.Top "lower-third", "lower_third", "lowerthird" -> return SubtitlePositionPreset.LowerThird @@ -3527,44 +3950,9 @@ private fun TvPlayerClockScope( content(clock) } -/** - * Apply (or clear, for [VIDEO_QUALITY_AUTO_ID]) a video quality override on the - * player. Mirrors [AudioTrackManager]'s override approach but targets a specific - * format *within* the video group. This is a real Media3 track switch. - */ -internal fun selectVideoQuality(player: Player, id: String): Boolean { - if (id == VIDEO_QUALITY_AUTO_ID) { - player.trackSelectionParameters = player.trackSelectionParameters - .buildUpon() - .clearOverridesOfType(C.TRACK_TYPE_VIDEO) - .build() - return true - } - val parts = id.split(":") - val groupOrdinal = parts.getOrNull(0)?.toIntOrNull() ?: return false - val trackIndex = parts.getOrNull(1)?.toIntOrNull() ?: return false - var ordinal = 0 - for (group in player.currentTracks.groups) { - if (group.type != C.TRACK_TYPE_VIDEO) continue - if (ordinal == groupOrdinal) { - val mediaGroup = group.mediaTrackGroup - if (trackIndex !in 0 until mediaGroup.length) return false - player.trackSelectionParameters = player.trackSelectionParameters - .buildUpon() - .setOverrideForType( - androidx.media3.common.TrackSelectionOverride(mediaGroup, trackIndex), - ) - .build() - return true - } - ordinal++ - } - return false -} - /** * The overlay layer stacked above the player surface: lifecycle notice, remote - * message toast, Watch Together indicator and close confirmation, the Up Next + * message toast, Watch Party indicator, the Up Next * surface, the intro auto-skip banner, and the reconnect spinner. * * Split out of [TvPlayerScreen] to keep that composable's generated method @@ -3577,19 +3965,22 @@ internal fun selectVideoQuality(player: Player, id: String): Boolean { private fun TvPlayerOverlays( isInPictureInPictureMode: Boolean, notice: PlayerNotice?, + subtitleSyncNotice: StateFlow<SubtitleSyncNotice?>, remoteMessage: RemoteMessage?, roomSnapshot: RoomSnapshot?, roomActive: Boolean, showControls: Boolean, hudOpen: Boolean, - showLeaveDialog: Boolean, showNextUp: Boolean, nextEpisode: NextEpisodeState?, nextUpVideoEnded: Boolean, nextUpCountdownSeconds: Int?, nextUpCountdownTotalSeconds: Int, + shuffle: TvShuffleUiState?, autoPlayNextEnabled: Boolean, introSkipState: IntroAutoSkipState, + manualSkipTarget: MarkerSkipTarget?, + onManualMarkerSelect: () -> Unit, /** Bumps when the pill's timer (re)starts, so its fill re-anchors. */ introSkipCountdownRun: Int, /** False while the pill is up but its timer is frozen by a pause. */ @@ -3602,14 +3993,18 @@ private fun TvPlayerOverlays( isBuffering: Boolean, sleepTimerState: SleepTimerState, showSpinner: Boolean, - onCloseRoom: () -> Unit, - onCancelLeaveDialog: () -> Unit, onPlayNextNow: () -> Unit, onKeepWatching: () -> Unit, + onPickAnother: () -> Unit, + onStopShuffling: () -> Unit, onToggleAutoPlayNext: () -> Unit, onExitPlayback: () -> Unit, onNextUpVideoBoundsChanged: (Rect) -> Unit, onIntroPromptSelect: () -> Unit, + onDeckItems: List<TvOnDeckItem> = emptyList(), + onPlayOnDeckItem: (TvOnDeckItem) -> Unit = {}, + /** Watch Party: the lower rung offered after repeated stalls, or null. */ + qualityOfferLabel: String? = null, ) { // Lifecycle-driven notice toast (top-start). Slides in for outage // recovery, fades out when the lifecycle clears the notice. @@ -3624,6 +4019,22 @@ private fun TvPlayerOverlays( } } + // Subtitle sync card (top-end): follows a sync this viewer started. + // Never focusable. The HUD's Timing row shows the same state and its + // tabs sit where the card would, so the card waits for the HUD to close. + if (!isInPictureInPictureMode) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = 32.dp, end = 32.dp) + .zIndex(9f), + contentAlignment = Alignment.TopEnd, + ) { + val syncNotice by subtitleSyncNotice.collectAsState() + TvSubtitleSyncCard(notice = syncNotice.takeUnless { hudOpen }) + } + } + // Remote-control "display_message" toast (top-center), shown a few // seconds regardless of controls visibility. if (!isInPictureInPictureMode) remoteMessage?.let { message -> @@ -3651,10 +4062,10 @@ private fun TvPlayerOverlays( } } - // Watch Together room indicator (top-end so it doesn't collide with - // the top-start lifecycle notice). Member count, a "Waiting for - // members…" pill while the room is on the wait barrier, and the join - // code for the host. Only shown while the idle overlay is up. + // Watch Party indicator (top-end so it doesn't collide with the + // top-start lifecycle notice): member count and, for the host, the + // party code. Only shown while the idle overlay is up. Waiting and + // catching-up status is the party status overlay's job. val snapshot = roomSnapshot if (!isInPictureInPictureMode && roomActive && snapshot != null && showControls && !hudOpen) { Box( @@ -3665,7 +4076,6 @@ private fun TvPlayerOverlays( ) { TvRoomIndicator( memberCount = snapshot.memberCount, - waiting = snapshot.playbackState == RoomPlaybackState.Waiting, joinCode = snapshot.code.takeIf { snapshot.selfCanManageRoom && it.isNotBlank() }, ) } @@ -3763,15 +4173,6 @@ private fun TvPlayerOverlays( } } - // Host close-confirm dialog. Closing tears the room down for everyone - // (server emits room_closed → every member exits). Cancel resumes. - if (!isInPictureInPictureMode && showLeaveDialog && roomActive) { - TvRoomCloseConfirmDialog( - onClose = onCloseRoom, - onCancel = onCancelLeaveDialog, - ) - } - // F2 / Up-Next end-of-playback surface. Replaces the old "Still // watching?" dialog: a 16:9 mini-player (the still-playing video, // visible behind a bordered frame) beside a next-episode panel with @@ -3789,6 +4190,11 @@ private fun TvPlayerOverlays( onToggleAutoPlay = onToggleAutoPlayNext, onBack = onExitPlayback, onVideoBoundsChanged = onNextUpVideoBoundsChanged, + shuffle = shuffle, + onPickAnother = onPickAnother, + onStopShuffling = onStopShuffling, + onDeckItems = onDeckItems, + onPlayOnDeckItem = onPlayOnDeckItem, ) } } @@ -3813,6 +4219,12 @@ private fun TvPlayerOverlays( .padding(bottom = introSkipBottomInset, end = 32.dp), contentAlignment = Alignment.BottomEnd, ) { + if (manualSkipTarget != null) { + TvMarkerSkipPill( + label = stringResource(if (manualSkipTarget.kind == ManualMarkerKind.Recap) R.string.marker_skip_recap else R.string.marker_skip_credits), + onSelect = onManualMarkerSelect, + ) + } TvIntroAutoSkipBanner( state = introSkipState, onSelect = onIntroPromptSelect, @@ -3828,6 +4240,30 @@ private fun TvPlayerOverlays( } } + // Watch Party quality offer (bottom-start, clear of the intro pill). + // OK takes it and Back dismisses it, both through the remote bridge. + if (!isInPictureInPictureMode && qualityOfferLabel != null) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(bottom = 56.dp, start = 32.dp), + contentAlignment = Alignment.BottomStart, + ) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(percent = 50)) + .background(Color.Black.copy(alpha = 0.72f)) + .padding(horizontal = 20.dp, vertical = 10.dp), + ) { + Text( + text = "Keeps buffering? Press OK to switch to $qualityOfferLabel.", + color = Color.White, + style = MaterialTheme.typography.labelLarge, + ) + } + } + } + // Outage spinner. Native ExoPlayer buffering surfaces as the top-right // Buffering capsule (mirroring tvOS), so this centered spinner is // reserved for the lifecycle Reconnecting state: the server-outage @@ -3847,3 +4283,21 @@ private fun TvPlayerOverlays( } } } + +/** + * Pads the cue canvas's bottom so cues sit at least [reachPx] above the + * bottom of the screen, clear of the controls; 0 clears the lift. + */ +private fun liftSubtitlesClearOfControls(subtitleView: android.view.View, reachPx: Int) { + val liftPx = if (reachPx > 0) { + val location = IntArray(2) + subtitleView.getLocationInWindow(location) + val belowView = subtitleView.rootView.height - (location[1] + subtitleView.height) + (reachPx - belowView).coerceIn(0, subtitleView.height / 2) + } else { + 0 + } + if (subtitleView.paddingBottom != liftPx) { + subtitleView.setPadding(subtitleView.paddingLeft, subtitleView.paddingTop, subtitleView.paddingRight, liftPx) + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScrubber.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScrubber.kt index 83a06e5968..f2fd3d3e9d 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScrubber.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScrubber.kt @@ -1,5 +1,8 @@ package org.prairieserver.prairie.tv.ui.screens.player +import androidx.compose.foundation.Canvas +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.platform.LocalContext import android.os.SystemClock import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween @@ -133,6 +136,8 @@ fun TvPlayerScrubber( onPlayPause: () -> Unit, /** See TvPlayerIdleOverlay.canToggleAfterCommit. */ canToggleAfterCommit: Boolean = true, + /** The current rate, so the wall-clock finish time matches what the viewer will see. */ + playbackSpeed: Double = 1.0, onMoveDownToTransport: () -> Unit, onExitWhenIdle: () -> Unit, modifier: Modifier = Modifier, @@ -240,7 +245,11 @@ fun TvPlayerScrubber( } else 0f val trackHeight by animateDpAsState( - targetValue = if (isTimelineScrubbing) 6.dp else 3.5.dp, + targetValue = when { + isTimelineScrubbing -> 7.dp + isFocused -> 5.dp + else -> 4.dp + }, animationSpec = tween(120), label = "scrubberTrackHeight", ) @@ -254,37 +263,17 @@ fun TvPlayerScrubber( label = "scrubberPuckSize", ) - Column( - modifier = modifier.height(41.dp), - verticalArrangement = Arrangement.spacedBy(5.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = formatScrubberTime(labelPositionSec), - color = Color.White.copy(alpha = 0.82f), - style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.SemiBold), - ) - Text( - text = formatRemainingTime(durationSec - labelPositionSec), - color = Color.White.copy(alpha = 0.70f), - style = MaterialTheme.typography.labelLarge, - ) - } - - Box(modifier = Modifier.weight(1f)) { + // The bar sits above its clock row, as on tvOS: elapsed on the left, time + // left and the wall-clock finish on the right. + val context = LocalContext.current + val timeFormat = remember(context) { android.text.format.DateFormat.getTimeFormat(context) } + Column(modifier = modifier) { + Box(modifier = Modifier.fillMaxWidth().height(28.dp)) { BoxWithConstraints( modifier = Modifier .fillMaxWidth() .align(Alignment.Center) - // Required, not preferred: the column above hands this box - // ~14dp once the clock row has taken its share, and a plain - // height() is coerced to that, squashing the puck and - // clipping the auto-seek chip. .requiredHeight(28.dp) .focusRequester(onRequestFocus) .onFocusChanged { /* state collected via interactionSource */ } @@ -383,99 +372,52 @@ fun TvPlayerScrubber( val barWidthPx = with(density) { barWidthDp.toPx() } val puckSizePx = with(density) { puckSize.toPx() } - // Track (unfocused 0.24, focused 0.35, scrubbing 0.48 — spec). - Box( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.Center) - .height(trackHeight) - .clip(RoundedCornerShape(percent = 50)) - .background( - Color.White.copy( - alpha = when { - isTimelineScrubbing -> 0.48f - isFocused -> 0.35f - else -> 0.24f - }, - ), - ), - ) - - // Marker bands — intro/recap/credits/preview, each a tinted band on - // the track. Drawn above the bare track but below the played fill / - // ticks so the playhead still reads clearly over it. - if (durationSec > 0) { - val bandAlpha = if (isTimelineScrubbing || isFocused) 0.45f else 0.34f - val markers = listOfNotNull( - introRangeSec?.let { it to Color.Cyan }, + // Track, marker bands, buffered-ahead and played fill, drawn as one + // segmented bar: chapter starts become 2dp gaps rather than ticks. + val trackAlpha = when { + isTimelineScrubbing -> 0.45f + isFocused -> 0.32f + else -> 0.22f + } + val bandAlpha = if (isTimelineScrubbing || isFocused) 0.45f else 0.34f + val markerBands = if (durationSec > 0) { + listOfNotNull( + introRangeSec?.let { it to Color(0xFF22D3EE) }, recapRangeSec?.let { it to Color(0xFF8BC34A) }, creditsRangeSec?.let { it to Color(0xFFFFB74D) }, previewRangeSec?.let { it to Color(0xFFBA68C8) }, - ) - for ((range, color) in markers) { - val start = (range.start / durationSec).toFloat().coerceIn(0f, 1f) - val end = (range.endInclusive / durationSec).toFloat().coerceIn(0f, 1f) - if (end > start) { - Box( - modifier = Modifier - .align(Alignment.CenterStart) - .offset(x = barWidthDp * start) - .fillMaxWidth(end - start) - .height(trackHeight) - .clip(RoundedCornerShape(percent = 50)) - .background(color.copy(alpha = bandAlpha)), - ) - } + ).map { (range, color) -> + Triple( + (range.start / durationSec).toFloat().coerceIn(0f, 1f), + (range.endInclusive / durationSec).toFloat().coerceIn(0f, 1f), + color.copy(alpha = bandAlpha), + ) } + } else { + emptyList() } - - // Played fill — pure white, follows the preview while scrubbing. - Box( + val chapterFractions = if (durationSec > 0) { + chapters.map { (it.timeSec / durationSec).toFloat() } + } else { + emptyList() + } + Canvas( modifier = Modifier - .fillMaxWidth(totalProgress) - .align(Alignment.CenterStart) + .fillMaxWidth() .height(trackHeight) - .clip(RoundedCornerShape(percent = 50)) - .background(Color.White), - ) - - // Buffered-ahead sliver: only the region between playhead and - // end-of-buffer. Width can be 0 (live HLS, transcode start). - val bufferedAheadFrac = (bufferedFrac - totalProgress).coerceAtLeast(0f) - if (bufferedAheadFrac > 0f) { - Box( - modifier = Modifier - .align(Alignment.CenterStart) - .offset(x = barWidthDp * totalProgress) - .fillMaxWidth(bufferedAheadFrac) - .height(trackHeight) - .clip(RoundedCornerShape(percent = 50)) - .background(Color.White.copy(alpha = 0.28f)), + .align(Alignment.Center), + ) { + drawTvSegmentedTrack( + chapterFractions = chapterFractions, + trackColor = Color.White.copy(alpha = trackAlpha), + bufferedColor = Color.White.copy(alpha = 0.20f), + playedColor = TvPlayerChrome.Paper, + playedFraction = totalProgress, + bufferedFraction = bufferedFrac.coerceAtLeast(totalProgress), + bands = markerBands, ) } - // Chapter marker ticks — skip the chapter-0 tick at x≈0 so it doesn't - // sit under the capsule endcap. Iterate with `for` (rather than - // `forEach`) so the lambda body keeps composable scope. - if (durationSec > 0) { - for (ch in chapters) { - val frac = (ch.timeSec / durationSec).toFloat().coerceIn(0f, 1f) - if (frac > 0.001f) { - Box( - modifier = Modifier - // CenterStart, not Center: `offset` is anchor-relative, - // so Center adds half the bar width to every tick. - .align(Alignment.CenterStart) - .offset(x = barWidthDp * frac - 1.5.dp) - .width(if (isTimelineScrubbing) 3.dp else 2.dp) - .height(trackHeight + 8.dp) - .clip(RoundedCornerShape(percent = 50)) - .background(Color.White.copy(alpha = 0.45f)), - ) - } - } - } - // Puck — only while focused so the bar stays passive when // attention is elsewhere on the overlay. if (isFocused || isTimelineScrubbing) { @@ -520,9 +462,76 @@ fun TvPlayerScrubber( ) } } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = formatScrubberTime(labelPositionSec), + style = TvPlayerType.Time, + modifier = Modifier.weight(1f), + ) + val remainingSec = (durationSec - labelPositionSec).coerceAtLeast(0.0) + val wallClockRemainingSec = if (playbackSpeed > 0.0) remainingSec / playbackSpeed else remainingSec + Text( + text = listOfNotNull( + formatRemainingTime(remainingSec), + finishTimeLabel(timeFormat, wallClockRemainingSec)?.let { "Ends $it" }, + ).joinToString(" · "), + style = TvPlayerType.Time.copy(color = TvPlayerChrome.Graphite), + ) + } + } +} + +/** + * The bar as segments split at chapter starts. Gaps are dropped when a title + * has so many short chapters that the segments would stop reading as bars. + */ +private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawTvSegmentedTrack( + chapterFractions: List<Float>, + trackColor: Color, + bufferedColor: Color, + playedColor: Color, + playedFraction: Float, + bufferedFraction: Float, + bands: List<Triple<Float, Float, Color>>, +) { + val width = size.width + val height = size.height + val radius = androidx.compose.ui.geometry.CornerRadius(height / 2f) + val gap = 2.dp.toPx() + val boundaries = chapterFractions.filter { it > 0.001f && it < 0.999f }.sorted() + val edges = (listOf(0f) + boundaries + listOf(1f)).map { it * width } + val useGaps = boundaries.isNotEmpty() && edges.zipWithNext().all { (a, b) -> b - a >= height * 2f + gap } + val segments = if (useGaps) edges.zipWithNext() else listOf(0f to width) + segments.forEachIndexed { index, (rawStart, rawEnd) -> + val start = if (index == 0) rawStart else rawStart + gap / 2f + val end = if (index == segments.lastIndex) rawEnd else rawEnd - gap / 2f + val topLeft = androidx.compose.ui.geometry.Offset(start, 0f) + val segmentSize = androidx.compose.ui.geometry.Size(end - start, height) + fun fill(color: Color, untilX: Float, fromX: Float = start) { + if (untilX <= fromX) return + clipRect(left = fromX, top = 0f, right = untilX.coerceAtMost(end), bottom = height) { + drawRoundRect(color = color, topLeft = topLeft, size = segmentSize, cornerRadius = radius) + } + } + drawRoundRect(color = trackColor, topLeft = topLeft, size = segmentSize, cornerRadius = radius) + fill(bufferedColor, bufferedFraction * width) + bands.forEach { (from, to, color) -> fill(color, untilX = to * width, fromX = maxOf(start, from * width)) } + fill(playedColor, playedFraction * width) } } +/** "11:42 PM" in the device's 12/24-hour [timeFormat], [remainingSec] of wall-clock time from now. */ +private fun finishTimeLabel(timeFormat: java.text.DateFormat, remainingSec: Double): String? { + if (remainingSec <= 0.0 || !remainingSec.isFinite()) return null + val finish = java.util.Date(System.currentTimeMillis() + (remainingSec * 1000).toLong()) + return timeFormat.format(finish) } @Composable @@ -555,7 +564,7 @@ private fun TvScrubberAutoSeekChip(rate: Int, modifier: Modifier = Modifier) { private fun formatScrubberTime(seconds: Double): String = formatTimelineClock(seconds) private fun formatRemainingTime(secondsRemaining: Double): String = - "-${formatTimelineClock(secondsRemaining.coerceAtLeast(0.0))}" + "−${formatTimelineClock(secondsRemaining.coerceAtLeast(0.0))}" private fun formatTimelineClock(seconds: Double): String { if (seconds <= 0 || seconds.isNaN()) return "0:00" diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerTransportCluster.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerTransportCluster.kt index 08cd51292a..93ac62419d 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerTransportCluster.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerTransportCluster.kt @@ -1,25 +1,32 @@ package org.prairieserver.prairie.tv.ui.screens.player import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Pause +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.SkipNext +import androidx.compose.material.icons.rounded.Subtitles +import androidx.compose.material.icons.rounded.Tune +import androidx.compose.ui.draw.shadow +import androidx.tv.material3.Text import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.focusable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.ClosedCaption -import androidx.compose.material.icons.filled.Pause -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.SkipNext -import androidx.compose.material.icons.filled.Tune import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -27,8 +34,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType @@ -47,13 +54,13 @@ import org.prairieserver.prairie.tv.ui.components.TvSeekIntervalIcon * Bottom transport row mirroring `iosApp/.../tvOS/TVPlayerTransportCluster.swift`. * * Primary group (skipBack / playPause / skipForward) pinned left; secondary - * group (options / close) pushed right. Uniform circular buttons that flip - * white-on-black ↔ black-on-white when focused (white-fill inversion only — no - * focus scale). The skip buttons use the profile-wide video intervals - * (10s back / 30s forward on older servers). There is no Back button - * and no separate subtitles button — `options` (⋯) opens the floating HUD, - * whose Subtitles tab now owns the track/style/delay controls; `close` (xmark) - * exits the player. Up returns focus to the scrubber. + * group (up next / subtitles / options / close) pushed right. At rest every + * button is a dark disc with a faint ring, so it reads over a letterbox bar as + * well as over the picture. Focus inverts to Paper with an Ink glyph — no + * scale — and a secondary button widens into a labeled pill that also says + * what it is set to ("Subtitles · English"), the tvOS detail-page pattern. + * The skip buttons use the profile-wide video intervals (10s back / 30s + * forward on older servers). Up returns focus to the scrubber. */ @Composable fun TvPlayerTransportCluster( @@ -76,7 +83,14 @@ fun TvPlayerTransportCluster( // Resolved video intervals behind [onSkipBack]/[onSkipForward]. skipBackSeconds: Int = 10, skipForwardSeconds: Int = 30, + // What the Subtitles button reveals when focused ("English", "Off"). + subtitlesValue: String? = null, ) { + // The groups sit at opposite ends of the row. Left/Right cross the gap + // between them explicitly; a plain search from skip-forward can land on + // the scrubber above instead. + val skipForwardFocus = remember { FocusRequester() } + val firstSecondaryFocus = remember { FocusRequester() } Row( modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -93,7 +107,7 @@ fun TvPlayerTransportCluster( ) DockGap() TransportIconButton( - icon = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, + icon = if (isPlaying) Icons.Rounded.Pause else Icons.Rounded.PlayArrow, description = if (isPlaying) "Pause" else "Play", onClick = onPlayPause, focusRequester = playPauseFocus, @@ -106,6 +120,8 @@ fun TvPlayerTransportCluster( seekGlyph = SeekGlyph(forward = true, seconds = skipForwardSeconds), description = "Skip forward $skipForwardSeconds seconds", onClick = onSkipForward, + focusRequester = skipForwardFocus, + rightNeighbor = firstSecondaryFocus, onMoveUp = onMoveUpToScrubber, ) } @@ -114,30 +130,39 @@ fun TvPlayerTransportCluster( Row(verticalAlignment = Alignment.CenterVertically) { onUpNext?.let { showUpNext -> TransportIconButton( - icon = Icons.Filled.SkipNext, + icon = Icons.Rounded.SkipNext, description = "Up Next", + label = "Up next", onClick = showUpNext, + focusRequester = firstSecondaryFocus, + leftNeighbor = skipForwardFocus, onMoveUp = onMoveUpToScrubber, ) DockGap() } TransportIconButton( - icon = Icons.Filled.ClosedCaption, + icon = Icons.Rounded.Subtitles, description = "Subtitles", + label = "Subtitles", + value = subtitlesValue, onClick = onOpenQuickSubtitles, + focusRequester = firstSecondaryFocus.takeIf { onUpNext == null }, + leftNeighbor = skipForwardFocus.takeIf { onUpNext == null }, onMoveUp = onMoveUpToScrubber, ) DockGap() TransportIconButton( - icon = Icons.Filled.Tune, + icon = Icons.Rounded.Tune, description = "Info and options", + label = "Options", onClick = onOpenHUD, onMoveUp = onMoveUpToScrubber, ) DockGap() TransportIconButton( - icon = Icons.Filled.Close, + icon = Icons.Rounded.Close, description = "Close player", + label = "Close", onClick = onClose, onMoveUp = onMoveUpToScrubber, ) @@ -147,7 +172,7 @@ fun TvPlayerTransportCluster( @Composable private fun DockGap() { - Spacer(modifier = Modifier.size(width = 5.dp, height = 1.dp)) + Spacer(modifier = Modifier.size(width = 10.dp, height = 1.dp)) } @Composable @@ -156,9 +181,15 @@ private fun TransportIconButton( description: String, onClick: () -> Unit, focusRequester: FocusRequester? = null, + // Explicit Left/Right targets where the default search would go astray. + leftNeighbor: FocusRequester? = null, + rightNeighbor: FocusRequester? = null, isPrimary: Boolean = false, onMoveUp: () -> Unit = {}, seekGlyph: SeekGlyph? = null, + // Shown beside the glyph while focused; null keeps the button a disc. + label: String? = null, + value: String? = null, ) { val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() @@ -167,22 +198,32 @@ private fun TransportIconButton( val metrics = tvTransportControlMetrics(isPrimary) val buttonSize = metrics.buttonSizeDp.dp val symbolSize = metrics.symbolSizeDp.dp + val shape = RoundedCornerShape(percent = 50) - // Focus is signaled by filling the circle white — no scale transform so the - // buttons never cross the bounds of their circular hit target. - val focusBg by animateColorAsState( - targetValue = if (isFocused) Color.White else Color.Black.copy(alpha = 0.35f), + // Focus inverts the disc to Paper — no scale, so a button never crosses + // its own hit target or nudges its neighbours. + val fill by animateColorAsState( + targetValue = if (isFocused) TvPlayerChrome.Paper else TvPlayerChrome.ButtonRest, animationSpec = tween(120), label = "transportBg", ) - val iconTint = if (isFocused) Color.Black else Color.White + val tint = if (isFocused) TvPlayerChrome.Ink else TvPlayerChrome.Paper + val expanded = isFocused && label != null - Box( + Row( modifier = Modifier - .size(buttonSize) - .clip(CircleShape) - .background(focusBg) + .height(buttonSize) + .widthIn(min = buttonSize) + .then(if (isFocused) Modifier.shadow(10.dp, shape, clip = false) else Modifier) + .clip(shape) + .background(fill) + .then(if (isFocused) Modifier else Modifier.border(1.dp, TvPlayerChrome.ButtonRing, shape)) + .animateContentSize(animationSpec = tween(160)) .let { mod -> if (focusRequester != null) mod.focusRequester(focusRequester) else mod } + .focusProperties { + leftNeighbor?.let { left = it } + rightNeighbor?.let { right = it } + } .focusable(interactionSource = interactionSource) .onPreviewKeyEvent { event -> if (event.type != KeyEventType.KeyUp) return@onPreviewKeyEvent false @@ -199,27 +240,42 @@ private fun TransportIconButton( } } .semantics { - contentDescription = description + contentDescription = listOfNotNull(description, value).joinToString(", ") role = Role.Button - }, - contentAlignment = Alignment.Center, + } + .padding(horizontal = if (expanded) 13.dp else 0.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, ) { if (seekGlyph != null) { TvSeekIntervalIcon( forward = seekGlyph.forward, seconds = seekGlyph.seconds, contentDescription = null, - tint = iconTint, + tint = tint, modifier = Modifier.size(symbolSize), ) } else if (icon != null) { Icon( imageVector = icon, contentDescription = null, - tint = iconTint, + tint = tint, modifier = Modifier.size(symbolSize), ) } + if (expanded) { + Spacer(Modifier.width(8.dp)) + Text(text = label.orEmpty(), style = TvPlayerType.ButtonLabel.copy(color = TvPlayerChrome.Ink), maxLines = 1) + if (!value.isNullOrBlank()) { + Spacer(Modifier.width(6.dp)) + Text( + text = value, + style = TvPlayerType.ButtonValue.copy(color = TvPlayerChrome.InkMuted), + maxLines = 1, + ) + } + Spacer(Modifier.width(5.dp)) + } } } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerViewModel.kt index 6033587da2..c676d628ca 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerViewModel.kt @@ -13,6 +13,7 @@ import org.prairieserver.prairie.tv.BuildConfig import android.os.SystemClock import android.util.Log import org.prairieserver.prairie.common.player.SubDiag +import org.prairieserver.prairie.common.player.subtitlesForVideoMediaMount import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import org.prairieserver.prairie.common.player.PlaybackAnalyticsListener @@ -39,6 +40,7 @@ import org.prairieserver.prairie.common.player.SessionState import org.prairieserver.prairie.common.player.SleepTimerController import org.prairieserver.prairie.common.player.SleepTimerState import org.prairieserver.prairie.common.player.StartParams +import org.prairieserver.prairie.common.ui.LanguageNames import org.prairieserver.prairie.common.player.MountedSubtitleTrack import org.prairieserver.prairie.common.player.resolveMountedSubtitle import org.prairieserver.prairie.common.player.backend.VideoBackendCapabilities @@ -55,7 +57,10 @@ import org.prairieserver.prairie.common.player.seek.playerPositionForSource import org.prairieserver.prairie.common.player.seek.replanMountPositionForSource import org.prairieserver.prairie.common.player.seek.sourcePositionForPlayer import org.prairieserver.prairie.common.network.ServerReachabilityMonitor +import org.prairieserver.prairie.common.player.video.MarkerReconciliationFence +import org.prairieserver.prairie.common.player.video.MarkerSkipTarget import org.prairieserver.prairie.common.player.video.VideoPlaybackSessionCoordinator +import org.prairieserver.prairie.common.player.video.markerSkipTarget import org.prairieserver.prairie.common.player.video.VideoPlaybackStartRequest import org.prairieserver.prairie.common.player.video.EpisodeAudioIntent import org.prairieserver.prairie.common.player.video.EpisodeAudioMode @@ -84,12 +89,14 @@ import org.prairieserver.prairie.model.settings.SubtitleAppearance import org.prairieserver.prairie.model.playback.AutoSubtitleCandidate import org.prairieserver.prairie.model.playback.executableMedia3ClientTransformations import org.prairieserver.prairie.model.playback.AutoSubtitleContext -import org.prairieserver.prairie.model.playback.AutoSubtitleResolution import org.prairieserver.prairie.model.playback.inventoryAutoSubtitleCandidates import org.prairieserver.prairie.model.playback.resolveAutoSubtitle import org.prairieserver.prairie.model.playback.selectedCandidate import org.prairieserver.prairie.model.playback.PlaybackDelivery import org.prairieserver.prairie.model.playback.PlaybackAvailableQualityV3 +import org.prairieserver.prairie.model.playback.PlaybackEffectiveRecipeV3 +import org.prairieserver.prairie.model.playback.activePlaybackQualityId +import org.prairieserver.prairie.model.playback.playbackQualityMenu import org.prairieserver.prairie.model.playback.PlayMethod import org.prairieserver.prairie.model.playback.ClientCodecCapabilities import org.prairieserver.prairie.model.playback.ClientPlaybackContext @@ -102,7 +109,21 @@ import org.prairieserver.prairie.model.playback.SubtitleMediaIdentity import org.prairieserver.prairie.model.playback.enrichAuthoritativePlaybackSubtitleChoices import org.prairieserver.prairie.model.playback.resolvedSelectedSubtitleIndex import org.prairieserver.prairie.model.playback.mergeDownloadedSubtitles +import org.prairieserver.prairie.playback.PlaybackMarkersUpdate import org.prairieserver.prairie.playback.PlaybackSubtitleReady +import org.prairieserver.prairie.playback.PlaybackSubtitleTimingChanged +import org.prairieserver.prairie.playback.PlaybackSubtitleSyncUpdated +import org.prairieserver.prairie.playback.SubtitleSyncController +import org.prairieserver.prairie.playback.SubtitleSyncFeedbackTracker +import org.prairieserver.prairie.playback.SubtitleSyncNotice +import org.prairieserver.prairie.playback.feedbackInput +import org.prairieserver.prairie.playback.includesSyncKey +import org.prairieserver.prairie.playback.activeSyncKey +import org.prairieserver.prairie.playback.statusLabelFor +import org.prairieserver.prairie.playback.syncKeyFor +import org.prairieserver.prairie.playback.timingActionsFor +import org.prairieserver.prairie.model.subtitles.SubtitleSyncState +import org.prairieserver.prairie.common.player.subtitleSyncName import org.prairieserver.prairie.playback.applyAuthoritativeSubtitleReadyTrack import org.prairieserver.prairie.model.subtitles.SubtitleAiQuota import org.prairieserver.prairie.model.subtitles.SubtitleAiStatus @@ -119,6 +140,8 @@ import org.prairieserver.prairie.repository.SubtitlesRepository import org.prairieserver.prairie.repository.port.PlaybackWriteScope import org.prairieserver.prairie.repository.port.TrackSelectionFingerprintUpdate import org.prairieserver.prairie.tv.ui.screens.detail.TvDetailTrackSelectionSession +import org.prairieserver.prairie.watchtogether.RoomPlayerObservation +import org.prairieserver.prairie.watchtogether.WatchPartyPlaybackContext import org.prairieserver.prairie.watchtogether.shouldNavigateToLocalNext import kotlin.math.ceil import kotlinx.coroutines.CancellationException @@ -136,6 +159,7 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop @@ -178,17 +202,26 @@ internal fun PlayerTrackEntry.toMountedAudioTrack(): MountedAudioTrack = Mounted label = displayLabel.ifBlank { label }, ) -/** Projects the protocol-v3 quality menu verbatim, preserving server order. */ +/** + * The protocol-v3 Quality menu: Auto, then the server's entries in server + * order, with the row [selectedLabel] resolves to marked selected. + */ internal fun authoritativePlaybackQualityOptions( available: List<PlaybackAvailableQualityV3>, selectedLabel: String?, -): List<VideoQualityOption> = available.map { quality -> - VideoQualityOption( - id = quality.label, - label = quality.displayName?.takeIf { it.isNotBlank() } ?: quality.label, - isSelected = quality.label == selectedLabel, - resolution = quality.height.takeIf { it > 0 }?.let { "${it}p" }, - ) + delivered: PlaybackEffectiveRecipeV3? = null, +): List<VideoQualityOption> { + val menu = playbackQualityMenu(available) + val activeId = activePlaybackQualityId(menu, selectedLabel, delivered) + return menu.map { option -> + VideoQualityOption( + id = option.id, + label = option.name, + isSelected = option.id == activeId, + resolution = option.height.takeIf { it > 0 }?.let { "${it}p" }, + bitrateLabel = option.bitrateLabel, + ) + } } internal fun clampTvScrubPreview(seconds: Double, duration: Double): Double = @@ -438,43 +471,6 @@ internal fun subtitleTracksWithSelection( track.copy(isSelected = selectedIndex >= 0 && track.index == selectedIndex) } -internal sealed class SubtitleAutoSelection { - data object NoChange : SubtitleAutoSelection() - data object Disable : SubtitleAutoSelection() - data class Select(val index: Int) : SubtitleAutoSelection() -} - -/** - * Ranks MOUNTED Media3 text tracks through the shared resolver. - * - * The ranking itself lives in [resolveAutoSubtitle] — one cascade, one language - * table, one SDH predicate, one bitmap predicate, shared with the detail page's - * Auto preview. This only adapts [PlayerTrackEntry] into candidates. - */ -internal fun resolveAutoSubtitleSelection( - audioTracks: List<PlayerTrackEntry>, - subtitleTracks: List<PlayerTrackEntry>, - preferredLanguage: String?, - subtitleMode: String?, - showForced: Boolean, -): SubtitleAutoSelection = - when ( - val resolution = resolveAutoSubtitle( - candidates = playerTrackAutoSubtitleCandidates(subtitleTracks), - context = AutoSubtitleContext( - preferredLanguage = preferredLanguage, - mode = subtitleMode, - showForced = showForced, - audioLanguage = audioTracks.firstOrNull { it.isSelected }?.language, - ), - ) - ) { - AutoSubtitleResolution.NoChange -> SubtitleAutoSelection.NoChange - AutoSubtitleResolution.Disable -> SubtitleAutoSelection.Disable - is AutoSubtitleResolution.Select -> - SubtitleAutoSelection.Select(resolution.candidate.selectionIndex) - } - /** * The identity Auto resolves to for a launch that carried NO decision (deep * link, cast, remote/realtime start). @@ -529,6 +525,10 @@ internal fun resolveTvAutoSubtitleIdentity( * * Hearing-impaired travels as an explicit signal (role flags and both labels), * which the catalog cannot supply and the shared predicate ORs with the title. + * Nothing here needs a burn-in: every track is already in the player, Media3 + * decodes the bitmap families itself, and with no server inventory there is + * no burn-in route anyway. Media3 does not say where a track came from, so + * source stays unknown and ties keep the mounted order. */ internal fun playerTrackAutoSubtitleCandidates( subtitleTracks: List<PlayerTrackEntry>, @@ -539,32 +539,10 @@ internal fun playerTrackAutoSubtitleCandidates( codec = track.codecOrMime, forced = track.isForced, hearingImpaired = track.isEffectivelyHearingImpaired(), + needsBurnIn = false, ) } -internal fun preferredAutoTextSubtitleIndex( - tracks: List<PlayerTrackEntry>, - preferredLanguage: String?, -): Int? { - return when ( - val selection = resolveAutoSubtitleSelection( - audioTracks = emptyList(), - subtitleTracks = tracks, - preferredLanguage = preferredLanguage, - subtitleMode = "auto", - showForced = true, - ) - ) { - // This helper answers "which track should we MOVE to" — an idempotent - // re-select of the already-selected target (see the resolver) is not a - // move, so it stays null here. - is SubtitleAutoSelection.Select -> - selection.index.takeUnless { idx -> tracks.any { it.index == idx && it.isSelected } } - SubtitleAutoSelection.Disable, - SubtitleAutoSelection.NoChange -> null - } -} - internal fun resolveInitialSubtitleTrackIndex( requestedOrdinal: Int, subtitleTracks: List<PlayerTrackEntry>, @@ -644,9 +622,28 @@ data class NextEpisodeState( val episodeNumber: Int, val title: String?, val stillUrl: String?, + /** Whether [stillUrl] is the episode's own still (null when the server doesn't say). */ + val stillIsEpisodeStill: Boolean? = null, val overview: String? = null, val seriesTitle: String? = null, val runtimeMinutes: Int = 0, + /** Not started by this profile; spoiler protection may hide [overview]. */ + val isUnwatched: Boolean = true, + /** False for a shuffled movie, which has no season or episode line. */ + val isEpisode: Boolean = true, +) + +/** + * Set while a shuffle plays: the Up Next overlay announces the server's random + * pick from the scope [scopeLabel] names, and no sequential next episode is + * offered. + */ +data class TvShuffleUiState( + /** Null until the shuffle has been read. */ + val scopeLabel: String? = null, + val pickingAnother: Boolean = false, + /** A transient failure to continue or pick another. */ + val message: String? = null, ) data class TvPlayerLaunchArgs( @@ -678,6 +675,8 @@ data class TvPlayerLaunchArgs( */ val autoAdvanceCount: Int = 0, val episodeSelectionHandoff: EpisodeSelectionHandoff? = null, + /** The running shuffle this item is a pick of; picks play from the beginning. */ + val shuffleId: String? = null, ) /** @@ -755,6 +754,12 @@ class TvPlayerViewModel( private val serverReachabilityMonitor: ServerReachabilityMonitor, private val qualityLadderClient: org.prairieserver.prairie.playback.QualityLadderClient, private val launchArgs: TvPlayerLaunchArgs, + // Shuffle: the server picks what plays next. Optional so tests that + // construct the VM directly stay source-compatible. + private val shuffleFeatureStore: org.prairieserver.prairie.model.feature.ShuffleFeatureStore? = null, + // Home sections feeding the Up Next overlay's On Deck row. Optional so + // tests that construct the VM directly stay source-compatible. + private val sectionRepository: org.prairieserver.prairie.repository.SectionRepository? = null, ) : ViewModel() { companion object { @@ -775,6 +780,13 @@ class TvPlayerViewModel( // Auto-play countdown shown on the Up-Next overlay before the next // episode starts (mirrors tvOS CountdownRing default). const val NEXT_UP_COUNTDOWN_SECONDS = 10 + // A room player sample older than this cannot vouch for buffered media. + private const val ROOM_SAMPLE_MAX_AGE_MS = 1_000L + // How long a coordinated source fallback may take before the original + // refusal is shown after all. + private const val ROOM_FALLBACK_WAIT_MS = 15_000L + const val SHUFFLE_CONTINUE_FAILED = "Couldn't continue the shuffle." + const val SHUFFLE_PICK_FAILED = "Couldn't pick another." } // Up-Next auto-play countdown ticker. Cancelled on dismiss / Play Now / @@ -828,7 +840,17 @@ class TvPlayerViewModel( /** Live server ladder (or fallback) used to build the Quality picker. */ private var qualityLadder: List<org.prairieserver.prairie.playback.QualityLadderRung> = org.prairieserver.prairie.playback.FALLBACK_QUALITY_LADDER - private val roomId: String? = launchArgs.roomId + + /** + * Prairie: the current plan's `available_qualities` and delivered recipe. + * While the plan lists qualities the Quality picker is the server's menu + * (upstream); otherwise it is the Prairie transcode ladder above. + */ + private var planQualities: List<PlaybackAvailableQualityV3> = emptyList() + private var planDelivered: PlaybackEffectiveRecipeV3? = null + + // Blank means solo, exactly as the screen treats it. + private val roomId: String? = launchArgs.roomId?.takeIf { it.isNotBlank() } private val resumePositionOverride: Double? = launchArgs.resumePositionOverride // The handoff belongs to one cross-screen transition. A recoverable start // leases it until Ready publication; replacement/exit invalidates it. @@ -952,6 +974,47 @@ class TvPlayerViewModel( private var playerWindowIsSeekable = false private var playerWindowEndPlayerMs = -1L + // ---- Watch Party ---------------------------------------------------------- + // Declared ahead of init: the room observation collector and the sleep + // timer callback installed there touch these. + + /** The room playback epoch this player is prepared for; every room restart reuses it. */ + private var roomContext: WatchPartyPlaybackContext? = null + + /** The selection revision of the published session, or -1 before one. */ + private var roomPreparedRevision = -1L + + /** The room's last applied play state (commands only; never user intent). */ + private var roomPlaying = false + + /** Local reasons playback is held without asking the room. Main thread only. */ + private val roomHolds = mutableSetOf<TvRoomHold>() + private var roomLoadJob: Job? = null + + private data class TvRoomPlayerSample( + val positionMs: Long, + val bufferedPositionMs: Long, + val playWhenReady: Boolean, + val isPlaying: Boolean, + val playbackState: Int, + val suppressed: Boolean, + val sampledAtMs: Long, + ) + + private var roomPlayerSample: TvRoomPlayerSample? = null + private val _roomObservation = MutableStateFlow(RoomPlayerObservation()) + + /** What the player is actually doing, for the room binding. */ + val roomObservation: StateFlow<RoomPlayerObservation> = _roomObservation.asStateFlow() + private val _roomCorrectionRate = MutableStateFlow<Double?>(null) + + /** The room's temporary drift-correction rate; null is exactly 1x. Never saved or shown. */ + val roomCorrectionRate: StateFlow<Double?> = _roomCorrectionRate.asStateFlow() + private val _roomRefusal = MutableStateFlow<TvRoomRefusal?>(null) + + /** A server refusal the room must decide on before this player shows an error. */ + val roomRefusal: StateFlow<TvRoomRefusal?> = _roomRefusal.asStateFlow() + data class UiState( val isLoading: Boolean = true, val error: String? = null, @@ -964,6 +1027,8 @@ class TvPlayerViewModel( val serverUnreachable: Boolean = false, val title: String = "", val seriesTitle: String? = null, + val year: Int? = null, + val contentType: String? = null, /** * Artwork URL for Now Playing lock-screen / Bluetooth / Wear surfaces. * Sourced from `WatchDetail.posterUrl` with `backdropUrl` fallback. @@ -1057,6 +1122,10 @@ class TvPlayerViewModel( // subtitleUrls, so the initial prepare effect stays the only path // for session start / stream-URL changes. val subtitleRefreshNonce: Int = 0, + // Grows per sync key each time a syncable subtitle's timing changed + // and its cues must be fetched again. The mount that carries a + // revision reports it back, so sync feedback knows the new cues show. + val subtitleCueRevisions: Map<String, Int> = emptyMap(), val committedSubtitleIdentity: SubtitleIdentity = SubtitleIdentity.Off, val pendingSubtitleIdentity: SubtitleIdentity? = null, val subtitleApplying: Boolean = false, @@ -1096,6 +1165,8 @@ class TvPlayerViewModel( val seasonNumber: Int? = null, val episodeNumber: Int? = null, val nextEpisode: NextEpisodeState? = null, + // Up Next's On Deck row: other items in progress (tvOS parity). + val onDeckItems: List<TvOnDeckItem> = emptyList(), val stillWatchingPrompt: Boolean = false, // Up-Next end-of-playback surface (mirrors tvOS PlayerNextUpScreen). When // `showNextUp` is true the screen renders the Up-Next overlay — a 16:9 @@ -1115,6 +1186,7 @@ class TvPlayerViewModel( val nextUpVideoEnded: Boolean = false, val nextUpCountdownSeconds: Int? = null, val nextUpCountdownTotalSeconds: Int = NEXT_UP_COUNTDOWN_SECONDS, + val shuffle: TvShuffleUiState? = null, // Live player statistics — reduced from [PlaybackAnalyticsListener.Event]s // by [reducePlayerStats]. Always non-null so the HUD Stats pane has a // snapshot to read; populates field-by-field as events arrive. @@ -1124,7 +1196,12 @@ class TvPlayerViewModel( val videoFillMode: VideoFillMode = VideoFillMode.Fit, ) - private val _uiState = MutableStateFlow(UiState(contentId = contentId)) + private val _uiState = MutableStateFlow( + UiState( + contentId = contentId, + shuffle = TvShuffleUiState().takeIf { !launchArgs.shuffleId.isNullOrBlank() && shuffleFeatureStore != null }, + ), + ) val uiState: StateFlow<UiState> = _uiState.asStateFlow() val presentationState: StateFlow<UiState> = uiState .map(UiState::withoutPlaybackClock) @@ -1142,6 +1219,12 @@ class TvPlayerViewModel( started = SharingStarted.Eagerly, initialValue = _uiState.value.toPlaybackClock(), ) + // Marker controls use source time; presentationState deliberately omits the clock. + val manualMarkerSkipTarget: StateFlow<MarkerSkipTarget?> = uiState + .map { markerSkipTarget(it.position, it.recap, it.credits) } + .distinctUntilChanged() + .stateIn(viewModelScope, SharingStarted.Eagerly, null) + private var subtitleMountGeneration = 0L private var lastAdapterMountIdentity: SubtitleIdentity? = null private var lastAdapterMountGeneration: Long? = null @@ -1239,7 +1322,7 @@ class TvPlayerViewModel( val committedQuality = snapshot.transition.committed.qualityPreference if (!snapshot.subtitleApplying && committedQuality != null) { val currentMenu = qualityOverride - if (currentMenu == null) { + if (currentMenu == null || planQualities.isNotEmpty()) { qualityOverride = committedQuality } else if ( org.prairieserver.prairie.playback.toV3QualityPreference(currentMenu) != @@ -1352,6 +1435,16 @@ class TvPlayerViewModel( // so the Up-Next overlay couldn't arm its countdown. Carries the "video has // ended" flag forward so the countdown re-arms once nextEpisode resolves. private var pendingApproachingEndVideoEnded: Boolean? = null + // The running shuffle this player plays picks from; null outside a shuffle. + private var shufflePlayback: org.prairieserver.prairie.playback.ShufflePlayback? = + launchArgs.shuffleId?.takeIf { it.isNotBlank() }?.let { id -> shuffleFeatureStore?.playback(id) } + // The later part of a multi-part pick that plays before the shuffle moves on. + private var shuffleNextPartFileId: Int? = null + // Once per item: the server re-checks the next pick when the overlay opens. + private var shuffleRefreshedForPostRoll = false + private var shuffleAdvanceJob: Job? = null + private var shufflePickJob: Job? = null + private var shuffleMessageJob: Job? = null /** * Transient player notice (server reconnecting, suspend warnings, etc.) emitted by * [PlaybackSessionLifecycle]. `null` means show nothing. @@ -1372,6 +1465,55 @@ class TvPlayerViewModel( private val _aiTranslate = MutableStateFlow(AiTranslateUiState()) val aiTranslate: StateFlow<AiTranslateUiState> = _aiTranslate.asStateFlow() + // Timing and sync state of the playing file's stored subtitles. + private val subtitleSync = SubtitleSyncController( + subtitlesRepository, + viewModelScope, + onTimingChanged = ::onSubtitleSyncTimingChanged, + ) + /** The Subtitles pane's sync state: the selected track's timing actions and each track's status. */ + internal val hudSubtitleSync: StateFlow<TvHudSubtitleSync> = combine( + subtitleSync.state, + _uiState + .map { state -> state.subtitleUrls to (state.pendingSubtitleIdentity ?: state.committedSubtitleIdentity) } + .distinctUntilChanged(), + ) { sync, (rows, selected) -> + val statuses = rows.mapNotNull { row -> sync.statusLabelFor(row)?.let { row to it } } + TvHudSubtitleSync( + timing = sync.timingActionsFor(rows.syncKeyFor(selected)), + statusByServerIndex = statuses.associate { (row, status) -> row.index to status }, + statusByDownloadId = statuses.mapNotNull { (row, status) -> row.downloadId?.let { it to status } }.toMap(), + ) + }.stateIn(viewModelScope, SharingStarted.Eagerly, TvHudSubtitleSync()) + // The card that follows a sync this viewer started. + private val subtitleSyncFeedback = SubtitleSyncFeedbackTracker(viewModelScope) + val subtitleSyncNotice: StateFlow<SubtitleSyncNotice?> = subtitleSyncFeedback.notice + /** The cue revision of each sync key whose cues the player has in use. */ + private val loadedSubtitleCueRevisions = MutableStateFlow<Map<String, Int>>(emptyMap()) + /** Cue revisions of the last subtitle mount, until Media3 selects its subtitle track. */ + private var mountedSubtitleCueRevisions: Map<String, Int> = emptyMap() + + fun requestSubtitleSync(key: String) = subtitleSync.requestSync(key) + + fun resetSubtitleTiming(key: String) = subtitleSync.resetTiming(key) + + /** + * The player mounted subtitles built from a state carrying [revisions]. + * Media3 fetches them after the mount returns, so they count as loaded + * only once [onMountedSubtitleSelected] reports the track in use. + */ + internal fun onSubtitleCuesMounted(revisions: Map<String, Int>) { + mountedSubtitleCueRevisions = revisions + } + + /** Media3 selected the mounted subtitle track on the live player: its cues are in use. */ + internal fun onMountedSubtitleSelected() { + val revisions = mountedSubtitleCueRevisions + if (revisions.isEmpty()) return + mountedSubtitleCueRevisions = emptyMap() + loadedSubtitleCueRevisions.update { it + revisions } + } + /** * Mounts the subtitle transaction adapter has asked for, each carrying the * owner that must be told how it went. Mirrors the seekRequests idiom: the @@ -1397,9 +1539,7 @@ class TvPlayerViewModel( // is latched WITHOUT validation here precisely because the track list may // not be populated yet. private val _pendingRemoteAudioIndex = MutableStateFlow<Int?>(null) - val pendingRemoteAudioIndex: StateFlow<Int?> = _pendingRemoteAudioIndex.asStateFlow() private val _pendingRemoteSubtitleIndex = MutableStateFlow<Int?>(null) - val pendingRemoteSubtitleIndex: StateFlow<Int?> = _pendingRemoteSubtitleIndex.asStateFlow() // compareAndSet so a command arriving during the suspending apply isn't // clobbered by the clear of the one we just handled. @@ -1419,15 +1559,17 @@ class TvPlayerViewModel( .stateIn(viewModelScope, SharingStarted.Eagerly, true) val forceHdrPassthrough: StateFlow<Boolean> = playerSettingsStore.forceHdrPassthroughFlow .stateIn(viewModelScope, SharingStarted.Eagerly, false) - private val dvProfile7Hdr10Fallback: StateFlow<Boolean> = - playerSettingsStore.dvProfile7HDR10FallbackFlow - .stateIn(viewModelScope, SharingStarted.Eagerly, false) + val trueBlackBars: StateFlow<Boolean> = playerSettingsStore.trueBlackBarsFlow + .stateIn(viewModelScope, SharingStarted.Eagerly, false) val matchContentFrameRate: StateFlow<Boolean> = playerSettingsStore.matchContentFrameRateFlow .stateIn(viewModelScope, SharingStarted.Eagerly, false) // Effective = custom appearance unless "Match Device Settings" is on // (then the OS captioning style, tvOS parity). val subtitleAppearance: StateFlow<SubtitleAppearance> = playerSettingsStore.effectiveSubtitleAppearanceFlow .stateIn(viewModelScope, SharingStarted.Eagerly, SubtitleAppearance.DEFAULT) + /** False when the server is known to discard subtitle text opacity. */ + val subtitleTextOpacitySupported: StateFlow<Boolean> = playerSettingsStore.subtitleTextOpacitySupportedFlow + .stateIn(viewModelScope, SharingStarted.Eagerly, true) /** * Per-profile audio delay in ms, ±500 clamp. Sourced from * [PlayerSettingsStore.audioSyncMsFlow]; mirrored into the active @@ -1488,6 +1630,26 @@ class TvPlayerViewModel( .distinctUntilChanged() .collect { org.prairieserver.prairie.common.player.ActivePlaybackFile.set(it) } } + // Subtitle sync applies to server playback only. + viewModelScope.launch { + _uiState + .map { state -> + state.mediaFileId?.takeIf { state.sessionId != null } to + state.subtitleUrls.mapNotNullTo(mutableSetOf()) { it.syncKey } + } + .distinctUntilChanged() + .collect { (mediaFileId, syncKeys) -> subtitleSync.bind(mediaFileId, syncKeys) } + } + viewModelScope.launch { + val screen = _uiState + .map { state -> + Triple(activeSubtitleSyncKey(state), state.subtitleCueRevisions, state.subtitleUrls) + } + .distinctUntilChanged() + combine(subtitleSync.state, screen, loadedSubtitleCueRevisions) { sync, (activeKey, revisions, tracks), loaded -> + sync.feedbackInput(activeKey, revisions, loaded) { subtitleSyncName(tracks, it) } + }.collect(subtitleSyncFeedback::update) + } // Mirror the screen error into the adb test hook — screen-level // failures (terminal server plans) never reach the Media3 player, so // scripted tests can't see them through player state alone. @@ -1560,9 +1722,14 @@ class TvPlayerViewModel( } // When the sleep timer fires, flip user intent to paused. The screen - // mirrors `isPaused` to `mediaController.playWhenReady`. + // mirrors `isPaused` to `mediaController.playWhenReady`. In a Watch + // Party it is a local hold: the room keeps playing. sleepTimer.configure { - _uiState.update { it.copy(isPaused = true) } + if (roomId != null) { + holdRoomPlayback(TvRoomHold.SleepTimer) + } else { + _uiState.update { it.copy(isPaused = true) } + } } // Reduce the analytics listener's event stream into the HUD's Stats @@ -1580,7 +1747,16 @@ class TvPlayerViewModel( } } - if (contentId.isNotBlank()) loadContent(startPositionOverride = resumePositionOverride) + if (roomId != null) { + // Loading, session, error and duration changes all move the room + // observation; player samples arrive through onRoomPlayerSample. + viewModelScope.launch { _uiState.collect { publishRoomObservation() } } + } else if (contentId.isNotBlank()) { + // A Watch Party waits for the room's playback context + // (loadRoomPlayback): the route's own content and file must never + // start solo playback first. + loadContent(startPositionOverride = resumePositionOverride) + } } fun onBackendCapabilities(capabilities: VideoBackendCapabilities) { @@ -1715,6 +1891,8 @@ class TvPlayerViewModel( ?: before.selectedFileId ?: before.mediaFileId ?: return TvSubtitleAdoptionResult.Superseded + // A Watch Party plays exactly the room's file; never adopt another. + if (roomContext?.fileId?.let { it != fileId } == true) return TvSubtitleAdoptionResult.Superseded val version = before.fileVersions.firstOrNull { it.fileId == fileId } val adopted = sessionLifecycle.adoptActiveSessionIfCurrent( params = StartParams( @@ -1763,9 +1941,13 @@ class TvPlayerViewModel( mediaFileId = fileId, selectedFileResolution = version?.resolution ?: ready.plan.effectiveRecipe.height?.let { "${it}p" }, - videoQualities = authoritativePlaybackQualityOptions( + videoQualities = tvQualityMenu( available = ready.plan.availableQualities, selectedLabel = adoption.committed.qualityPreference, + delivered = ready.plan.effectiveRecipe, + sourceResolution = version?.resolution + ?: ready.plan.effectiveRecipe.height?.let { "${it}p" }, + playMethod = ready.session.playMethod?.name?.lowercase(), ), container = ready.plan.stream.container ?: version?.container ?: state.container, duration = duration, @@ -1776,6 +1958,7 @@ class TvPlayerViewModel( position = remountPosition.sourcePositionSeconds, ) } + adoptFileMarkersAfterReplan(before.mediaFileId, version) Log.i( TAG, "subtitle_replan_mount restored_source_seconds=${remountPosition.sourcePositionSeconds} " + @@ -1872,6 +2055,7 @@ class TvPlayerViewModel( // source/player time may no longer be identical. executeSeekTarget(targetSeconds) } + publishRoomObservation() } } @@ -1916,6 +2100,17 @@ class TvPlayerViewModel( // visible until the replacement has won ownership and is ready. preserveCurrentPlaybackOnFailure: Boolean = false, ) { + // A Watch Party only ever starts the room's exact file and position. + // Retry, the reachability retries and the 404 renewal all come through + // here and reuse the room context; before the room has supplied one + // there is nothing to start. + val requestRoom = tvRoomStartContext( + context = roomContext, + startPositionOverride = startPositionOverride, + currentSourcePositionSeconds = _uiState.value.position, + preparedRevision = roomPreparedRevision, + ) + if (roomId != null && requestRoom == null) return // Capture this pipeline's generation; a later loadContent bump makes // this one inert before it can touch _uiState. val generation = ++contentLoadGeneration @@ -1980,11 +2175,11 @@ class TvPlayerViewModel( ) val episodeSelectionHandoff = episodeSelectionHandoffLease?.handoff val request = VideoPlaybackStartRequest( - libraryId = launchArgs.libraryId, + libraryId = requestRoom?.libraryId ?: launchArgs.libraryId, contentId = contentId, - preferredFileId = preferredFileIdOverride ?: preferredFileId, - roomId = roomId, - resumePositionOverride = startPositionOverride, + preferredFileId = requestRoom?.fileId ?: preferredFileIdOverride ?: preferredFileId, + roomId = requestRoom?.roomId ?: roomId, + resumePositionOverride = requestRoom?.positionSeconds ?: startPositionOverride, audioTrackIndex = if (recoveryStartParams != null) { recoveryStartParams.audioTrackIndex } else if (audioTrackIndexOverrideSpecified) { @@ -2006,6 +2201,7 @@ class TvPlayerViewModel( force = force, episodeSelectionHandoff = episodeSelectionHandoff, recoveryStartParams = recoveryStartParams, + room = requestRoom, ) val result = loadOwners.withOwner(loadOwner) { videoPlaybackCoordinator.start(request) @@ -2181,6 +2377,12 @@ class TvPlayerViewModel( // invariant that lets exitSessionId read it // first. result.sessionId?.let { lastAdoptedSessionId = it } + if (requestRoom != null) { + // A room session starts paused; only the + // room's command after attach plays it. + roomPreparedRevision = requestRoom.selectionRevision + roomPlaying = false + } _uiState.update { it.copy( isLoading = false, @@ -2188,6 +2390,8 @@ class TvPlayerViewModel( contentId = contentId, title = result.title, seriesTitle = result.seriesTitle, + year = result.year, + contentType = result.contentType, artworkUrl = result.artworkUrl, sessionId = result.sessionId, playMethod = result.playMethod, @@ -2211,11 +2415,13 @@ class TvPlayerViewModel( .firstOrNull { it.fileId == result.fileId } ?.trickplay ?: result.versions.firstOrNull()?.trickplay, - // Server-transcode quality ladder for this source - // (tvOS parity) — replaces adaptive-variant options. - videoQualities = transcodeQualityLadder( - result.fileResolution, - qualityOverride ?: preferredQuality ?: "auto", + videoQualities = tvQualityMenu( + available = result.playbackPlanV3?.availableQualities.orEmpty(), + selectedLabel = qualityOverride + ?: preferredQuality + ?: PlaybackQuality.Auto.wireValue, + delivered = result.playbackPlanV3?.effectiveRecipe, + sourceResolution = result.fileResolution, playMethod = result.playMethod?.name?.lowercase(), ), mediaFileId = result.mediaFileId, @@ -2223,7 +2429,7 @@ class TvPlayerViewModel( position = result.sourceStartPositionSeconds, duration = result.durationSeconds ?: 0.0, serverDuration = result.durationSeconds ?: 0.0, - isPaused = false, + isPaused = tvPausedWhenReady(inWatchParty = requestRoom != null), subtitleUrls = hydratedSubtitleUrls, preferredAudioLanguage = result.preferredAudioLanguage, preferredTextLanguage = result.preferredTextLanguage, @@ -2320,13 +2526,27 @@ class TvPlayerViewModel( ) } startIntroAutoSkipObserver() - if (!nextUpTransitionGate.isActive) resolveNextEpisode() + if (!nextUpTransitionGate.isActive) { + resolveNextEpisode() + loadOnDeckItems() + } } is VideoPlayerUiState.Error -> { episodeSelectionHandoffSlot.retainForRetry( episodeSelectionHandoffLease, ) - if (preserveCurrentPlaybackOnFailure) { + val refusalReason = result.terminalReason?.takeIf { it.isNotBlank() } + if (requestRoom != null && refusalReason != null) { + // The room decides whether everyone moves to + // another source; the error shows only if not. + _uiState.update { it.copy(isLoading = true, error = null) } + _roomRefusal.value = TvRoomRefusal( + context = requestRoom, + fileId = requestRoom.fileId, + reason = refusalReason, + message = result.message, + ) + } else if (preserveCurrentPlaybackOnFailure) { _uiState.update { failTvReplacementLoad(it, result.message) } } else { fail(result.message) @@ -2625,6 +2845,23 @@ class TvPlayerViewModel( ?: effectiveVersion?.container ?: state.container.takeIf { effectiveFileId == fileId } val effectiveDuration = decision.session.durationSeconds ?: 0.0 + val roomFileId = roomContext?.fileId + if (roomFileId != null && effectiveFileId != roomFileId) { + // A Watch Party plays exactly the room's file. + // The server keeps that pin through every replan, + // so this is a guard, not a fallback path. + withContext(NonCancellable) { + runCatching { + playbackSessionManager.stopSession(decision.session.sessionId) + } + } + onReplanRequestFailed( + classification, + notice, + "The server offered a different version than the Watch Party's.", + ) + return@launch + } var adopted = false try { adopted = sessionLifecycle.adoptActiveSessionIfCurrent( @@ -2668,6 +2905,7 @@ class TvPlayerViewModel( coroutineContext.ensureActive() if (recoveryContentGeneration != contentLoadGeneration) return@launch val transportMountNonce = nextTypedSubtitleMountNonce(returnedSubtitleIdentity) + val previousFileId = _uiState.value.mediaFileId _uiState.update { it.copy( error = null, @@ -2681,12 +2919,15 @@ class TvPlayerViewModel( selectedFileId = effectiveFileId, mediaFileId = effectiveFileId, selectedFileResolution = effectiveResolution, - videoQualities = authoritativePlaybackQualityOptions( + videoQualities = tvQualityMenu( available = decision.plan.availableQualities, selectedLabel = qualityPreference ?: qualityOverride ?: preferredQuality ?: PlaybackQuality.Auto.wireValue, + delivered = decision.plan.effectiveRecipe, + sourceResolution = effectiveResolution, + playMethod = decision.session.playMethod?.name?.lowercase(), ), container = effectiveContainer, duration = effectiveDuration, @@ -2710,6 +2951,7 @@ class TvPlayerViewModel( committedIdentity = returnedSubtitleIdentity, ) subtitleTransactions.restoreCommittedLocalMount() + adoptFileMarkersAfterReplan(previousFileId, effectiveVersion) Log.i( TAG, "replan_mount restored_source_seconds=${remountPosition.sourcePositionSeconds} " + @@ -2735,10 +2977,13 @@ class TvPlayerViewModel( return@launch } lastAdoptedSessionId = null + val refusalContext = roomContext _uiState.update { it.copy( - error = terminalMessage, - isLoading = false, + // In a Watch Party the room decides first + // whether everyone moves to another source. + error = terminalMessage.takeIf { refusalContext == null }, + isLoading = refusalContext != null, isBuffering = false, isPlaying = false, isPaused = true, @@ -2749,6 +2994,14 @@ class TvPlayerViewModel( streamUrl = null, ) } + if (refusalContext != null) { + _roomRefusal.value = TvRoomRefusal( + context = refusalContext, + fileId = fileId, + reason = decision.reason, + message = terminalMessage, + ) + } } VideoSessionStartV3.ServerUpgradeRequired -> { cancelPendingCatalogSubtitle() @@ -2994,6 +3247,7 @@ class TvPlayerViewModel( ) } resolveNextEpisode() + loadOnDeckItems() } } @@ -3328,27 +3582,29 @@ class TvPlayerViewModel( /** Toggle user-intent pause state. Screen mirrors this to player.play/pause. */ fun onPlayPause() { + // In a Watch Party every toggle goes through the room; a local flip + // here would fork this viewer from everyone else. + if (roomId != null) return _uiState.update { it.copy(isPaused = !it.isPaused) } } /** - * Idempotent pause setter for Watch Together sync-applied commands. Unlike - * [onPlayPause] (a toggle), this sets the absolute desired state, so a - * duplicate room command can't flip the player the wrong way. The screen's - * `state.isPaused` mirror drives `mediaController.playWhenReady`. + * Idempotent pause setter. Unlike [onPlayPause] (a toggle), this sets the + * absolute desired state, so a duplicate command can't flip the player the + * wrong way. The screen's `state.isPaused` mirror drives + * `mediaController.playWhenReady`. */ fun setPaused(paused: Boolean) { _uiState.update { if (it.isPaused == paused) it else it.copy(isPaused = paused) } } /** - * Deadband-free seek for Watch Together corrective seeks - * ([TvRoomSyncController.applyDecision]). Updates `uiState.position` AND - * emits on [seekRequests], which the screen collects and applies to the - * MediaController unconditionally (TV has no position-mirror deadband, so - * `seekRequests` already reaches the player on every emission — sub-second - * sync corrections are never swallowed). Named to mirror the mobile - * `PlayerViewModel.seekImmediate` contract. + * Deadband-free seek, also used for Watch Party room seeks + * ([applyRoomSeek]). Updates `uiState.position` AND emits on + * [seekRequests] (or re-anchors), which the screen applies to the + * MediaController unconditionally, so sub-second corrections are never + * swallowed. Named to mirror the mobile `PlayerViewModel.seekImmediate` + * contract. */ fun seekImmediate(positionSec: Double) { cancelPendingQuickSkip() @@ -3864,9 +4120,251 @@ class TvPlayerViewModel( _uiState.update { it.copy(isBuffering = false, error = message) } } + // ---- Watch Party -------------------------------------------------------------- + + /** + * Prepare the room's playback epoch: its exact content, file and position, + * paused. A new selection revision or file cancels the old epoch; the same + * epoch again is a no-op. Only the room's commands play it. + */ + fun loadRoomPlayback(context: WatchPartyPlaybackContext) { + if (roomId == null || context.roomId != roomId) return + val previous = roomContext + if (previous != null && + previous.selectionRevision == context.selectionRevision && + previous.fileId == context.fileId + ) { + return + } + roomContext = context + _roomRefusal.value = null + // The old epoch is over: hold its picture still until the new one is ready. + roomPlaying = false + setPaused(true) + val contentChanged = context.contentId != contentId + val outgoingSessionId = exitSessionId.takeIf { previous != null && contentChanged } + roomLoadJob?.cancel() + roomLoadJob = viewModelScope.launch { + if (outgoingSessionId != null) { + // Another title: finish the outgoing one (final position, + // committed tracks, server stop) the way an episode change does. + finishCurrentEpisodeForNextUp(outgoingSessionId) + lastAdoptedSessionId = null + } + if (roomContext !== context) return@launch + if (contentChanged) resetForRoomContent(context.contentId) + preferredFileId = context.fileId + loadContent( + startPositionOverride = context.positionSeconds, + preferredFileIdOverride = context.fileId, + suppressResumeRewind = true, + ) + } + publishRoomObservation() + } + + private fun resetForRoomContent(newContentId: String) { + contentId = newContentId + preferredQuality = null + qualityOverride = null + initialAudioTrackIndex = null + pendingInitialSubtitleIndex = null + pendingInitialSubtitleAutoResolved = false + pendingInitialSubtitleAttempts = 0 + autoTextSubtitleSelectionAttempted = false + autoAdvanceHandled = false + pendingApproachingEndVideoEnded = null + episodeSelectionHandoffSlot.invalidate() + _uiState.update { it.copy(contentId = newContentId, nextEpisode = null) } + } + + /** A room seek. Maps onto the mounted stream or re-anchors; never user intent. */ + fun applyRoomSeek(sourceSeconds: Double) { + if (roomId == null || !sourceSeconds.isFinite()) return + seekImmediate(sourceSeconds.coerceAtLeast(0.0)) + publishRoomObservation() + } + + /** The room's play state. A local hold keeps this viewer paused until it clears. */ + fun applyRoomPlaying(playing: Boolean) { + if (roomId == null) return + roomPlaying = playing + setPaused(!playing || roomHolds.isNotEmpty()) + } + + /** The room's temporary correction rate, or null for exactly 1x. */ + fun setRoomCorrectionRate(rate: Double?) { + if (roomId == null) return + _roomCorrectionRate.value = rate?.takeIf { it.isFinite() && it > 0.0 } + } + + /** + * Whether [sourceSeconds] is reachable without new media: a plain native + * seek of the mounted item, between a second behind the playhead and the + * buffered position. Anything uncertain answers false. + */ + fun isRoomPositionBuffered(sourceSeconds: Double): Boolean { + if (roomId == null || !sourceSeconds.isFinite() || sourceSeconds < 0.0) return false + val sample = roomPlayerSample ?: return false + if (SystemClock.elapsedRealtime() - sample.sampledAtMs > ROOM_SAMPLE_MAX_AGE_MS) return false + if (sample.playbackState != androidx.media3.common.Player.STATE_READY && + sample.playbackState != androidx.media3.common.Player.STATE_BUFFERING + ) { + return false + } + val state = _uiState.value + if (state.error != null || roomSeekPending(state)) return false + val timeline = state.playbackPlan?.timeline + val targetPlayerSeconds = if (timeline != null) { + val decision = timeline.decideSeek(sourceSeconds, mountedSeekableSourceRange(state)) + (decision as? PlaybackSeekDecision.NativeSeek)?.targetPlayerPositionSeconds ?: return false + } else { + sourceSeconds + } + return tvRoomPositionBuffered( + targetPlayerSeconds = targetPlayerSeconds, + currentPlayerSeconds = sample.positionMs.coerceAtLeast(0L) / 1000.0, + bufferedPlayerSeconds = sample.bufferedPositionMs.coerceAtLeast(0L) / 1000.0, + ) + } + + /** What Media3 reports right now. Drives [roomObservation]. */ + fun onRoomPlayerSample( + positionMs: Long, + bufferedPositionMs: Long, + playWhenReady: Boolean, + isPlaying: Boolean, + playbackState: Int, + suppressed: Boolean, + ) { + if (roomId == null) return + roomPlayerSample = TvRoomPlayerSample( + positionMs = positionMs, + bufferedPositionMs = bufferedPositionMs, + playWhenReady = playWhenReady, + isPlaying = isPlaying, + playbackState = playbackState, + suppressed = suppressed, + sampledAtMs = SystemClock.elapsedRealtime(), + ) + publishRoomObservation() + } + + /** A mounted player position in source time, or null while a new mount takes over. */ + fun roomSourcePositionForPlayer(playerPositionMs: Long): Double? { + if (transportMountGate.suppressPositionReports) return null + val seconds = playerPositionMs.coerceAtLeast(0L) / 1000.0 + return _uiState.value.playbackPlan?.timeline?.sourcePositionForPlayer(seconds) ?: seconds + } + + /** Playback is held locally for at least one reason. */ + val isRoomHeld: Boolean get() = roomHolds.isNotEmpty() + + /** Hold playback locally (background, audio focus, sleep timer). The room is not asked. */ + fun holdRoomPlayback(hold: TvRoomHold) { + if (roomId == null) return + roomHolds.add(hold) + setPaused(true) + publishRoomObservation() + } + + /** Clear one hold; with none left, follow the room again. */ + fun releaseRoomPlayback(hold: TvRoomHold) { + if (roomId == null || !roomHolds.remove(hold)) return + if (roomHolds.isEmpty()) setPaused(!roomPlaying) + publishRoomObservation() + } + + /** The viewer pressed play: the holds they can clear themselves go away. */ + fun releaseUserRoomHolds() { + if (roomId == null) return + val focus = roomHolds.remove(TvRoomHold.AudioFocus) + val sleep = roomHolds.remove(TvRoomHold.SleepTimer) + if (!focus && !sleep) return + if (roomHolds.isEmpty()) setPaused(!roomPlaying) + publishRoomObservation() + } + + /** Play pressed while held and the room is playing: resume here, ask nothing. */ + fun resumeRoomPlaybackLocally() { + if (roomId == null) return + roomHolds.remove(TvRoomHold.AudioFocus) + roomHolds.remove(TvRoomHold.SleepTimer) + roomPlaying = true + if (roomHolds.isEmpty()) setPaused(false) + publishRoomObservation() + } + + /** + * The room answered a refusal. When it moved everyone to another source a + * new context follows; otherwise the refusal becomes this player's error. + * No other file is ever chosen locally. + */ + fun onRoomRefusalHandled(refusal: TvRoomRefusal, tookOver: Boolean) { + if (_roomRefusal.value != refusal) return + _roomRefusal.value = null + if (tookOver) { + viewModelScope.launch { + delay(ROOM_FALLBACK_WAIT_MS) + if (roomContext?.selectionRevision == refusal.context.selectionRevision && + _uiState.value.streamUrl == null && _uiState.value.error == null + ) { + fail(refusal.message) + } + } + return + } + if (roomContext?.selectionRevision != refusal.context.selectionRevision) return + fail(refusal.message) + } + + /** A short in-player message (Watch Party notices share the remote-message toast). */ + fun showPlayerMessage(text: String) { + if (text.isBlank()) return + _remoteMessage.value = RemoteMessage(++remoteMessageCounter, text) + } + + private fun roomSeekPending(state: UiState): Boolean = + transportMountGate.suppressPositionReports || + state.isLoading || + state.streamUrl == null || + activeSeekTargetSec != null || + quickSkipAccumulator.pending != null || + pendingNativeSeekAfterMount != null || + seekRecoveryQueue.hasInFlight || + recoveryJob?.isActive == true + + private fun publishRoomObservation() { + if (roomId == null) return + val state = _uiState.value + val sample = roomPlayerSample + val hasMedia = state.streamUrl != null && state.error == null + // Engine truth mapped to source time. While a new mount takes over, the + // old item's position cannot be mapped through the new timeline, so + // the last known value stands (and seekPending says it is not a decision). + val position = if (sample != null && hasMedia && !transportMountGate.suppressPositionReports) { + val raw = sample.positionMs.coerceAtLeast(0L) / 1000.0 + val mapped = state.playbackPlan?.timeline?.sourcePositionForPlayer(raw) ?: raw + state.serverDuration.takeIf { it > 0.0 }?.let(mapped::coerceAtMost) ?: mapped + } else { + _roomObservation.value.sourcePositionSeconds + } + _roomObservation.value = RoomPlayerObservation( + sessionId = state.sessionId?.takeIf { hasMedia }, + selectionRevision = roomPreparedRevision, + sourcePositionSeconds = position, + durationSeconds = state.duration, + playWhenReady = sample?.playWhenReady ?: false, + isPlaying = sample?.isPlaying ?: false, + state = tvRoomPlayerState(sample?.playbackState, hasMedia), + seekPending = roomSeekPending(state), + suspended = roomHolds.isNotEmpty() || sample?.suppressed == true, + ) + } + // ---- Remote-control adapters (TvPlaybackRealtimeController calls these) ---- - /** True while in a Watch Together room — remote transport is gated (the room is authoritative). */ - val remoteTransportSuppressed: Boolean get() = roomId != null + /** True while this player belongs to a Watch Party: the room owns transport. */ + val inWatchParty: Boolean get() = roomId != null fun remotePause() = setPaused(true) fun remoteUnpause() = setPaused(false) @@ -3936,12 +4434,61 @@ class TvPlayerViewModel( _pendingRemoteSubtitleIndex.value?.let(::remoteSelectSubtitle) } + private val markerReconciliationFence = MarkerReconciliationFence() + private var markerReconcileJob: Job? = null + + /** + * Read the playing file's markers again: after every control connection, + * including on-demand mode, and after a replan lands on another file. + * Naming the file makes the server fill that version's on-demand markers. + */ + fun reconcileMarkers(expectedSessionId: String) { + val state = _uiState.value + val fileId = state.mediaFileId ?: return + if (state.sessionId != expectedSessionId) return + // The same library the start used: a room may have moved to another library's title. + val libraryId = roomContext?.libraryId ?: launchArgs.libraryId + val ticket = markerReconciliationFence.begin() + markerReconcileJob?.cancel() + markerReconcileJob = viewModelScope.launch { + val owner = catalogRepository.captureWatchAuthority() ?: return@launch + val detail = (catalogRepository.getWatchDetail(state.contentId, owner, libraryId, fileId) as? ApiResult.Success) + ?.data ?: return@launch + if (!catalogRepository.isWatchAuthorityCurrent(owner)) return@launch + val current = _uiState.value + if (!markerReconciliationFence.isCurrent(ticket) || current.sessionId != expectedSessionId || + current.contentId != state.contentId || current.mediaFileId != fileId) return@launch + val version = detail.versions.firstOrNull { it.fileId == fileId } ?: return@launch + applyUpdatedMarkers(version.intro, version.credits, + version.recap, version.preview) + } + } + + /** + * A `markers_updated` event. The server also sends a file's update to + * sessions that requested that file but play another, so check the file. + */ + fun applyMarkersUpdate(update: PlaybackMarkersUpdate) { + if (update.fileId != null && update.fileId != _uiState.value.mediaFileId) return + applyUpdatedMarkers(update.intro, update.credits, update.recap, update.preview) + } + + /** A replan that lands on another file takes that file's markers, then reads them again. */ + private fun adoptFileMarkersAfterReplan(previousFileId: Int?, version: FileVersion?) { + val state = _uiState.value + if (state.mediaFileId == previousFileId) return + applyUpdatedMarkers(version?.intro, version?.credits, version?.recap, version?.preview) + state.sessionId?.let(::reconcileMarkers) + } + /** - * Adopt server-recomputed intro/credits ranges (a `markers_updated` event). - * Skip-intro and the credits-based F2 trigger read these from UiState, so the - * update takes effect immediately; `null` clears a marker the server dropped. + * Adopt the playing file's marker ranges (a `markers_updated` event or a + * reconcile read). Skip-intro and the credits-based F2 trigger read these + * from UiState, so the update takes effect immediately; `null` clears a + * marker the server dropped. */ - fun applyUpdatedMarkers(intro: TimeRange?, credits: TimeRange?, recap: TimeRange?, preview: TimeRange?) { + private fun applyUpdatedMarkers(intro: TimeRange?, credits: TimeRange?, recap: TimeRange?, preview: TimeRange?) { + markerReconciliationFence.invalidate() _uiState.update { it.copy(intro = intro, credits = credits, recap = recap, preview = preview) } } @@ -3953,7 +4500,52 @@ class TvPlayerViewModel( * excluded, per the resolver's playback-order contract) and finds the * immediate next via [nextEpisodeAfter]. */ + private var onDeckGeneration = 0L + + /** + * Populates the Up Next overlay's On Deck row (tvOS + * `loadNextUpOnDeckItems` and phone parity): home continue-watching pools, + * minus the current item and its series. A Watch Party never shows it: + * the room decides what plays. + */ + private fun loadOnDeckItems() { + val run = ++onDeckGeneration + val repository = sectionRepository ?: return + val state = _uiState.value + val forContentId = state.contentId + val currentSeriesId = state.seriesId + _uiState.update { it.copy(onDeckItems = emptyList()) } + if (roomId != null) return + // Not keyed on the session: a startup replan swaps it for the same + // item without reloading, and the row doesn't depend on it. + fun stillCurrent() = run == onDeckGeneration && _uiState.value.contentId == forContentId + viewModelScope.launch { + val owner = repository.captureHomeAuthority() ?: return@launch + repository.loadScopedHomeSections(owner, ::stillCurrent) { sections -> + val pool = sections.toTvOnDeckItems(forContentId, currentSeriesId) + _uiState.update { if (!stillCurrent()) it else it.copy(onDeckItems = pool) } + } + } + } + + /** + * An On Deck pick leaves this player for another item: an explicit choice, + * so stop the countdown before navigation replaces the screen and its + * disposal finishes the session as any exit does. + */ + fun onOnDeckItemChosen() { + nextUpCountdownJob?.cancel() + nextUpCountdownJob = null + _uiState.update { it.copy(nextUpCountdownSeconds = null) } + } + private fun resolveNextEpisode() { + // The room owns what plays next; nothing here may offer it. + if (roomId != null) return + if (shufflePlayback != null) { + resolveShuffleNext() + return + } val state = _uiState.value val seriesId = state.seriesId ?: return val curSeason = state.seasonNumber ?: return @@ -3985,9 +4577,11 @@ class TvPlayerViewModel( episodeNumber = next.episodeNumber, title = next.title, stillUrl = next.stillUrl, + stillIsEpisodeStill = next.stillIsEpisodeStill, overview = next.overview, seriesTitle = state.seriesTitle, runtimeMinutes = next.runtime, + isUnwatched = org.prairieserver.prairie.model.settings.EpisodeSpoilers.isUnwatched(next.userData), ) _uiState.update { if (it.contentId != forContentId) it else it.copy(nextEpisode = nextState) @@ -4027,6 +4621,17 @@ class TvPlayerViewModel( // (it would silently leave/desync the room). Mirrors the remote-control // transport gate. if (roomId != null) return + if (shufflePlayback != null) { + // A multi-part pick plays its later parts before the shuffle moves on. + shuffleNextPartFileId?.let { fileId -> + if (videoEnded) playNextShufflePart(fileId) + return + } + if (!shuffleRefreshedForPostRoll) { + shuffleRefreshedForPostRoll = true + refreshShuffleNext() + } + } // Playback end reopens a dismissed card without restarting its timer. // A card still showing keeps its existing countdown. if (autoAdvanceHandled) { @@ -4076,8 +4681,11 @@ class TvPlayerViewModel( * does nothing when pressed. */ fun canShowNextUpNow(): Boolean { + // The room owns what plays next; Play Now could never advance. + if (roomId != null) return false val state = _uiState.value - return state.nextEpisode != null && !state.showNextUp + // A shuffle replaces the series order: no sequential next episode. + return state.nextEpisode != null && !state.showNextUp && state.shuffle == null } /** @@ -4093,6 +4701,7 @@ class TvPlayerViewModel( * mid-decision is the opposite of what the press asked for. */ fun onUserRequestedNextUp() { + if (roomId != null) return val next = _uiState.value.nextEpisode ?: return if (_uiState.value.showNextUp) return nextUpCountdownJob?.cancel() @@ -4203,12 +4812,31 @@ class TvPlayerViewModel( // collector enforced this guard; keep it here now that TV advances // in place so a remote Next command cannot desynchronise the room. if (!shouldNavigateToLocalNext(inWatchTogetherRoom = roomId != null)) return + if (shufflePlayback != null) { + advanceShuffle(nextAutoAdvanceCount) + return + } + val next = _uiState.value.nextEpisode ?: return + playInPlace(next.contentId, targetFileId = null, nextAutoAdvanceCount = nextAutoAdvanceCount) + } + + /** + * Replaces the mounted item in place with [targetContentId] (and + * [targetFileId], when a specific file must play) from the beginning. + * [showCard] keeps the Up Next overlay up through the handoff; a later + * part of the same item plays without it. + */ + private fun playInPlace( + targetContentId: String, + targetFileId: Int?, + nextAutoAdvanceCount: Int, + showCard: Boolean = true, + ) { val state = _uiState.value - val next = state.nextEpisode ?: return - if (!nextUpTransitionGate.begin(next.contentId)) return + if (!nextUpTransitionGate.begin(targetContentId)) return _uiState.update { it.copy( - showNextUp = true, + showNextUp = it.showNextUp || showCard, isNextUpTransitioning = true, nextUpCountdownSeconds = null, ) @@ -4248,8 +4876,8 @@ class TvPlayerViewModel( if (!nextUpTransitionGate.isActive) return@launch lastAdoptedSessionId = null - contentId = next.contentId - preferredFileId = null + contentId = targetContentId + preferredFileId = targetFileId preferredQuality = null qualityOverride = null initialAudioTrackIndex = null @@ -4260,6 +4888,8 @@ class TvPlayerViewModel( autoAdvanceCount = nextAutoAdvanceCount autoAdvanceHandled = false pendingApproachingEndVideoEnded = null + shuffleNextPartFileId = null + shuffleRefreshedForPostRoll = false episodeSelectionHandoffSlot.replace(handoff) loadContent( startPositionOverride = 0.0, @@ -4301,6 +4931,164 @@ class TvPlayerViewModel( return stopped } + // ---- Shuffle ---- + + private fun resolveShuffleNext() { + val shuffle = shufflePlayback ?: return + val state = _uiState.value + val forContentId = state.contentId + // The overlay waits for the last part of a multi-part pick. + shuffleNextPartFileId = org.prairieserver.prairie.playback.nextPlaybackPartFileId( + versions = state.fileVersions, + currentFileId = state.selectedFileId ?: state.mediaFileId, + ) + if (shuffleNextPartFileId != null) return + resolveNextEpisodeJob?.cancel() + // The advance that started this item already returned its next pick. + if (shuffle.latest?.current?.contentId == forContentId) { + publishShuffleNext(forContentId) + return + } + resolveNextEpisodeJob = viewModelScope.launch { + shuffle.refresh() + publishShuffleNext(forContentId) + } + } + + /** Re-reads the shuffle as the overlay opens: the server replaces a next pick that can no longer play. */ + private fun refreshShuffleNext() { + val shuffle = shufflePlayback ?: return + val forContentId = _uiState.value.contentId + viewModelScope.launch { + shuffle.refresh() + publishShuffleNext(forContentId) + } + } + + /** Shows the shuffle's pick after [forContentId], or the finished state when there is none. */ + private fun publishShuffleNext(forContentId: String) { + val shuffle = shufflePlayback ?: return + if (_uiState.value.contentId != forContentId || nextUpTransitionGate.isActive) return + val next = shuffle.nextPickAfter(forContentId)?.let { pick -> + NextEpisodeState( + contentId = pick.contentId, + seasonNumber = pick.seasonNumber, + episodeNumber = pick.episodeNumber, + title = pick.title, + stillUrl = pick.stillUrl, + overview = pick.overview, + seriesTitle = pick.seriesTitle, + runtimeMinutes = pick.runtimeMinutes, + isUnwatched = pick.isUnwatched, + isEpisode = pick.isEpisode, + ) + } + val previous = _uiState.value.nextEpisode + _uiState.update { + it.copy( + nextEpisode = next, + shuffle = it.shuffle?.copy(scopeLabel = shuffle.scopeLabel ?: it.shuffle.scopeLabel), + ) + } + val countingDownAtEnd = nextUpCountdownJob?.isActive == true && _uiState.value.nextUpVideoEnded + when { + next == null -> { + nextUpCountdownJob?.cancel() + nextUpCountdownJob = null + _uiState.update { it.copy(nextUpCountdownSeconds = null) } + } + // A different pick, such as Pick Another, gets the full countdown + // rather than whatever was left of the previous one. + previous != null && previous.contentId != next.contentId && countingDownAtEnd -> { + _uiState.update { + it.copy( + nextUpCountdownSeconds = NEXT_UP_COUNTDOWN_SECONDS, + nextUpCountdownTotalSeconds = NEXT_UP_COUNTDOWN_SECONDS, + ) + } + startNextUpCountdown() + } + previous == null && !autoAdvanceHandled -> + pendingApproachingEndVideoEnded?.let { videoEnded -> commitApproachingEnd(next, videoEnded) } + } + } + + /** Plays the next part of a multi-part pick from its start, without the overlay. */ + private fun playNextShufflePart(fileId: Int) { + shuffleNextPartFileId = null + playInPlace(contentId, targetFileId = fileId, nextAutoAdvanceCount = autoAdvanceCount, showCard = false) + } + + /** + * Moves the shuffle past the item that played and plays the returned + * pick from the beginning. The server moves on only while that item is + * still current, so a repeated press or a retry plays the same pick. + */ + private fun advanceShuffle(nextAutoAdvanceCount: Int) { + val shuffle = shufflePlayback ?: return + if (_uiState.value.nextEpisode == null) return + if (shuffleAdvanceJob?.isActive == true || nextUpTransitionGate.isActive) return + val fromContentId = _uiState.value.contentId + _uiState.update { it.copy(nextUpCountdownSeconds = null) } + val pendingPick = shufflePickJob + shuffleAdvanceJob = viewModelScope.launch { + // A Pick Another still in flight decides what plays; wait for it. + pendingPick?.join() + if (shufflePlayback !== shuffle) return@launch + val result = shuffle.advance(fromContentId) + if (_uiState.value.contentId != fromContentId || shufflePlayback !== shuffle) return@launch + val target = (result as? ApiResult.Success)?.data?.current?.contentId + if (target == null) { + if (shuffle.exhausted) publishShuffleNext(fromContentId) else showShuffleMessage(SHUFFLE_CONTINUE_FAILED) + return@launch + } + // Like the first pick, every shuffled item plays from the start. + playInPlace(target, targetFileId = null, nextAutoAdvanceCount = nextAutoAdvanceCount) + } + } + + /** Up Next "Pick Another": the server replaces the announced pick. */ + fun pickAnotherShuffle() { + val shuffle = shufflePlayback ?: return + if (shufflePickJob?.isActive == true || shuffleAdvanceJob?.isActive == true || nextUpTransitionGate.isActive) return + val forContentId = _uiState.value.contentId + _uiState.update { it.copy(shuffle = it.shuffle?.copy(pickingAnother = true)) } + shufflePickJob = viewModelScope.launch { + val result = shuffle.pickAnother() + _uiState.update { it.copy(shuffle = it.shuffle?.copy(pickingAnother = false)) } + if (_uiState.value.contentId != forContentId || shufflePlayback !== shuffle) return@launch + if (result !is ApiResult.Success && !shuffle.exhausted) { + showShuffleMessage(SHUFFLE_PICK_FAILED) + return@launch + } + publishShuffleNext(forContentId) + } + } + + /** + * Up Next "Stop shuffling": ends the shuffle on the server. The screen + * leaves the player right after, back to where the shuffle started. + */ + fun stopShuffling() { + val shuffle = shufflePlayback ?: return + shufflePlayback = null + shuffleAdvanceJob?.cancel() + shufflePickJob?.cancel() + nextUpCountdownJob?.cancel() + nextUpCountdownJob = null + shuffle.stopInBackground() + _uiState.update { it.copy(shuffle = null, nextEpisode = null, nextUpCountdownSeconds = null) } + } + + private fun showShuffleMessage(message: String) { + shuffleMessageJob?.cancel() + _uiState.update { it.copy(shuffle = it.shuffle?.copy(message = message)) } + shuffleMessageJob = viewModelScope.launch { + delay(4_000) + _uiState.update { it.copy(shuffle = it.shuffle?.copy(message = null)) } + } + } + /** Up-Next "Keep Watching" — dismiss the overlay and stay on the current episode. */ fun dismissNextUp() { if (nextUpTransitionGate.isActive) return @@ -4817,10 +5605,6 @@ class TvPlayerViewModel( _uiState.update { it.copy(videoFillMode = mode) } } - fun onVideoQualitySelectionApplied(resolution: String?) { - _uiState.update { it.copy(selectedFileResolution = resolution) } - } - /** * Switch the in-player video quality (tvOS / web ladder parity): pin a * session-level [qualityOverride] (full menu id, including high variants) @@ -4828,9 +5612,19 @@ class TvPlayerViewModel( * ids that V3 does not understand are mapped via [toV3QualityPreference] * for the replan while the menu keeps the selected rung highlighted. */ - fun switchQuality(wireValue: String) { - val current = qualityOverride ?: preferredQuality ?: "auto" - if (wireValue == current) return + fun switchQuality(wireValue: String): Boolean { + val current = qualityOverride ?: preferredQuality ?: PlaybackQuality.Auto.wireValue + if (wireValue == current) return false + val state = _uiState.value + if (planQualities.isNotEmpty()) { + // The server's menu: its label goes back verbatim. + playbackMutationFence.beginReplan() + launchSubtitleTransaction(state) { + subtitleTransactions.selectQuality(wireValue) + } + return true + } + // Prairie ladder: pin the full menu id, replan with its V3 mapping. qualityOverride = wireValue val v3Preference = org.prairieserver.prairie.playback.toV3QualityPreference(wireValue) viewModelScope.launch { @@ -4847,21 +5641,39 @@ class TvPlayerViewModel( ), ) } - val state = _uiState.value playbackMutationFence.beginReplan() - viewModelScope.launch { - val playbackContext = subtitlePlaybackContext(state) - launchSubtitleTransaction(state) { - subtitleTransactions.updatePlaybackContext(playbackContext) - subtitleTransactions.selectQuality(v3Preference) - } + launchSubtitleTransaction(state) { + subtitleTransactions.selectQuality(v3Preference) } + return true + } + + /** + * Prairie: the Quality picker for a plan. The server's menu when the plan + * lists qualities (upstream), else the Prairie transcode ladder. Records + * the plan's menu so later rebuilds and picks follow the same source. + */ + private fun tvQualityMenu( + available: List<PlaybackAvailableQualityV3>, + selectedLabel: String?, + delivered: PlaybackEffectiveRecipeV3?, + sourceResolution: String?, + playMethod: String?, + ): List<VideoQualityOption> { + planQualities = available + planDelivered = delivered + return transcodeQualityLadder( + sourceResolution, + selectedLabel ?: PlaybackQuality.Auto.wireValue, + playMethod = playMethod, + ) } /** * The server-transcode quality ladder for the current source: Auto + Original * always, plus each downscale rung whose height is below the source (never * offer an upscale). Built from [QualityLadderClient] (fallback until fetched). + * Defers to the plan's own menu while it lists qualities. */ private fun transcodeQualityLadder( sourceResolution: String?, @@ -4869,6 +5681,9 @@ class TvPlayerViewModel( playMethod: String? = null, sourceBitrateKbps: Int = 0, ): List<VideoQualityOption> { + if (planQualities.isNotEmpty()) { + return authoritativePlaybackQualityOptions(planQualities, selectedWire, planDelivered) + } val nativeHeight = org.prairieserver.prairie.playback.sourceHeightForFile( ladder = qualityLadder, resolution = sourceResolution, @@ -4944,6 +5759,8 @@ class TvPlayerViewModel( * error surfaced). */ fun onSubtitlesPaneShown() { + // Opening re-reads sync state so a job that finished meanwhile shows. + subtitleSync.reload() if (aiStatusRequested) return aiStatusRequested = true viewModelScope.launch { @@ -4956,8 +5773,7 @@ class TvPlayerViewModel( } fun openSubtitleSearchDialog() { - val defaultLang = _uiState.value.preferredTextLanguage - ?.takeIf { it.isNotBlank() }?.take(2)?.lowercase() ?: "en" + val defaultLang = LanguageNames.searchCode(_uiState.value.preferredTextLanguage) _subtitleSearch.update { // Keep prior results/language when reopening mid-session. if (it.hasSearched) it else it.copy(language = defaultLang) @@ -5033,6 +5849,8 @@ class TvPlayerViewModel( if (generation != subtitleDownloadGeneration || _uiState.value.mediaFileId != mediaFileId || _uiState.value.sessionId != sessionId) return@launch when (r) { is ApiResult.Success -> { + // Follows the automatic sync the server starts for a new download. + subtitleSync.remember(r.data.subtitle) val merged = refreshSubtitles( autoSelectSubtitleId = r.data.subtitle.id, source = TvSubtitleRefreshSource.Download, @@ -5174,6 +5992,63 @@ class TvPlayerViewModel( return applied } + /** + * The server retimed a subtitle of the file, stored or a sidecar (a sync, + * or a timing set or reset). The sync controller re-reads it and reports + * the change through [onSubtitleSyncTimingChanged]. + */ + internal fun applySubtitleTimingChanged(update: PlaybackSubtitleTimingChanged) { + val state = _uiState.value + val sessionId = state.sessionId ?: return + if (update.sessionId != null && update.sessionId != sessionId) return + if (update.mediaFileId != null && update.mediaFileId != state.mediaFileId) return + val key = update.syncKey ?: update.subtitleId?.let(SubtitleSyncState::storedKey) ?: return + subtitleSync.timingChanged(key) + } + + /** A step of a sync job of the file (realtime): progress, then the outcome. */ + internal fun applySubtitleSyncUpdated(update: PlaybackSubtitleSyncUpdated?) { + update ?: return + val state = _uiState.value + val sessionId = state.sessionId ?: return + if (update.sessionId != null && update.sessionId != sessionId) return + if (update.mediaFileId != null && update.mediaFileId != state.mediaFileId) return + subtitleSync.syncUpdated(update) + } + + fun dismissSubtitleSyncNotice() = subtitleSyncFeedback.dismiss() + + private fun mountedSubtitles(state: UiState): List<PlayerSubtitleInfo> = + if (state.sessionId == null) { + emptyList() + } else { + subtitlesForVideoMediaMount( + subtitles = state.subtitleUrls, + playbackPlan = state.playbackPlan, + subtitleIdentity = state.pendingSubtitleIdentity ?: state.committedSubtitleIdentity, + preferMuxedTracks = true, + ) + } + + private fun activeSubtitleSyncKey(state: UiState): String? = + state.subtitleUrls.activeSyncKey( + state.pendingSubtitleIdentity ?: state.committedSubtitleIdentity, + mounted = mountedSubtitles(state), + ) + + /** + * A syncable subtitle's timing changed. Media3 keeps the cues it already + * parsed, so when that subtitle is the mounted sidecar, remount the same + * item at the same position to fetch it again. Its cue revision tells the + * sync feedback when the new cues show. + */ + private fun onSubtitleSyncTimingChanged(key: String) { + _uiState.update { + it.copy(subtitleCueRevisions = it.subtitleCueRevisions + (key to (it.subtitleCueRevisions[key] ?: 0) + 1)) + } + if (mountedSubtitles(_uiState.value).includesSyncKey(key)) subtitleTransactions.remountSubtitles() + } + // ---- Subtitle suite: AI translate / transcribe ------------------------------- fun refreshAiQuota() { @@ -5320,6 +6195,9 @@ class TvPlayerViewModel( // ---- Settings setters (forward to per-profile DataStore) ------------------- fun onSetPlaybackSpeed(value: Double) { + // A Watch Party plays at 1x; its correction rate is never a user + // speed, and the saved preference stays untouched for solo playback. + if (roomId != null) return viewModelScope.launch { playerSettingsStore.setPlaybackSpeed(value) } } @@ -5347,6 +6225,9 @@ class TvPlayerViewModel( fun onSetDolbyVisionEnabled(value: Boolean) { viewModelScope.launch { playerSettingsStore.setDolbyVisionEnabled(value) + // A Watch Party never restarts its session for a local setting; + // the choice applies from the next start. + if (roomId != null) return@launch val state = _uiState.value if (state.streamUrl == null) return@launch val fileId = state.selectedFileId ?: state.mediaFileId @@ -5518,15 +6399,6 @@ class TvPlayerViewModel( } } - /** Ordered path used by auto-advance before the singleton lifecycle starts the next item. */ - suspend fun stopSessionForExit(): Boolean { - subtitleTransactions.invalidateAndAwaitSettlement() - playbackMutationFence.invalidateAll() - prepareSessionExit() - subtitleTransactions.persistCommittedSelectionAndFlush() - return lifecycleTeardown.stopOrdered(expectedSessionId = exitSessionId) - } - /** Ordinary Back/remote-stop path: snapshot locally and return to detail immediately. */ fun stopSessionForExitAsync( positionMs: Long? = null, @@ -5752,6 +6624,8 @@ class TvPlayerViewModel( * session on the chosen server file version at the current position. */ fun onSelectFileVersion(fileId: Int) { + // A Watch Party plays exactly the room's file. + if (roomId != null) return val state = _uiState.value // Validate BEFORE mutating. A no-op or unknown id used to fall through // after the audio intent had already been dropped, silently losing the @@ -5773,6 +6647,8 @@ class TvPlayerViewModel( * chain. Raw ordinals never cross a file boundary. */ private fun restartSessionInPlace(fileId: Int?) { + // Version and Dolby Vision restarts would choose playback locally. + if (roomId != null) return val state = _uiState.value val currentFileId = state.selectedFileId ?: state.mediaFileId val sourceVersion = state.fileVersions.firstOrNull { it.fileId == currentFileId } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvRoomSyncController.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvRoomSyncController.kt deleted file mode 100644 index 7069c6ca79..0000000000 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvRoomSyncController.kt +++ /dev/null @@ -1,474 +0,0 @@ -package org.prairieserver.prairie.tv.ui.screens.player - -import android.os.SystemClock -import org.prairieserver.prairie.RoomSyncEngine -import org.prairieserver.prairie.SyncDecision -import org.prairieserver.prairie.model.watchtogether.MemberRole -import org.prairieserver.prairie.model.watchtogether.RoomPhase -import org.prairieserver.prairie.model.watchtogether.RoomPlaybackState -import org.prairieserver.prairie.model.watchtogether.RoomSnapshot -import org.prairieserver.prairie.model.watchtogether.TransportAction -import org.prairieserver.prairie.repository.PongSample -import org.prairieserver.prairie.repository.ScheduledTransportCommand -import org.prairieserver.prairie.repository.WatchTogetherRepository -import org.prairieserver.prairie.watchtogether.RoomSession -import org.prairieserver.prairie.watchtogether.RoomCommandDispatcher -import org.prairieserver.prairie.watchtogether.RoomControllerLifetime -import org.prairieserver.prairie.watchtogether.RoomPendingExecutions -import org.prairieserver.prairie.watchtogether.externalPlayPauseDecision -import org.prairieserver.prairie.watchtogether.scheduledCommandStillCurrent -import kotlinx.coroutines.CoroutineStart -import org.prairieserver.prairie.watchtogether.RoomTransportIntent -import org.prairieserver.prairie.watchtogether.roomTransportAuthorized -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlin.math.abs -import java.time.Instant - -// ---- Pure transport-authority gate (unit-tested) --------------------------- - -/** - * A local transport intent originating from the TV UI. Named distinctly from - * the shared [TransportAction] (play/pause/seek wire enum) to avoid a clash — - * the UI only distinguishes "is this a play/pause toggle or a seek?" for the - * authority check; the wire action is derived later. - */ -enum class TvTransportIntent { PlayPause, Seek } - -/** Whether a [TvTransportIntent] may be broadcast as a `transport_request`. */ -enum class TransportGate { Send, Blocked } - -/** - * Decide whether the local member may drive [action] right now. Delegates to the - * shared [roomTransportAuthorized] (the single source of truth mirroring the - * server): seek is host-only, play/pause follows `self_can_control_transport`, - * and nothing is allowed outside the Playing phase. - */ -fun tvRoomTransportGate(s: RoomSnapshot?, action: TvTransportIntent): TransportGate { - val intent = when (action) { - TvTransportIntent.PlayPause -> RoomTransportIntent.PlayPause - TvTransportIntent.Seek -> RoomTransportIntent.Seek - } - return if (roomTransportAuthorized(s, intent)) TransportGate.Send else TransportGate.Blocked -} - -// ---- State-report cadence/suppression gate (duplicated from mobile) -------- -// -// Mobile's pure shouldEmitStateReport lives in androidApp, which is NOT a TV -// dependency, so we duplicate the exact logic here (matching the -// notifications/subtitle cross-app duplication convention). Logic must stay in -// lockstep with androidApp's RoomSyncStateReportGate. - -/** - * Emit a `state_report` once per [cadenceMs], EXCEPT inside a +/- - * [suppressWindowMs] window around a pending transport command's local execute - * time — reporting our position right as we are about to seek/play would feed - * the server a stale pre-execute sample and fight the sync barrier. - */ -fun tvShouldEmitStateReport( - nowMs: Long, - lastReportMs: Long, - cadenceMs: Long, - pendingExecuteAtMs: Long?, - suppressWindowMs: Long, -): Boolean { - if (nowMs - lastReportMs < cadenceMs) return false - if (pendingExecuteAtMs != null && abs(nowMs - pendingExecuteAtMs) <= suppressWindowMs) return false - return true -} - -/** - * Binds a Watch Together room to the TV [TvPlayerViewModel] for the lifetime of - * a synced-player screen. Active ONLY when the player route carries a roomId. - * This is the TV mirror of the mobile `RoomSyncController` (M3) in its final - * fixed state. - * - * The controller OWNS a [RoomSyncEngine] (a pure shared class that lives in the - * binding, not the repo). It: - * - launches the repo WS reconnect loop ([WatchTogetherRepository.connect] is - * suspend) and attaches the player's playback session id once it resolves; - * - drives a ping loop (one ping on start + every 15s) so pongs flow, stamps - * `clientReceivedMs` on each pong with the SAME wall clock used for the ping - * `client_sent_at`, and feeds [RoomSyncEngine.recordPongSample]; - * - feeds each [ScheduledTransportCommand] into [RoomSyncEngine.decide] and - * applies the resulting [SyncDecision] (seek / setPlaying) at the engine- - * scheduled local delay, auto-emitting `ready` on the waiting barrier; - * - emits `state_report` on a ~1.5s cadence, suppressed around a pending - * execute (see [tvShouldEmitStateReport]); - * - emits `ready`/`buffering` on buffer transitions while the room is waiting; - * - routes user play/pause/seek through `transport_request`, gated on - * [tvRoomTransportGate]; - * - surfaces `room_closed` ([closedReason], TERMINAL only) so the screen exits. - * Transient server `error` frames do NOT eject the user (they flow on the - * repo's `errors` stream, never feeding [closedReason]). - * - * ## Clock domains (critical) - * - WALL CLOCK ([System.currentTimeMillis]) is used for everything the engine - * sees: the ping `client_sent_at` RFC3339 string, the `clientReceivedMs` - * pong stamp, and `nowLocalMs` passed to [RoomSyncEngine.decide]. The engine - * derives its offset as `server - clientWallClock`, and the server timestamps - * are wall-clock epoch millis, so these MUST share the same clock. - * - MONOTONIC ([SystemClock.elapsedRealtime]) is used only for the local - * state-report cadence/suppression gate + the engine-scheduled execute - * delay's pending marker — pure local timing that must not jump if the wall - * clock is adjusted. - */ -class TvRoomSyncController( - private val roomId: String, - private val repository: WatchTogetherRepository, - private val roomSession: RoomSession, - private val viewModel: TvPlayerViewModel, - private val scope: CoroutineScope, - private val engine: RoomSyncEngine = RoomSyncEngine(), -) { - companion object { - private const val REPORT_CADENCE_MS = 1_500L - private const val SUPPRESS_WINDOW_MS = 250L - private const val REPORT_TICK_MS = 250L - private const val PING_INTERVAL_MS = 15_000L - } - - /** Live room snapshot for the overlay. */ - val room: StateFlow<RoomSnapshot?> get() = repository.roomSnapshot - - /** - * Non-null once the server CLOSES the room (host left / explicit close) — the - * screen observes and exits. Transient server `error` frames do NOT set this - * (they would wrongly eject the user); they flow on [WatchTogetherRepository.errors]. - */ - val closedReason: StateFlow<String?> get() = repository.roomClosedReason - - // Monotonic-domain state-report cadence bookkeeping. - @Volatile private var lastReportMs = 0L - private val lifetime = RoomControllerLifetime(scope) - private val controllerScope = lifetime.scope - private val deliveryLatch = repository.roomDeliveryLatch - private val commandDispatcher = RoomCommandDispatcher(controllerScope) - private val pendingExecutions = RoomPendingExecutions() - - fun start() { - controllerScope.launch(start = CoroutineStart.UNDISPATCHED) { - combine( - viewModel.uiState.map { it.sessionId }.distinctUntilChanged(), - repository.connectionState, - ) { sessionId, connection -> deliveryLatch.keyOrNull(connection, sessionId) } - .distinctUntilChanged() - .collectLatest { key -> - key ?: return@collectLatest - while (isActive && deliveryLatch.needsAttach(key)) { - val delivered = repository.attachSession(key.playbackSessionId) - deliveryLatch.recordAttach(key, delivered) - if (!delivered) { - delay(500) - } - } - } - } - - // Clock-sync: drive pings (once on start + every PING_INTERVAL_MS) and - // fold pongs into the engine's offset estimate. - controllerScope.launch(start = CoroutineStart.UNDISPATCHED) { - combine( - viewModel.uiState.map { it.sessionId }.distinctUntilChanged(), - repository.connectionState, - ) { sessionId, connection -> - deliveryLatch.keyOrNull(connection, sessionId) to connection.writable - } - .distinctUntilChanged() - .collectLatest { (key, writable) -> - if (!writable || key == null) return@collectLatest - while (!deliveryLatch.isAttached(key)) delay(10) - while (isActive) { - repository.ping(clientSentAt = nowWallClockRfc3339()) - delay(PING_INTERVAL_MS) - } - } - } - controllerScope.launch(start = CoroutineStart.UNDISPATCHED) { - repository.pongs.collect { pong -> recordPong(pong) } - } - - // Apply engine decisions for each scheduled transport command. - controllerScope.launch(start = CoroutineStart.UNDISPATCHED) { - repository.transportCommands.collect { scheduled -> handleCommand(scheduled) } - } - - // Drift reporting loop (suppressed around a pending execute). - controllerScope.launch { - while (isActive) { - val state = viewModel.uiState.value - val sessionId = state.sessionId - val deliveryKey = deliveryLatch.keyOrNull( - repository.connectionState.value, - sessionId, - ) - val now = monotonicMs() - if (deliveryKey != null && - deliveryLatch.isServerAttached( - deliveryKey, - repository.roomDeliveryEcho.value, - ) && - tvShouldEmitStateReport( - now, - lastReportMs, - REPORT_CADENCE_MS, - pendingExecutions.nearestTo(now), - SUPPRESS_WINDOW_MS, - ) - ) { - lastReportMs = now - repository.stateReport( - sessionId = deliveryKey.playbackSessionId, - positionSeconds = state.position, - isPaused = state.isPaused, - ) - } - delay(REPORT_TICK_MS) - } - } - - // ready / buffering during the waiting barrier. - controllerScope.launch(start = CoroutineStart.UNDISPATCHED) { - combine( - viewModel.uiState - .map { it.sessionId to it.isBuffering } - .distinctUntilChanged(), - repository.roomSnapshot - .map { it?.playbackState } - .distinctUntilChanged(), - repository.connectionState, - ) { readiness, playbackState, connection -> Triple(readiness, playbackState, connection) } - .collectLatest { (readiness, playbackState, connection) -> - val (sessionId, buffering) = readiness - val key = deliveryLatch.keyOrNull(connection, sessionId) - if (playbackState != RoomPlaybackState.Waiting || key == null) { - return@collectLatest - } - while ( - !deliveryLatch.isServerAttached( - key, - repository.roomDeliveryEcho.value, - ) - ) { - delay(10) - } - while (isActive && deliveryLatch.needsReadiness(key, buffering)) { - val currentState = viewModel.uiState.value - val delivered = if (buffering) { - repository.buffering( - key.playbackSessionId, - currentState.position, - currentState.isPaused, - ) - } else { - repository.ready( - key.playbackSessionId, - currentState.position, - currentState.isPaused, - ) - } - deliveryLatch.recordReadiness(key, buffering, delivered) - if (!delivered) { - delay(500) - } - } - } - } - - roomSession.adopt(roomId) - } - - /** - * Stamp `clientReceivedMs` (wall clock) at collection time and feed the NTP - * sample to the engine. A sample with any missing server timestamp can't - * update the offset, so we drop it. - */ - private fun recordPong(pong: PongSample) { - val clientSent = pong.clientSentMs ?: return - val serverReceived = pong.serverReceivedMs ?: return - val serverSent = pong.serverSentMs ?: return - engine.recordPongSample( - clientSentMs = clientSent, - serverReceivedMs = serverReceived, - serverSentMs = serverSent, - clientReceivedMs = System.currentTimeMillis(), - ) - } - - private fun handleCommand(scheduled: ScheduledTransportCommand) { - if (!scheduled.connection.writable || - scheduled.connection != repository.connectionState.value - ) { - return - } - val command = scheduled.command - val state = viewModel.uiState.value - val snapshot = repository.roomSnapshot.value - // executeAtMs null (malformed wire timestamp) → apply immediately; pass - // nowWallClock so the engine computes a 0 delay. - val nowWall = System.currentTimeMillis() - val serverExecuteAtMs = scheduled.executeAtMs ?: nowWall - val decision = engine.decide( - command = command, - serverExecuteAtMs = serverExecuteAtMs, - currentLocalPositionMs = (state.position * 1000.0).toLong(), - currentIsPlaying = !state.isPaused, - nowLocalMs = nowWall, - currentRevision = snapshot?.selectionRevision ?: command.selectionRevision, - attachedSessionId = snapshot?.attachedSessionId ?: state.sessionId, - ) ?: return // duplicate / revision / session gate → ignore - - commandDispatcher.dispatch { - applyDecision( - decision = decision, - sessionId = state.sessionId, - command = command, - acceptedConnection = scheduled.connection, - ) - } - } - - private suspend fun applyDecision( - decision: SyncDecision, - sessionId: String?, - command: org.prairieserver.prairie.model.watchtogether.TransportCommand, - acceptedConnection: org.prairieserver.prairie.repository.WatchTogetherConnectionState, - ) { - val delayMs = decision.localExecuteDelayMs.coerceAtLeast(0L) - // Record the pending execute against the MONOTONIC clock for the - // state-report suppression gate, then wait the engine-computed delay. - val pendingToken = pendingExecutions.record(monotonicMs() + delayMs) - try { - if (delayMs > 0) delay(delayMs) - if ( - !scheduledCommandStillCurrent( - command = command, - acceptedPlaybackSessionId = sessionId, - acceptedRoomId = roomId, - acceptedConnection = acceptedConnection, - currentPlaybackSessionId = viewModel.uiState.value.sessionId, - currentRoom = repository.roomSnapshot.value, - currentConnection = repository.connectionState.value, - ) - ) { - return - } - - // Use the deadband-free immediate-seek path: room corrective seeks can - // be as small as the engine's 0.35s DRIFT_THRESHOLD_MS, and a normal - // seekRequest would be fine on TV (no position-mirror deadband), but we - // route through seekImmediate to match mobile's contract exactly and - // guarantee the MediaController moves regardless of any future deadband. - decision.seekToMs?.let { ms -> viewModel.seekImmediate(ms / 1000.0) } - // Idempotent pause: set the desired state directly (NOT a toggle) so a - // duplicate command can't flip us the wrong way. - viewModel.setPaused(!decision.setPlaying) - } finally { - pendingExecutions.finish(pendingToken) - } - - // The epoch/session-keyed readiness collector owns ready delivery and - // retries. Do not advance an untracked one-shot latch here. - } - - // ---- User-initiated transport: route through the room instead of local apply ---- - - /** - * Route a local play/pause through the room. Gated: a guest under - * host_only is a no-op; the screen also disables the affordance, this is - * the defensive backstop. - */ - fun onUserPlayPause() { - val authorization = repository.currentTransportAuthorization() ?: return - val snapshot = authorization.snapshot - if (tvRoomTransportGate(snapshot, TvTransportIntent.PlayPause) != TransportGate.Send) { - return - } - val state = viewModel.uiState.value - val willPause = !state.isPaused - controllerScope.launch { - val delivered = repository.transportRequestForAuthorization( - authorization = authorization, - intent = RoomTransportIntent.PlayPause, - action = if (willPause) TransportAction.Pause.wire else TransportAction.Play.wire, - positionSeconds = state.position, - isPaused = willPause, - ) - if (!delivered) repository.reportDeliveryFailure("room_transport_unavailable") - } - } - - /** Route a local seek through the room. Guests never seek (gated). */ - fun onUserSeek(positionSeconds: Double) { - val authorization = repository.currentTransportAuthorization() ?: return - val snapshot = authorization.snapshot - if (tvRoomTransportGate(snapshot, TvTransportIntent.Seek) != TransportGate.Send) { - return - } - val state = viewModel.uiState.value - controllerScope.launch { - val delivered = repository.transportRequestForAuthorization( - authorization = authorization, - intent = RoomTransportIntent.Seek, - action = TransportAction.Seek.wire, - positionSeconds = positionSeconds, - isPaused = state.isPaused, - ) - if (!delivered) repository.reportDeliveryFailure("room_transport_unavailable") - } - } - - /** Reconcile a deliberate external MediaSession play/pause through room authority. */ - fun onExternalPlayWhenReadyChanged(localPlayWhenReady: Boolean): Boolean? { - val authorization = repository.currentTransportAuthorization() - val snapshot = authorization?.snapshot - val decision = externalPlayPauseDecision(snapshot, localPlayWhenReady) ?: return null - if (authorization == null) return decision.restorePlayWhenReady - decision.requestIsPaused?.let { requestIsPaused -> - val state = viewModel.uiState.value - controllerScope.launch { - val delivered = repository.transportRequestForAuthorization( - authorization = authorization, - intent = RoomTransportIntent.PlayPause, - action = if (requestIsPaused) { - TransportAction.Pause.wire - } else { - TransportAction.Play.wire - }, - positionSeconds = state.position, - isPaused = requestIsPaused, - ) - if (!delivered) repository.reportDeliveryFailure("room_transport_unavailable") - } - } - return decision.restorePlayWhenReady - } - - /** Leave the room. Host close tears the room down for everyone; both reset local state. */ - fun leave(closeRoom: Boolean) { - engine.reset() - roomSession.depart(closeRoom) - } - - /** Cancel this screen binding without ending the process-owned room lease. */ - fun dispose() { - engine.reset() - lifetime.dispose() - } - - /** Wall-clock RFC3339 string for the ping `client_sent_at`. */ - private fun nowWallClockRfc3339(): String = Instant.ofEpochMilli(System.currentTimeMillis()).toString() - - /** Monotonic clock for local-only cadence/scheduling. */ - private fun monotonicMs(): Long = SystemClock.elapsedRealtime() -} - -/** Convenience: is the local member the room host? */ -val RoomSnapshot.isHost: Boolean get() = selfRole == MemberRole.Host diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSkipSeekIndicator.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSkipSeekIndicator.kt index 6a82c090ec..e5c2875175 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSkipSeekIndicator.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSkipSeekIndicator.kt @@ -95,22 +95,22 @@ fun TvSkipSeekIndicator( if (showTrack && snapshot.durationSec > 0.0) { val progress = (snapshot.targetSec / snapshot.durationSec).toFloat().coerceIn(0f, 1f) - // Mirrors TvPlayerScrubber's resting track exactly: 3.5dp - // capsule, White@0.24 rail, solid White fill — so the line is + // Mirrors TvPlayerScrubber's resting track exactly: 4dp + // capsule, White@0.22 rail, solid Paper fill — so the line is // visually the same bar the transport shows in this spot. Box( modifier = Modifier .fillMaxWidth() - .height(3.5.dp) + .height(4.dp) .clip(RoundedCornerShape(percent = 50)) - .background(Color.White.copy(alpha = 0.24f)), + .background(Color.White.copy(alpha = 0.22f)), ) { Box( modifier = Modifier .fillMaxWidth(progress) - .height(3.5.dp) + .height(4.dp) .clip(RoundedCornerShape(percent = 50)) - .background(Color.White), + .background(TvPlayerChrome.Paper), ) } } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleAppearanceOptions.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleAppearanceOptions.kt index a33fd30f91..ea5767262e 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleAppearanceOptions.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleAppearanceOptions.kt @@ -48,10 +48,31 @@ object TvSubtitleAppearanceOptions { /** Background-opacity percentage steps for the HUD quick overlay (coarse). */ val OPACITY_STEPS: List<Int> = listOf(0, 25, 50, 75, 100) + /** Text-opacity percentage steps for the HUD quick overlay (coarse). Starts + * at 25: 1% is the schema floor but reads as no subtitles at all. Matches + * prairie-apple's tvOS HUD. */ + val TEXT_OPACITY_STEPS: List<Int> = listOf(25, 50, 75, 100) + /** Fine-grained opacity steps for the Settings Appearance block — 0–100 by * 5, matching prairie-apple `TVSettingsOptions.backgroundOpacity`. */ val OPACITY_PERCENT_STEPS: List<Int> = (0..100 step 5).toList() + /** Fine-grained text-opacity steps for the Settings Appearance block — same + * 5-point cadence as [OPACITY_PERCENT_STEPS], but starting at 5 rather than + * 0 since fully transparent text has no legible affordance to pick it back + * up from. Matches prairie-apple `TVSettingsOptions.textOpacity`. */ + val TEXT_OPACITY_PERCENT_STEPS: List<Int> = (5..100 step 5).toList() + + /** + * A step list plus the currently saved value, so a value set elsewhere + * (the phone's free-typed percent field) that doesn't fall on this + * picker's cadence is still a selectable option. Without this, opening + * the picker focuses the first step and pressing Select silently + * overwrites the real value with it. + */ + fun percentOptions(steps: List<Int>, current: Int): List<Int> = + (steps + current).toSortedSet().toList() + /** Font / outline color palette: (hex, label). Matches Apple `fontColors`. */ val FONT_COLORS: List<Pair<String, String>> = listOf( "#ffffff" to "White", @@ -61,6 +82,7 @@ object TvSubtitleAppearanceOptions { "#d946ef" to "Magenta", "#ef4444" to "Red", "#3b82f6" to "Blue", + "#9ca3af" to "Gray", "#000000" to "Black", ) @@ -113,6 +135,14 @@ object TvSubtitleAppearanceOptions { else -> FontFamily.SansSerif } + /** + * Converts a stored opacity percent (0-100 for background, 1-100 for + * text) into the alpha channel the TV preview composables paint with. + * Shared by both the Settings and player HUD previews so the two never + * drift apart on rounding or clamping. + */ + fun previewOpacityAlpha(percent: Int, floor: Int): Float = percent.coerceIn(floor, 100) / 100f + fun previewAlignment(value: SubtitlePositionPreset): Alignment = when (value) { SubtitlePositionPreset.Top -> Alignment.TopCenter SubtitlePositionPreset.LowerThird -> Alignment.Center diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleHudState.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleHudState.kt index cea1aa2a87..c2fd441cd5 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleHudState.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleHudState.kt @@ -5,6 +5,7 @@ import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo import org.prairieserver.prairie.playback.encodeSubtitleIdentityPreference import org.prairieserver.prairie.playback.isBitmapSubtitleCodecFamily import org.prairieserver.prairie.playback.subtitleMediaIdentityOrNull +import org.prairieserver.prairie.playback.SubtitleTimingActions internal data class TvSubtitleHudOption( val stableId: String, @@ -24,6 +25,24 @@ internal data class TvSubtitleHudRow( get() = if (applying) "Applying…" else null } +/** + * What the HUD's Subtitles pane shows about subtitle sync: the selected + * track's timing actions, and each track's status line, by ordinal for + * server tracks and by stored ID for rows merged from the stored list. + * Built by the view model, so the player screen passes one value through. + */ +internal data class TvHudSubtitleSync( + val timing: SubtitleTimingActions? = null, + val statusByServerIndex: Map<Int, String> = emptyMap(), + val statusByDownloadId: Map<Int, String> = emptyMap(), +) { + fun statusFor(identity: SubtitleIdentity): String? = when (identity) { + is SubtitleIdentity.ServerSidecar -> statusByServerIndex[identity.serverIndex] + is SubtitleIdentity.Downloaded -> statusByDownloadId[identity.downloadId] + else -> null + } +} + internal data class TvSubtitleHudPresentation( val rows: List<TvSubtitleHudRow>, val hudOpen: Boolean, diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleSearchDialog.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleSearchDialog.kt index 9d5971596b..e42c6c2c5e 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleSearchDialog.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleSearchDialog.kt @@ -48,27 +48,46 @@ import androidx.tv.material3.Glow import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text +import org.prairieserver.prairie.common.ui.LanguageNames import org.prairieserver.prairie.model.subtitles.SubtitleResult import org.prairieserver.prairie.tv.ui.components.rememberTvDialogInitialFocus import org.prairieserver.prairie.tv.ui.theme.DarkBackground import org.prairieserver.prairie.tv.ui.theme.FocusedContainer import org.prairieserver.prairie.tv.ui.theme.FocusedContent -import java.util.Locale /** - * Language codes offered by the search/translate pickers — matches the web's - * common-language set. Cycled left/right on a [TvDialogCyclerRow]; no text - * input anywhere (TV constraint that put this feature in scope). + * Language codes offered by the search/translate pickers — the shared + * [LanguageNames] vocabulary the phone app uses, in the same display-name + * order. Cycled left/right on a [TvDialogCyclerRow]; no text input anywhere + * (TV constraint that put this feature in scope). */ -internal val TvSubtitleLanguageOptions: List<String> = listOf( - "en", "es", "fr", "de", "it", "pt", "nl", "pl", "ru", "ja", "ko", "zh", - "ar", "tr", "sv", "no", "da", "fi", "cs", "el", "he", "hi", "hu", "id", - "ro", "th", "uk", "vi", -) +internal val TvSubtitleLanguageOptions: List<String> = + LanguageNames.dropdownOptions.map { it.first } -/** ISO 639-1 → English display name, fallback uppercased code (spec LanguageNames behavior). */ -internal fun tvLanguageDisplayName(code: String): String = - Locale(code).getDisplayLanguage(Locale.ENGLISH).ifBlank { code.uppercase() } +/** + * Picker position for a profile/track language code (2- or 3-letter). + * Normalizes through [LanguageNames.searchCode], so every supported language + * lands on itself and only unsupported codes fall back to English. + */ +internal fun tvSubtitleLanguageIndex(code: String?): Int = + TvSubtitleLanguageOptions.indexOf(LanguageNames.searchCode(code)) + .takeIf { it >= 0 } ?: TvSubtitleLanguageOptions.indexOf("en") + +/** + * Whether a track's 2- or 3-letter code names the same language as a picker + * code ("bul" vs "bg"). Unknown or missing source codes never match, since + * [LanguageNames.searchCode] would otherwise map them to English. + */ +internal fun tvIsSameLanguage(source: String?, target: String): Boolean { + val code = source?.trim()?.lowercase().orEmpty() + if (code.isEmpty()) return false + val normalized = LanguageNames.searchCode(code) + if (normalized == "en" && LanguageNames.displayName(code) != "English") return false + return normalized == LanguageNames.searchCode(target) +} + +/** Display name for a 2- or 3-letter code, fallback uppercased code. */ +internal fun tvLanguageDisplayName(code: String): String = LanguageNames.displayName(code) /** * Score bucket colors — web/mobile parity (SubtitleScoreBadge): >=70 green @@ -174,8 +193,7 @@ fun TvSubtitleSearchDialog( modifier = Modifier.padding(horizontal = 8.dp), ) - val langIndex = TvSubtitleLanguageOptions.indexOf(state.language) - .takeIf { it >= 0 } ?: 0 + val langIndex = tvSubtitleLanguageIndex(state.language) TvDialogCyclerRow( title = "Language", value = tvLanguageDisplayName(TvSubtitleLanguageOptions[langIndex]), diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleSyncCard.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleSyncCard.kt new file mode 100644 index 0000000000..c2b870b66a --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleSyncCard.kt @@ -0,0 +1,124 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Warning +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.prairieserver.prairie.playback.SubtitleSyncNotice + +private val SyncSuccessColor = Color(0xFF6EE7B7) +private val SyncInfoColor = Color(0xFF7DD3FC) +private val SyncWarningColor = Color(0xFFFCD34D) + +/** + * A card that follows a subtitle sync this viewer started: its progress while + * it runs, then how it ended. It never takes D-pad focus; a finished card + * leaves on its own. Sized for TV viewing distance. + */ +@Composable +internal fun TvSubtitleSyncCard(notice: SubtitleSyncNotice?, modifier: Modifier = Modifier) { + // Keyed by the notice id, so one job's progress updates the card in place + // and a finished card fades out with its last content. + AnimatedContent( + targetState = notice, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + contentKey = { it?.id }, + label = "TvSubtitleSyncCard", + modifier = modifier, + ) { current -> + if (current != null) TvSubtitleSyncCardContent(current) + } +} + +@Composable +private fun TvSubtitleSyncCardContent(notice: SubtitleSyncNotice) { + val running = notice.tone == SubtitleSyncNotice.Tone.Progress + val accent = when (notice.tone) { + SubtitleSyncNotice.Tone.Progress -> Color.White.copy(alpha = 0.15f) + SubtitleSyncNotice.Tone.Success -> SyncSuccessColor.copy(alpha = 0.45f) + SubtitleSyncNotice.Tone.Info -> SyncInfoColor.copy(alpha = 0.45f) + SubtitleSyncNotice.Tone.Warning -> SyncWarningColor.copy(alpha = 0.55f) + } + Surface( + color = Color.Black.copy(alpha = 0.82f), + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, accent), + modifier = Modifier.width(360.dp), + ) { + Row( + modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + val iconModifier = Modifier.padding(top = 2.dp).size(22.dp) + when (notice.tone) { + SubtitleSyncNotice.Tone.Progress -> CircularProgressIndicator( + color = Color.White, + trackColor = Color.White.copy(alpha = 0.25f), + strokeWidth = 2.5.dp, + modifier = iconModifier, + ) + SubtitleSyncNotice.Tone.Success -> + Icon(Icons.Outlined.CheckCircle, contentDescription = null, tint = SyncSuccessColor, modifier = iconModifier) + SubtitleSyncNotice.Tone.Info -> + Icon(Icons.Outlined.Info, contentDescription = null, tint = SyncInfoColor, modifier = iconModifier) + SubtitleSyncNotice.Tone.Warning -> + Icon(Icons.Outlined.Warning, contentDescription = null, tint = SyncWarningColor, modifier = iconModifier) + } + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(text = notice.title, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Medium) + notice.detail?.let { Text(text = it, color = Color.White.copy(alpha = 0.72f), fontSize = 15.sp) } + val percent = notice.percent + if (running && percent != null) { + TvSyncProgressBar(percent = percent, modifier = Modifier.padding(top = 6.dp)) + } + } + } + } +} + +/** A thin bar and its percentage, for a running sync. */ +@Composable +internal fun TvSyncProgressBar(percent: Int, modifier: Modifier = Modifier) { + val progress by animateFloatAsState(targetValue = percent.coerceIn(0, 100) / 100f, label = "TvSubtitleSyncProgress") + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + LinearProgressIndicator( + progress = { progress }, + color = Color.White.copy(alpha = 0.85f), + trackColor = Color.White.copy(alpha = 0.15f), + strokeCap = StrokeCap.Round, + modifier = Modifier.weight(1f), + ) + Text(text = "$percent%", color = Color.White.copy(alpha = 0.62f), fontSize = 14.sp) + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleTransactionAdapter.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleTransactionAdapter.kt index 2bc0bc88b1..0bd63ceae2 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleTransactionAdapter.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleTransactionAdapter.kt @@ -42,8 +42,6 @@ import org.prairieserver.prairie.model.playback.rebaseDownloadedSubtitleUrl import org.prairieserver.prairie.model.playback.reduceSubtitleTransition import org.prairieserver.prairie.model.playback.resolvedSelectedSubtitleIndex import org.prairieserver.prairie.network.ApiResult -import org.prairieserver.prairie.playback.downloadedSubtitleArtifactTrackId -import org.prairieserver.prairie.playback.subtitleLabelIndicatesHearingImpaired import org.prairieserver.prairie.repository.port.PlaybackWriteScope internal data class TvSubtitlePlaybackContext( @@ -922,6 +920,17 @@ internal class TvSubtitleTransactionAdapter( return true } + /** + * Remounts the unchanged subtitle list so a sidecar whose content changed + * behind the same URL (a server timing change) is fetched again. It does + * not start a refresh, so an in-flight download refresh keeps ownership. + */ + fun remountSubtitles() { + if (context == null) return + subtitleRefreshNonce += 1 + publish() + } + fun completeRefreshFailure(owner: TvSubtitleRefreshOwner, message: String): Boolean { if (!ownsRefresh(owner)) return false refreshGeneration += 1 @@ -2579,27 +2588,6 @@ private fun VideoSessionStartV3.Ready.selectedTvSubtitleIdentity(): SubtitleIden ?.let(::tvSubtitleIdentity) } -private fun PlayerSubtitleInfo.isDownloadedTvRow(): Boolean = - isLocalDownloadedSubtitle() - -private fun PlayerSubtitleInfo.toDownloadedTvIdentity(): SubtitleIdentity.Downloaded { - val id = requireNotNull(downloadId) - return SubtitleIdentity.Downloaded( - downloadId = id, - media = org.prairieserver.prairie.model.playback.SubtitleMediaIdentity( - trackId = downloadedSubtitleArtifactTrackId(id), - label = label, - language = language, - codecFamily = codec, - forced = forced ?: false, - hearingImpaired = label - ?.takeIf(::subtitleLabelIndicatesHearingImpaired) - ?.let { true } - ?: false, - ), - ) -} - private fun TvStagedSubtitleCandidate.validationFailure( identity: SubtitleIdentity, ): String? = when (identity) { diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvVideoPlaybackStarter.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvVideoPlaybackStarter.kt index e2a778e3c2..78e4b91f24 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvVideoPlaybackStarter.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvVideoPlaybackStarter.kt @@ -33,6 +33,7 @@ import org.prairieserver.prairie.model.playback.applyResumeRewind import org.prairieserver.prairie.model.playback.buildPlaybackSubtitleChoices import org.prairieserver.prairie.model.playback.enrichAuthoritativePlaybackSubtitleChoices import org.prairieserver.prairie.model.playback.isExplicitStartOver +import org.prairieserver.prairie.playback.firstPlaybackPart import org.prairieserver.prairie.model.playback.resolvePlaybackStartRequestPosition import org.prairieserver.prairie.model.playback.resolvePlaybackStartPosition import org.prairieserver.prairie.network.ApiResult @@ -106,12 +107,29 @@ class TvVideoPlaybackStarter( val preferredQuality = request.preferredQualityOverride ?: playerSettingsStore.preferredQualityFlow.first() val playbackQualityIntent = request.playbackQualityIntent ?: preferredQuality + val room = request.room + if (room != null && watchDetail.versions.none { it.fileId == room.fileId }) { + return failure( + request.contentId, + "The Watch Party's version of this title isn't available to this profile.", + diagnosticsCode = PlaybackDiagnosticsCode.NO_VERSIONS, + ) + } + // Room starts keep the file through every replan; renewals keep + // whatever the original start chose. + val allowAlternateVersions = if (room != null) { + false + } else { + request.recoveryStartParams?.allowAlternateVersions + } val resolvedEpisodeSelection = resolveTvPlaybackStartSelection( - preferredFileId = request.preferredFileId, + // A room plays exactly its file; never a preferred or remembered one. + preferredFileId = room?.fileId ?: request.preferredFileId, episodeSelectionHandoff = request.episodeSelectionHandoff, targetVersions = watchDetail.versions, targetLastFileId = watchDetail.userData?.lastFileId, preferredQuality = preferredQuality, + fromBeginning = isExplicitStartOver(request.resumePositionOverride), ) val version = watchDetail.versions.first { it.fileId == resolvedEpisodeSelection.fileId } // The server rejects -1, while the Ready result retains it for the @@ -184,7 +202,7 @@ class TvVideoPlaybackStarter( // Suppressed for Start Over / retry (request flag) and Watch Together // (roomId); the one rewound value drives both the server seek and the // player start so a transcode cut and the player position never disagree. - val suppressRewind = request.suppressResumeRewind || request.roomId != null || + val suppressRewind = request.suppressResumeRewind || request.roomId != null || room != null || org.prairieserver.prairie.model.playback.isExplicitStartOver(request.resumePositionOverride) // Per-profile setting (default 7; 0 = off). Read once per start. val rewindSeconds = playerSettingsStore.resumeRewindSecondsFlow.first().toDouble() @@ -195,7 +213,9 @@ class TvVideoPlaybackStarter( rewindSeconds = rewindSeconds, ) } - val startRequestPosition = rewound( + // A room starts at the room position, including an explicit zero; + // personal resume never applies. + val startRequestPosition = room?.positionSeconds ?: rewound( resolvePlaybackStartRequestPosition( overridePosition = request.resumePositionOverride, detailPosition = watchDetail.userData?.positionSeconds, @@ -228,6 +248,7 @@ class TvVideoPlaybackStarter( maxBitrateKbps = maxBitrateKbps, deferPublication = true, expectedMetadataOwner = expectedMetadataOwner, + allowAlternateVersions = allowAlternateVersions, ) ) { is ApiResult.Success -> r.data @@ -249,6 +270,7 @@ class TvVideoPlaybackStarter( request.contentId, serverTerminalUserMessage(v3Start.message), diagnosticsCode = PlaybackDiagnosticsCode.serverTerminal(v3Start.reason), + terminalReason = v3Start.reason, ) VideoSessionStartV3.ServerUpgradeRequired -> return failure( request.contentId, @@ -266,6 +288,15 @@ class TvVideoPlaybackStarter( val effectiveFileId = resolved.mediaFileId.takeIf { it > 0 } ?: readyV3.plan.effectiveMediaFileId ?: version.fileId + if (room != null && effectiveFileId != room.fileId) { + discardUnpublishedSession(unpublishedSessionId, lifecycleAdopted) + unpublishedSessionId = null + return failure( + request.contentId, + "The server offered a different version than the Watch Party's.", + diagnosticsCode = PlaybackDiagnosticsCode.START_REQUEST, + ) + } val effectiveVersion = watchDetail.versions.firstOrNull { it.fileId == effectiveFileId } val resolvedDelivery = resolved.resolvedPlaybackDelivery() val resolvedStreamUrl = resolved.playbackPlan?.stream?.url @@ -311,6 +342,7 @@ class TvVideoPlaybackStarter( qualityPreference = playbackQualityIntent, startPosition = sourceStartPos, clientPlaybackContext = readyV3.clientPlaybackContext, + allowAlternateVersions = allowAlternateVersions, ), session = resolved, deferPublication = true, @@ -380,13 +412,15 @@ class TvVideoPlaybackStarter( showForcedSubtitles = watchDetail.effectiveShowForcedSubtitles ?: activeProfile?.showForcedSubtitles ?: true, - intro = watchDetail.intro, - credits = watchDetail.credits, - recap = watchDetail.recap, - preview = watchDetail.preview, + intro = effectiveVersion?.intro, + credits = effectiveVersion?.credits, + recap = effectiveVersion?.recap, + preview = effectiveVersion?.preview, chapters = effectiveVersion?.chapters.orEmpty(), seriesId = watchDetail.seriesId, seriesTitle = watchDetail.seriesTitle, + year = watchDetail.year, + contentType = watchDetail.type, seasonNumber = watchDetail.seasonNumber, episodeNumber = watchDetail.episodeNumber, resolvedEpisodeSelection = resolvedEpisodeSelection, @@ -427,6 +461,7 @@ class TvVideoPlaybackStarter( message: String, cause: Throwable? = null, diagnosticsCode: PlaybackDiagnosticsCode? = null, + terminalReason: String? = null, ): VideoPlaybackStartResult.Error { // Log the throwable here instead of stashing it on the (unread) result — // the message already carries the human-facing detail. @@ -435,6 +470,7 @@ class TvVideoPlaybackStarter( contentId = contentId, message = message, diagnosticsCode = diagnosticsCode, + terminalReason = terminalReason, ) } @@ -487,6 +523,8 @@ fun resolveTvPlaybackStartSelection( targetVersions: List<FileVersion>, targetLastFileId: Int?, preferredQuality: String?, + /** A start from the beginning opens a multi-part item at its first part. */ + fromBeginning: Boolean = false, ): ResolvedEpisodeSelection { require(targetVersions.isNotEmpty()) { "targetVersions must not be empty" } @@ -498,7 +536,9 @@ fun resolveTvPlaybackStartSelection( ?.let { preferredId -> targetVersions.firstOrNull { it.fileId == preferredId } } ?: semanticFileId ?.let { handoffFileId -> targetVersions.firstOrNull { it.fileId == handoffFileId } } - ?: selectPlaybackVersion(targetVersions, targetLastFileId, preferredQuality) + ?: selectPlaybackVersion(targetVersions, targetLastFileId, preferredQuality).let { selected -> + if (fromBeginning) firstPlaybackPart(targetVersions, selected) else selected + } val resolvedSubtitle = resolveEpisodeSubtitleIntent( intent = episodeSelectionHandoff?.subtitle ?: EpisodeSubtitleIntent.auto(), targetSubtitles = buildPlaybackSubtitleChoices( @@ -532,20 +572,6 @@ fun resolveTvPlaybackStartSelection( ) } -/** - * Playback authority for audio at launch. A track chosen on the movie/show - * detail is title-level intent and therefore outranks both a carried episode - * choice and the global language/quality preference. The preference is only - * the fallback when neither manual source supplied a track. - */ -internal fun resolveTvStartAudioTrackIndex( - requestedTitleTrackIndex: Int?, - episodeHandoffTrackIndex: Int?, - automaticPreferenceTrackIndex: Int?, -): Int? = requestedTitleTrackIndex - ?: episodeHandoffTrackIndex - ?: automaticPreferenceTrackIndex - /** Converts the client-side selection to the server's non-negative index contract. */ fun resolveTvServerSubtitleTrackIndex( episodeSelectionHandoff: EpisodeSelectionHandoff?, diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvWatchPartyPlayer.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvWatchPartyPlayer.kt new file mode 100644 index 0000000000..c3de74ef8f --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvWatchPartyPlayer.kt @@ -0,0 +1,519 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import org.prairieserver.prairie.common.player.video.MarkerSkipTarget +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player +import org.koin.compose.koinInject +import org.prairieserver.prairie.watchtogether.RoomSession +import org.prairieserver.prairie.watchtogether.WatchPartyAvailabilityRepository +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.isActive +import org.prairieserver.prairie.common.player.watchparty.IssuedSeekTracker +import org.prairieserver.prairie.common.player.watchparty.WatchPartyPlayback +import org.prairieserver.prairie.common.player.watchparty.watchPartyNoticeText +import org.prairieserver.prairie.repository.WatchTogetherRepository +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.RoomPlaybackState +import org.prairieserver.prairie.playback.PlaybackAction +import org.prairieserver.prairie.watchtogether.RoomPlayerObservation +import org.prairieserver.prairie.watchtogether.RoomPlayerPort +import org.prairieserver.prairie.watchtogether.RoomPlayerState +import org.prairieserver.prairie.watchtogether.RoomTransportIntent +import org.prairieserver.prairie.watchtogether.RoomTransportResult +import org.prairieserver.prairie.watchtogether.WatchPartyPlaybackContext + +/** Why a Watch Party viewer's playback is held locally, without asking the room. */ +enum class TvRoomHold { + /** The app left the foreground (ON_PAUSE / ON_STOP). */ + Background, + + /** Audio focus was lost or the output became noisy. */ + AudioFocus, + + /** The sleep timer fired. */ + SleepTimer, +} + +/** A room start or replan the server refused, waiting for the room to decide. */ +data class TvRoomRefusal( + val context: WatchPartyPlaybackContext, + val fileId: Int, + val reason: String, + val message: String, +) + +/** The room playback-control port, mapped onto the TV player's ViewModel. */ +internal class TvRoomPlayerPort(private val viewModel: TvPlayerViewModel) : RoomPlayerPort { + override val observations: StateFlow<RoomPlayerObservation> = viewModel.roomObservation + + override fun seekTo(sourceSeconds: Double) = viewModel.applyRoomSeek(sourceSeconds) + + override fun setPlaying(playing: Boolean) = viewModel.applyRoomPlaying(playing) + + override fun setCorrectionRate(rate: Double?) = viewModel.setRoomCorrectionRate(rate) + + override fun isBuffered(sourceSeconds: Double): Boolean = viewModel.isRoomPositionBuffered(sourceSeconds) +} + +/** + * The TV player screen's side of a Watch Party: routes every user, remote, + * PrairieCast and MediaSession transport input through [playback], recognizes + * seeks this screen did not issue, and feeds the ViewModel the player samples + * the room observation is built from. + * + * Kept out of the composable so the player screen's generated method stays + * within ART's JIT limit. + */ +internal class TvWatchPartyScreenController( + val playback: WatchPartyPlayback, + private val viewModel: TvPlayerViewModel, + private val nowMs: () -> Long = SystemClock::elapsedRealtime, +) { + private val issuedSeeks = IssuedSeekTracker(nowMs) + private val mainHandler = Handler(Looper.getMainLooper()) + private var departed = false + + fun start() = playback.start() + + /** The screen is going away; the room engagement continues. */ + fun dispose() = playback.dispose() + + fun canSeek(): Boolean = playback.allows(RoomTransportIntent.Seek) + + fun canPlayPause(): Boolean = playback.allows(RoomTransportIntent.PlayPause) + + /** A play/pause toggle from the remote, the transport, PrairieCast or an admin. */ + fun togglePlayPause(): Boolean = setPlaying(play = viewModel.uiState.value.isPaused) + + /** + * Ask the room to play or pause. Pressing play while playback is held + * locally clears the hold without asking the room when the room never + * paused: a playing room resumes this viewer, and a waiting room keeps + * its own barrier. Returns true when the input was accepted. + */ + fun setPlaying(play: Boolean): Boolean { + if (play && viewModel.isRoomHeld) { + val room = playback.room.value + when { + room.isPlayingRoom() -> { + viewModel.resumeRoomPlaybackLocally() + return true + } + room?.playbackState == RoomPlaybackState.Waiting -> { + viewModel.releaseUserRoomHolds() + return true + } + else -> viewModel.releaseUserRoomHolds() + } + } + return playback.requestPlayPause(pause = !play) == RoomTransportResult.Sent + } + + /** Ask the room to seek (host only). Returns true when the request was sent. */ + fun seek(sourceSeconds: Double): Boolean = + playback.requestSeek(sourceSeconds) == RoomTransportResult.Sent + + /** Every seek this screen hands to Media3, so its discontinuity is not mistaken for an outside seek. */ + fun recordIssuedSeek(playerPositionMs: Long) = issuedSeeks.note(playerPositionMs) + + /** A Media3 seek discontinuity. Seeks from outside the app become room requests or are undone. */ + fun onSeekDiscontinuity(fromPlayerMs: Long, toPlayerMs: Long) { + if (!issuedSeeks.isExternal(toPlayerMs)) return + val from = viewModel.roomSourcePositionForPlayer(fromPlayerMs) ?: return + val to = viewModel.roomSourcePositionForPlayer(toPlayerMs) ?: return + playback.onExternalSeek(from, to) { restore -> viewModel.applyRoomSeek(restore) } + } + + /** + * A deliberate MediaSession play/pause (notification, headset, Assistant). + * The room decides; returns the local play state the player must go back + * to meanwhile. + */ + fun onExternalPlayWhenReady(playWhenReady: Boolean): Boolean { + setPlaying(play = playWhenReady) + return !viewModel.uiState.value.isPaused + } + + /** + * A transport command from the playback-control socket. Transport follows + * the room's permission decision; Stop and terminate end only this + * device's engagement. Returns whether the command was accepted. + */ + fun onRemoteTransport(action: PlaybackAction, exit: () -> Unit): Boolean = when (action) { + PlaybackAction.Pause -> setPlaying(play = false) + PlaybackAction.Unpause -> setPlaying(play = true) + PlaybackAction.TogglePlayPause -> togglePlayPause() + is PlaybackAction.SeekTo -> seek(action.positionSeconds) + PlaybackAction.Stop -> { + leave() + exit() + true + } + else -> false + } + + /** + * A phone's PrairieCast launch would replace this party player. Refuse it + * and say why on the TV too. Called off the main thread. + */ + fun refuseLaunch(): String { + mainHandler.post { viewModel.showPlayerMessage(TV_WATCH_PARTY_LAUNCH_REFUSAL) } + return TV_WATCH_PARTY_LAUNCH_REFUSAL + } + + /** The viewer changed quality: the room's stall count and reload pacing start over. */ + fun onQualityChanged() = playback.binding.onQualityChanged() + + /** Leave this device's membership, once. For a host the room ends two minutes later. */ + fun leave() { + if (departed) return + departed = true + playback.leave() + } + + /** End the party for everyone (host), once. */ + fun endForEveryone() { + if (departed) return + departed = true + playback.endForEveryone() + } + + /** The engagement already ended on the server; leaving would only erase that record. */ + fun markEnded() { + departed = true + } + + /** Publish what the player is actually doing. */ + fun sample(player: Player) { + viewModel.onRoomPlayerSample( + positionMs = player.currentPosition, + bufferedPositionMs = player.bufferedPosition, + playWhenReady = player.playWhenReady, + isPlaying = player.isPlaying, + playbackState = player.playbackState, + suppressed = player.playbackSuppressionReason != Player.PLAYBACK_SUPPRESSION_REASON_NONE, + ) + } +} + +/** + * The Watch Party binding for one player screen, or null for solo playback. + * Built once per room; started and disposed with the screen. Disposing never + * leaves the room. + */ +@Composable +internal fun rememberTvWatchParty(roomId: String?, viewModel: TvPlayerViewModel): TvWatchPartyScreenController? { + val repository: WatchTogetherRepository = koinInject() + val roomSession: RoomSession = koinInject() + val availability: WatchPartyAvailabilityRepository = koinInject() + val scope = rememberCoroutineScope() + val watchParty = remember(roomId) { + roomId?.takeIf { it.isNotBlank() }?.let { id -> + TvWatchPartyScreenController( + playback = WatchPartyPlayback( + roomId = id, + repository = repository, + roomSession = roomSession, + availability = availability, + player = TvRoomPlayerPort(viewModel), + scope = scope, + ), + viewModel = viewModel, + ) + } + } + DisposableEffect(watchParty) { + watchParty?.start() + onDispose { watchParty?.dispose() } + } + return watchParty +} + +/** + * The speed the player runs at. Solo: the saved preference. A Watch Party + * ignores it: exactly 1x, or the room's temporary correction rate, which is + * never saved or shown. + */ +@Composable +internal fun TvPlaybackSpeedEffect( + player: Player?, + inWatchParty: Boolean, + viewModel: TvPlayerViewModel, + savedSpeed: Double, +) { + val correctionRate by viewModel.roomCorrectionRate.collectAsState() + val speed = if (inWatchParty) correctionRate ?: 1.0 else savedSpeed + // PlaybackParameters because MediaController has no setter that keeps + // pitch correction defaults. + LaunchedEffect(player, speed) { + player?.playbackParameters = PlaybackParameters(speed.toFloat()) + } +} + +/** + * Keep the marker pill and hidden-controls OK action behind the visible quality + * offer, and off while playback is loading or failed: the overlays still draw + * over the error screen. + */ +internal fun tvManualMarkerSkipTarget( + active: MarkerSkipTarget?, + canSeek: Boolean, + introVisible: Boolean, + playbackUnavailable: Boolean, + qualityOfferVisible: Boolean, +): MarkerSkipTarget? = active?.takeIf { + canSeek && !introVisible && !playbackUnavailable && !qualityOfferVisible +} + +/** Whether the viewer dismissed the current lower-quality offer. */ +@Stable +internal class TvWatchPartyQualityOfferState { + var dismissed by mutableStateOf(false) +} + +/** + * After repeated stalls in a room: one step down the same file's quality + * ladder, or null when there is no offer, it was dismissed, or no lower rung + * remains. A new offer after a quality change can show again. + */ +@Composable +internal fun tvWatchPartyLowerQuality( + watchParty: TvWatchPartyScreenController?, + offerState: TvWatchPartyQualityOfferState, + videoQualities: List<VideoQualityOption>, + selectedFileResolution: String?, +): VideoQualityOption? { + val offered = watchParty?.playback?.offerLowerQuality?.collectAsState()?.value ?: false + LaunchedEffect(offered) { if (!offered) offerState.dismissed = false } + if (!offered || offerState.dismissed) return null + return tvLowerQualityOption(videoQualities, tvQualityHeight(selectedFileResolution)) +} + +/** Switch the same file's quality; in a room the binding's stall pacing starts over. */ +internal fun switchTvPlaybackQuality( + viewModel: TvPlayerViewModel, + watchParty: TvWatchPartyScreenController?, + id: String, +) { + if (viewModel.switchQuality(id)) watchParty?.onQualityChanged() +} + +/** How often the room observation samples the player. */ +private const val ROOM_SAMPLE_INTERVAL_MS = 200L + +/** A player route whose room never appears (no live membership) gives up after this. */ +private const val ROOM_MISSING_EXIT_MS = 10_000L + +/** + * The Watch Party side effects of one player screen: loads each room epoch, + * samples the player for the room observation, shows notices, reports + * refusals to the room, and leaves the player when the room stops playing + * (back to the lobby, membership kept) or the party ends. + */ +@Composable +internal fun TvWatchPartyEffects( + watchParty: TvWatchPartyScreenController, + roomId: String, + viewModel: TvPlayerViewModel, + player: Player?, + onReturnToLobby: () -> Unit, + onPartyEnded: () -> Unit, + repository: WatchTogetherRepository = koinInject(), +) { + val context = LocalContext.current + val latestReturnToLobby by rememberUpdatedState(onReturnToLobby) + val latestPartyEnded by rememberUpdatedState(onPartyEnded) + + // Each new room epoch (selection revision or file) prepares the exact + // file at the room position, paused. Anchor and membership updates keep + // the current player. + LaunchedEffect(watchParty) { + watchParty.playback.playbackContext.collect { playback -> + if (playback != null) viewModel.loadRoomPlayback(playback) + } + } + + // Host Stop keeps the membership and returns to the lobby; an ended + // party leaves the player. + LaunchedEffect(watchParty) { + watchParty.playback.room.collect { room -> + if (room?.phase == RoomPhase.Lobby) latestReturnToLobby() + } + } + LaunchedEffect(watchParty) { + watchParty.playback.ended.filterNotNull().collect { ended -> + if (ended.roomId != roomId) return@collect + watchParty.markEnded() + // The hub this leads to says why the party ended and offers Rejoin. + latestPartyEnded() + } + } + // A route with no live membership behind it (the room token is memory + // only) would otherwise wait on the loading screen forever. + LaunchedEffect(watchParty) { + watchParty.playback.room.collectLatest { room -> + if (room != null) return@collectLatest + delay(ROOM_MISSING_EXIT_MS) + if (watchParty.playback.ended.value == null) { + Toast.makeText(context, "This Watch Party isn't available anymore.", Toast.LENGTH_SHORT).show() + latestPartyEnded() + } + } + } + + LaunchedEffect(watchParty) { + watchParty.playback.notices.collect { notice -> + viewModel.showPlayerMessage(watchPartyNoticeText(notice)) + } + } + // Transient server rejections (a refused request) never end the party. + LaunchedEffect(watchParty) { + repository.errors.collect { message -> + if (message.isNotBlank()) Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + } + + // A refused start or replan goes to the room first. Only the approved + // reasons move everyone to another source; otherwise it is this + // player's error. No other file is ever chosen here. + LaunchedEffect(watchParty) { + viewModel.roomRefusal.filterNotNull().collect { refusal -> + val tookOver = watchParty.playback.reportRefusal( + context = refusal.context, + failedFileId = refusal.fileId, + reason = refusal.reason, + ) + viewModel.onRoomRefusalHandled(refusal, tookOver) + } + } + + // Engine truth for the room binding, sampled between Media3 callbacks. + LaunchedEffect(watchParty, player) { + val sampled = player ?: return@LaunchedEffect + while (isActive) { + watchParty.sample(sampled) + delay(ROOM_SAMPLE_INTERVAL_MS) + } + } +} + +private fun org.prairieserver.prairie.model.watchtogether.RoomSnapshot?.isPlayingRoom(): Boolean = + this != null && + phase == RoomPhase.Playing && + !isPaused && + playbackState == RoomPlaybackState.Playing + +internal const val TV_WATCH_PARTY_LAUNCH_REFUSAL = "Leave the Watch Party to play something else." + +/** Whether a freshly published session starts paused: always in a Watch Party, where only the room plays. */ +internal fun tvPausedWhenReady(inWatchParty: Boolean): Boolean = inWatchParty + +/** + * The context a room start sends. The first start of a revision uses the room + * position as sent. A restart of a session already prepared for this + * revision (retry, reachability retry, 404 renewal) resumes where this player + * was: the requested position, else the current one. Positions from an + * earlier revision never carry over, and the file never changes. + */ +internal fun tvRoomStartContext( + context: WatchPartyPlaybackContext?, + startPositionOverride: Double?, + currentSourcePositionSeconds: Double, + preparedRevision: Long, +): WatchPartyPlaybackContext? { + val room = context ?: return null + if (preparedRevision != room.selectionRevision) return room + val position = startPositionOverride?.takeIf { it.isFinite() && it >= 0.0 } + ?: currentSourcePositionSeconds.takeIf { it.isFinite() && it > 0.0 } + ?: room.positionSeconds + return room.copy(positionSeconds = position) +} + +/** + * Whether reaching [targetPlayerSeconds] needs no new media: it must lie + * between a second behind the playhead and the buffered position of the + * mounted item, all in player time. + */ +internal fun tvRoomPositionBuffered( + targetPlayerSeconds: Double, + currentPlayerSeconds: Double, + bufferedPlayerSeconds: Double, + behindToleranceSeconds: Double = 1.0, +): Boolean { + if (!targetPlayerSeconds.isFinite() || !currentPlayerSeconds.isFinite() || !bufferedPlayerSeconds.isFinite()) { + return false + } + if (bufferedPlayerSeconds < currentPlayerSeconds) return false + return targetPlayerSeconds >= currentPlayerSeconds - behindToleranceSeconds && + targetPlayerSeconds <= bufferedPlayerSeconds +} + +/** The room player state for a Media3 playback state. */ +internal fun tvRoomPlayerState(playbackState: Int?, hasMedia: Boolean): RoomPlayerState { + if (!hasMedia) return RoomPlayerState.Idle + return when (playbackState) { + Player.STATE_BUFFERING -> RoomPlayerState.Buffering + Player.STATE_READY -> RoomPlayerState.Ready + Player.STATE_ENDED -> RoomPlayerState.Ended + else -> RoomPlayerState.Idle + } +} + +/** + * The speed a PrairieCast `setPlaybackSpeed` may apply, or null to refuse it. + * A Watch Party plays at 1x (plus the room's own correction), so a phone + * cannot change it there. + */ +internal fun tvPrairieCastPlaybackSpeed(inWatchParty: Boolean, requested: Double): Double? = + requested.takeIf { !inWatchParty && it.isFinite() && it > 0.0 } + +/** + * One step down the same file's quality ladder: the highest rung below the + * current height. With no known height (Auto or Original), the top rung. + */ +internal fun tvLowerQualityOption( + options: List<VideoQualityOption>, + fallbackCurrentHeight: Int?, +): VideoQualityOption? { + val rungs = options + .mapNotNull { option -> tvQualityHeight(option.resolution)?.let { option to it } } + .sortedByDescending { it.second } + if (rungs.isEmpty()) return null + val selected = options.firstOrNull { it.isSelected } + val current = tvQualityHeight(selected?.resolution) ?: fallbackCurrentHeight + return if (current != null) { + rungs.firstOrNull { it.second < current }?.first + } else { + rungs.first().first + } +} + +/** A vertical resolution from labels such as "720p", "1080p" or "4K". */ +internal fun tvQualityHeight(label: String?): Int? { + val text = label?.trim()?.lowercase() ?: return null + Regex("(\\d{3,4})p").find(text)?.groupValues?.get(1)?.toIntOrNull()?.let { return it } + return when { + "8k" in text -> 4320 + "4k" in text || "uhd" in text -> 2160 + else -> null + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvWatchPartyPlayerOverlays.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvWatchPartyPlayerOverlays.kt new file mode 100644 index 0000000000..99341d7045 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvWatchPartyPlayerOverlays.kt @@ -0,0 +1,382 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import android.widget.Toast +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.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import androidx.compose.ui.zIndex +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import org.prairieserver.prairie.common.ui.components.rememberProfileServerUrl +import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.repository.WatchTogetherRepository +import org.prairieserver.prairie.tv.ui.components.rememberTvDialogInitialFocus +import org.prairieserver.prairie.tv.ui.focus.TvRestoreFocusOnModalDismiss +import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved +import org.prairieserver.prairie.tv.ui.focus.tvModalFocusBoundary +import org.prairieserver.prairie.tv.ui.screens.watchparty.PARTY_KEY_OPTIONS +import org.prairieserver.prairie.tv.ui.screens.watchparty.TV_WATCH_PARTY_HOST_AWAY_NOTE +import org.prairieserver.prairie.tv.ui.screens.watchparty.TV_WATCH_PARTY_HOST_LEAVE_NOTE +import org.prairieserver.prairie.tv.ui.screens.watchparty.TvPartyNotice +import org.prairieserver.prairie.tv.ui.screens.watchparty.TvPartyRoomActions +import org.prairieserver.prairie.tv.ui.screens.watchparty.TvWatchPartyConfirmDialog +import org.prairieserver.prairie.tv.ui.screens.watchparty.TvWatchPartyInvitePage +import org.prairieserver.prairie.tv.ui.screens.watchparty.TvWatchPartyOptionsOverlay +import org.prairieserver.prairie.tv.ui.screens.watchparty.TvWatchPartyRoomView +import org.prairieserver.prairie.tv.ui.screens.watchparty.rememberTvPartyFocus +import org.prairieserver.prairie.tv.ui.screens.watchparty.rememberTvStagedPreview +import org.prairieserver.prairie.tv.ui.screens.watchparty.rememberTvWatchPartyReconnectNotice +import org.prairieserver.prairie.tv.ui.screens.watchparty.tvPartyAnchorKey +import org.prairieserver.prairie.tv.ui.screens.watchparty.tvWatchPartyLeftBehind +import org.prairieserver.prairie.tv.ui.screens.watchparty.tvWatchPartyNameList +import org.prairieserver.prairie.tv.ui.screens.watchparty.tvWatchPartyWaitingFor +import org.prairieserver.prairie.tv.ui.theme.DarkBackground +import org.prairieserver.prairie.watchtogether.WatchPartyAvailabilityRepository +import org.prairieserver.prairie.watchtogether.watchPartyEligibility +import org.prairieserver.prairie.watchtogether.watchPartyErrorMessage +import org.prairieserver.prairie.watchtogether.watchPartyInviteUrl +import org.prairieserver.prairie.common.cards.LocalEpisodeSpoilerPrefs + +/** + * The Watch Party layer over the TV player: the non-focusable status overlay + * and the party panel that Back opens over the retained player. + * + * The panel is a focusable popup; the player keeps running underneath and + * closing the panel never tears it down. Focus returns to the player when it + * closes. Kept out of [TvPlayerScreen] so that composable stays within ART's + * JIT method-size limit. + */ +@Composable +internal fun TvWatchPartyPlayerOverlays( + watchParty: TvWatchPartyScreenController, + panelOpen: Boolean, + isInPictureInPictureMode: Boolean, + playerFocus: FocusRequester, + playerFocused: Boolean, + onPanelOpenChange: (Boolean) -> Unit, + /** The player's terminal exit; it leaves the party on the way out. */ + exit: () -> Unit, + repository: WatchTogetherRepository = koinInject(), +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val room by watchParty.playback.room.collectAsState() + val latestPlayerFocused by rememberUpdatedState(playerFocused) + if (!isInPictureInPictureMode && !panelOpen) { + TvWatchPartyStatusOverlay(watchParty = watchParty, room = room, repository = repository) + } + if (!isInPictureInPictureMode && panelOpen) { + TvWatchPartyPanel( + room = room, + repository = repository, + onClose = { onPanelOpenChange(false) }, + onReturnToLobby = { + onPanelOpenChange(false) + scope.launch { + val result = repository.stopPlayback() + if (result !is ApiResult.Success) { + Toast.makeText( + context, + watchPartyErrorMessage(result, "Couldn't stop playback."), + Toast.LENGTH_SHORT, + ).show() + } + } + }, + onLeave = { + onPanelOpenChange(false) + exit() + }, + onEndForEveryone = { + onPanelOpenChange(false) + watchParty.endForEveryone() + exit() + }, + ) + } + TvRestoreFocusOnModalDismiss( + visible = panelOpen && !isInPictureInPictureMode, + opener = playerFocus, + isOpenerFocused = { latestPlayerFocused }, + ) +} + +/** + * Status that explains what the party is doing, top-center. Never focusable + * and never announced on every update; it only reports. + */ +@Composable +private fun TvWatchPartyStatusOverlay( + watchParty: TvWatchPartyScreenController, + room: RoomSnapshot?, + repository: WatchTogetherRepository, +) { + val catchingUp by watchParty.playback.catchingUp.collectAsState() + val connection by repository.connectionState.collectAsState() + val reconnect = rememberTvWatchPartyReconnectNotice(connection = connection, inRoom = room != null) + + // "Continuing without …": members the room stopped waiting for, shown + // for a few seconds each time the room leaves someone behind. + var leftBehind by remember { mutableStateOf<List<String>>(emptyList()) } + var leftBehindNonce by remember { mutableIntStateOf(0) } + LaunchedEffect(watchParty) { + var previous: RoomSnapshot? = null + watchParty.playback.room.collect { next -> + val names = tvWatchPartyLeftBehind(previous, next) + if (names.isNotEmpty()) { + leftBehind = names + leftBehindNonce++ + } + previous = next + } + } + LaunchedEffect(leftBehindNonce) { + if (leftBehindNonce == 0) return@LaunchedEffect + delay(LEFT_BEHIND_NOTICE_MS) + leftBehind = emptyList() + } + + val playing = room?.phase == RoomPhase.Playing + val hostAway = playing && room?.selfRole != MemberRole.Host && room?.hostConnected == false + val waitingFor = if (catchingUp) null else tvWatchPartyWaitingFor(room) + if (reconnect == null && !hostAway && !catchingUp && waitingFor == null && leftBehind.isEmpty()) return + + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = 20.dp) + .zIndex(6f), + contentAlignment = Alignment.TopCenter, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (reconnect != null) TvPartyNotice(title = reconnect) + if (hostAway) TvPartyNotice(title = "The host disconnected", detail = TV_WATCH_PARTY_HOST_AWAY_NOTE) + if (catchingUp) { + TvPartyNotice( + title = "Catching up to the party", + detail = "The party kept playing".takeIf { room?.selfIgnoreWait == true }, + ) + } + if (waitingFor != null) { + TvPartyNotice( + title = if (waitingFor.isEmpty()) { + "Syncing playback" + } else { + "Syncing playback · Waiting for ${tvWatchPartyNameList(waitingFor)}" + }, + ) + } + if (leftBehind.isNotEmpty()) { + TvPartyNotice(title = "Continuing without ${tvWatchPartyNameList(leftBehind)}") + } + } + } +} + +/** + * The party room over the retained player, as tvOS does it: Back inside party + * playback opens the lobby itself, with Return to playback as the primary + * action. Invitation, playback policy, Return everyone to lobby, End, and + * Leave all live where they do in the lobby (the code pill and "···"). + */ +@Composable +private fun TvWatchPartyPanel( + room: RoomSnapshot?, + repository: WatchTogetherRepository, + onClose: () -> Unit, + onReturnToLobby: () -> Unit, + onLeave: () -> Unit, + onEndForEveryone: () -> Unit, + availability: WatchPartyAvailabilityRepository = koinInject(), +) { + val pending by repository.pendingAction.collectAsState() + val connection by repository.connectionState.collectAsState() + val suggestions by repository.suggestions.collectAsState() + val features = availability.features + val eligibility = watchPartyEligibility( + room = room, + features = features, + busy = pending != null, + personalVotesKnown = false, + ) + val isHost = eligibility.isHost + val serverUrl = rememberProfileServerUrl() + val inviteUrl = room?.takeIf { isHost }?.let { watchPartyInviteUrl(serverUrl, it.invitePath) } + val reconnect = rememberTvWatchPartyReconnectNotice(connection = connection, inRoom = room != null) + val staged = rememberTvStagedPreview( + contentId = room?.selectedContentId, + fileId = room?.selectedFileId, + libraryId = room?.selectedLibraryId, + suggestions = suggestions, + refreshKey = room?.phase, + ) + val focus = rememberTvPartyFocus() + val context = LocalContext.current + val scope = rememberCoroutineScope() + var optionsOpen by remember { mutableStateOf(false) } + var inviteOpen by remember { mutableStateOf(false) } + var confirmEnd by remember { mutableStateOf(false) } + var confirmLeave by remember { mutableStateOf(false) } + val modalOpen = optionsOpen || inviteOpen || confirmEnd || confirmLeave + var opener by remember { mutableStateOf<String?>(null) } + fun openModal(open: () -> Unit) { + opener = focus.focusedKey + open() + } + val anchor = room?.let { tvPartyAnchorKey(it, features, suggestions) } ?: PARTY_KEY_OPTIONS + // The focusable popup hands focus to its first control (the code pill) + // on its own, so put it on Return to playback once the room is laid out. + LaunchedEffect(anchor, room != null) { + if (room == null || optionsOpen || inviteOpen || confirmEnd || confirmLeave) return@LaunchedEffect + requestFocusUntilObserved( + maxAttempts = PANEL_FOCUS_ATTEMPTS, + awaitAttempt = { withFrameNanos { } }, + requestFocus = focus.requester(anchor)::requestFocus, + isFocused = { focus.isFocused(anchor) }, + ) + } + + Popup( + alignment = Alignment.Center, + onDismissRequest = onClose, + properties = PopupProperties( + focusable = true, + dismissOnBackPress = true, + dismissOnClickOutside = false, + clippingEnabled = false, + ), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(DarkBackground) + .tvModalFocusBoundary() + .then(rememberTvDialogInitialFocus(focus.requester(anchor))), + ) { + if (room != null) { + TvWatchPartyRoomView( + room = room, + suggestions = suggestions, + features = features, + eligibility = eligibility, + personalVotesKnown = false, + connection = connection, + staged = staged, + focus = focus, + notice = reconnect, + actions = TvPartyRoomActions( + onInvite = { openModal { inviteOpen = true } }, + onOptions = { openModal { optionsOpen = true } }, + onReturnToPlayback = onClose, + ), + ) + } + } + } + + if (optionsOpen && room != null) { + TvWatchPartyOptionsOverlay( + room = room, + features = features, + eligibility = eligibility, + onSetMode = {}, + onSetPolicy = { policy -> + scope.launch { + val result = repository.updatePolicy(policy) + if (result !is ApiResult.Success) { + Toast.makeText( + context, + watchPartyErrorMessage(result, "Couldn't change who can play and pause."), + Toast.LENGTH_SHORT, + ).show() + } + } + }, + onStopPlayback = { + optionsOpen = false + onReturnToLobby() + }, + onEnd = { + optionsOpen = false + confirmEnd = true + }, + onLeave = { + optionsOpen = false + if (isHost) confirmLeave = true else onLeave() + }, + onDismiss = { optionsOpen = false }, + ) + } + if (inviteOpen && room != null) { + TvWatchPartyInvitePage( + code = room.code, + inviteUrl = inviteUrl, + backdropUrl = staged?.backdropUrl, + backdropThumbhash = staged?.backdropThumbhash, + backdropHidden = staged?.spoilers?.hidesBackdrop(LocalEpisodeSpoilerPrefs.current, staged.backdropUrl) == true, + onDismiss = { inviteOpen = false }, + ) + } + if (confirmEnd) { + TvWatchPartyConfirmDialog( + title = "End this party for everyone?", + message = "Playback stops for everyone and the party closes.", + confirmLabel = "End party", + destructive = true, + onConfirm = { + confirmEnd = false + onEndForEveryone() + }, + onDismiss = { confirmEnd = false }, + ) + } + if (confirmLeave) { + TvWatchPartyConfirmDialog( + title = "Leave the party?", + message = TV_WATCH_PARTY_HOST_LEAVE_NOTE, + confirmLabel = "Leave party", + destructive = true, + onConfirm = { + confirmLeave = false + onLeave() + }, + onDismiss = { confirmLeave = false }, + ) + } + TvRestoreFocusOnModalDismiss( + visible = modalOpen, + opener = opener?.let(focus::requester), + isOpenerFocused = { focus.isFocused(opener) }, + ) +} + +private const val LEFT_BEHIND_NOTICE_MS = 6_000L +private const val PANEL_FOCUS_ATTEMPTS = 10 diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvHouseholdReverifyHost.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvHouseholdReverifyHost.kt new file mode 100644 index 0000000000..25f3125a27 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvHouseholdReverifyHost.kt @@ -0,0 +1,29 @@ +package org.prairieserver.prairie.tv.ui.screens.profiles + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import org.koin.compose.koinInject +import org.prairieserver.prairie.repository.ProfileRepository +import org.prairieserver.prairie.tv.ui.components.TvPinEntryDialog + +/** + * Asks for the primary profile's PIN again when a manage-mode call finds its + * token stale (see [org.prairieserver.prairie.repository.HouseholdManagementSession]). + * App-wide because the call can come from the picker, add, or edit screen. + */ +@Composable +fun TvHouseholdReverifyHost(profileRepository: ProfileRepository = koinInject()) { + val session = profileRepository.householdManagement + val prompt by session.reverify.collectAsState() + prompt?.let { + TvPinEntryDialog( + profile = it.profile, + onPinEntered = session::submitPin, + onDismiss = session::cancelReverify, + errorMessage = it.error, + isVerifying = it.isVerifying, + prompt = "Enter this PIN to manage profiles", + ) + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvProfileSelectionScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvProfileSelectionScreen.kt index 294d534876..08c3964aeb 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvProfileSelectionScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvProfileSelectionScreen.kt @@ -1,113 +1,101 @@ package org.prairieserver.prairie.tv.ui.screens.profiles -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Canvas +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background -import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusGroup import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ChildCare +import androidx.compose.material.icons.automirrored.outlined.Logout +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Dns import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.Logout -import androidx.compose.material.icons.filled.WorkspacePremium +import androidx.compose.material.icons.outlined.Dns +import androidx.compose.material.icons.outlined.ManageAccounts import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.shadow import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathEffect -import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription 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.tv.material3.Card -import androidx.tv.material3.CardDefaults -import androidx.tv.material3.Border -import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.Glow import androidx.tv.material3.Icon -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Surface import androidx.tv.material3.Text -import org.prairieserver.prairie.common.ui.components.ThumbhashImage -import org.prairieserver.prairie.common.ui.components.avatarRef -import org.prairieserver.prairie.common.ui.components.isEmojiAvatar -import org.prairieserver.prairie.common.ui.components.profileAvatarDisplayText -import org.prairieserver.prairie.common.ui.components.rememberProfileAvatarImage +import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.common.ui.marquee.MarqueeColors +import org.prairieserver.prairie.common.ui.marquee.MarqueeProfileAvatar +import org.prairieserver.prairie.common.ui.marquee.MarqueeScene +import org.prairieserver.prairie.common.ui.marquee.MarqueeScrim +import org.prairieserver.prairie.common.ui.marquee.MarqueeScrimStyle +import org.prairieserver.prairie.common.ui.marquee.ProfileTilePalette +import org.prairieserver.prairie.common.ui.marquee.rememberReduceMotion import org.prairieserver.prairie.model.profile.Profile -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.runtime.withFrameNanos -import androidx.compose.runtime.setValue -import androidx.compose.runtime.mutableStateOf -import org.prairieserver.prairie.tv.ui.focus.TvObservedFocusResult -import org.prairieserver.prairie.tv.ui.focus.TvFocusTargetState -import org.prairieserver.prairie.tv.ui.focus.tvProfileFocusTarget -import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved -import org.prairieserver.prairie.tv.ui.focus.TvFrameRelocationMaxAttempts -import org.prairieserver.prairie.tv.ui.components.TvAuroraBackdrop -import org.prairieserver.prairie.tv.ui.components.AuroraJourneyProgress -import org.prairieserver.prairie.tv.ui.components.TvAuroraVariant import org.prairieserver.prairie.tv.ui.components.TvDialogOption import org.prairieserver.prairie.tv.ui.components.TvErrorScreen -import org.prairieserver.prairie.tv.ui.components.TvHeroActionPill import org.prairieserver.prairie.tv.ui.components.TvLoadingScreen import org.prairieserver.prairie.tv.ui.components.TvOptionDialog -import org.prairieserver.prairie.tv.ui.components.TvPillVariant import org.prairieserver.prairie.tv.ui.components.TvPinEntryDialog -import org.prairieserver.prairie.tv.ui.theme.Spacing -import org.prairieserver.prairie.tv.ui.theme.navRailLabel -import org.prairieserver.prairie.tv.ui.theme.prairieCardDefaults -import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeButton +import org.prairieserver.prairie.tv.ui.components.marquee.TvMarqueeButtonKind +import org.prairieserver.prairie.tv.ui.components.marquee.rememberTvActiveServer +import org.prairieserver.prairie.tv.ui.focus.TvFocusTargetState +import org.prairieserver.prairie.tv.ui.focus.TvFrameRelocationMaxAttempts +import org.prairieserver.prairie.tv.ui.focus.TvObservedFocusResult +import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved +import org.prairieserver.prairie.tv.ui.focus.tvProfileFocusTarget -private const val ProfileGridColumns = 4 -private val ProfileTileSize = 140.dp -private val ProfileTileCornerRadius = 18.dp -private val AddProfilePlusSize = 44.dp -private val AddProfilePlusStrokeWidth = 2.dp -private val ProfileUtilityChromeTop = 40.dp -private val ProfileUtilityChromeEnd = 64.dp -// The utility pills size to their own labels. Fixed widths clipped them: -// "Sign Out" rendered as "Sign" at 100dp, and the label a control shows is the -// one thing about it that cannot be allowed to be wrong. -private val ProfileUtilityChipHeight = 28.dp -private val ProfileHeaderTop = 92.dp -private val ProfileGridWidth = 772.dp -private val ProfileGridGap = 70.dp +/** Six avatars and their gaps fit the 870dp safe width. */ +private const val ProfileGridColumns = 6 +private val ProfileAvatarSize = 110.dp +private val ProfileColumnWidth = 128.dp +private val ProfileColumnGap = 14.dp +/** + * "Who's watching?" on Android TV (prairie-apple `ProfileSelectionView` on + * tvOS): round avatars centered over the brand light, "Signed in as", and + * the utilities (change server, sign out, and for admins, manage) small and + * below the people. A PIN prompt opens full screen over the same light, + * tinted with that person's color. + */ @OptIn(ExperimentalTvMaterial3Api::class) @Composable fun TvProfileSelectionScreen( @@ -119,6 +107,7 @@ fun TvProfileSelectionScreen( viewModel: TvProfileSelectionViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() + rememberTvActiveServer() // Reload on resume so a profile created/edited on a pushed screen is // reflected when we return (the VM otherwise only loads in init). @@ -131,6 +120,41 @@ fun TvProfileSelectionScreen( onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } + // One requester per profile ID; see the anchoring effect below. + val tileFocusRequesters = remember { mutableMapOf<String, FocusRequester>() } + var focusedProfileId by remember { mutableStateOf<String?>(null) } + // Closing a PIN prompt puts focus back on the profile that opened it, + // not the first one. + var pinOpenedBy by remember { mutableStateOf<String?>(null) } + LaunchedEffect(state.pinProfile?.id) { + val opened = state.pinProfile?.id + if (opened != null) { + pinOpenedBy = opened + return@LaunchedEffect + } + val returnTo = pinOpenedBy ?: return@LaunchedEffect + pinOpenedBy = null + requestFocusUntilObserved( + maxAttempts = TvFrameRelocationMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = { tileFocusRequesters[returnTo]?.requestFocus() ?: false }, + isFocused = { focusedProfileId == returnTo }, + ) + } + + LaunchedEffect(Unit) { MarqueeScene.focus = MarqueeScene.Focus.Profiles } + LaunchedEffect(state.pinProfile?.id) { + MarqueeScene.personalTint = state.pinProfile?.id?.let(ProfileTilePalette::tint) + } + DisposableEffect(Unit) { onDispose { MarqueeScene.personalTint = null } } + + LaunchedEffect(state.openAddProfile) { + if (state.openAddProfile) { + viewModel.onAddProfileConsumed() + onAddProfile() + } + } + LaunchedEffect(state.selectedProfileId) { if (state.selectedProfileId != null) { viewModel.onSelectionConsumed() @@ -145,85 +169,51 @@ fun TvProfileSelectionScreen( // stale-while-revalidate — keep the grid up during a refresh, like // the error branch already does. state.isLoading && state.profiles.isEmpty() -> TvLoadingScreen() + // A one-profile household opening straight after sign-in: nothing to + // pick, and nothing hidden left for the remote to press. + state.openingOnlyProfile -> MarqueeScrim(MarqueeScrimStyle.Ambient) state.error != null && state.profiles.isEmpty() -> TvErrorScreen( message = state.error!!, onRetry = viewModel::loadProfiles, ) else -> { - Box(modifier = Modifier.fillMaxSize()) { - TvAuroraBackdrop(variant = TvAuroraVariant.Profile) - - Row( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(top = ProfileUtilityChromeTop, end = ProfileUtilityChromeEnd), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (state.canManageProfiles) { - TvHeroActionPill( - label = if (state.isManageMode) "Done" else "Manage", - icon = Icons.Filled.Edit, - variant = TvPillVariant.Hollow, - heightOverride = ProfileUtilityChipHeight, - horizontalPaddingOverride = 10.dp, - labelStyle = MaterialTheme.typography.labelMedium, - onClick = viewModel::toggleManageMode, - ) - } - TvHeroActionPill( - label = "Change Server", - icon = Icons.Filled.Dns, - variant = TvPillVariant.Hollow, - heightOverride = ProfileUtilityChipHeight, - horizontalPaddingOverride = 10.dp, - labelStyle = MaterialTheme.typography.labelMedium, - onClick = onChangeServer, - ) - TvHeroActionPill( - label = "Sign Out", - icon = Icons.Filled.Logout, - variant = TvPillVariant.Hollow, - heightOverride = ProfileUtilityChipHeight, - horizontalPaddingOverride = 10.dp, - labelStyle = MaterialTheme.typography.labelMedium, - onClick = onSignOut, - ) - } - + Box( + modifier = Modifier + .fillMaxSize() + // Hidden behind the PIN prompt. + .alpha(if (state.pinProfile != null) 0f else 1f), + ) { + MarqueeScrim(MarqueeScrimStyle.Ambient) Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier - .fillMaxWidth() - .align(Alignment.TopCenter) - // No journey row here any more, so the title band sits - // at its own top inset rather than offsetting one. - .padding( - top = ProfileHeaderTop, - start = Spacing.safeArea, - end = Spacing.safeArea, - ), + .fillMaxSize() + .padding(vertical = 30.dp), ) { + Spacer(Modifier.weight(1f)) Text( text = "Who's watching?", - style = MaterialTheme.typography.displayLarge, - color = MaterialTheme.colorScheme.onBackground, + fontSize = 32.sp, + fontWeight = FontWeight.ExtraBold, + letterSpacing = (-0.8).sp, + color = MarqueeColors.Ink, + maxLines = 1, ) - Spacer(modifier = Modifier.height(6.dp)) + // Keeps its height while the account loads so the picker doesn't shift. Text( - text = "Select your profile", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.78f), + text = state.accountName?.let { "Signed in as $it" } ?: " ", + fontSize = 14.sp, + color = MarqueeColors.InkSecondary, + maxLines = 1, + modifier = Modifier.padding(top = 7.dp), ) - Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.height(36.dp)) // The screen reloads on every resume, so re-anchoring on // the first tile overrode the viewer's position on every // return. Keep a requester per profile ID and move focus // only where tvProfileFocusTarget says it belongs. - val tileFocusRequesters = remember { mutableMapOf<String, FocusRequester>() } - var focusedProfileId by remember { mutableStateOf<String?>(null) } var previousProfileIds by remember { mutableStateOf(emptyList<String>()) } var hasAnchored by remember { mutableStateOf(false) } val profileIds = state.profiles.map { it.id } @@ -277,15 +267,45 @@ fun TvProfileSelectionScreen( onProfileSelected = viewModel::onProfileSelected, onEditProfile = { onEditProfile(it.id) }, onDeleteProfile = viewModel::requestDelete, - onAddProfile = onAddProfile, + onAddProfile = viewModel::requestAddProfile, ) if (state.error != null) { - Spacer(modifier = Modifier.height(24.dp)) Text( text = state.error!!, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, + fontSize = 14.sp, + color = MarqueeColors.Error, + modifier = Modifier.padding(top = 18.dp), + ) + } + Spacer(Modifier.weight(1f)) + // Full width, so Down from any avatar column finds the row. + Row( + modifier = Modifier.fillMaxWidth().focusGroup(), + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), + ) { + if (state.canManageProfiles) { + TvMarqueeButton( + text = if (state.isManageMode) "Done" else "Manage", + onClick = viewModel::toggleManageMode, + kind = TvMarqueeButtonKind.Glass, + compact = true, + icon = if (state.isManageMode) null else Icons.Outlined.ManageAccounts, + ) + } + TvMarqueeButton( + text = "Change server", + onClick = onChangeServer, + kind = TvMarqueeButtonKind.Glass, + compact = true, + icon = Icons.Outlined.Dns, + ) + TvMarqueeButton( + text = "Sign out", + onClick = onSignOut, + kind = TvMarqueeButtonKind.Glass, + compact = true, + icon = Icons.AutoMirrored.Outlined.Logout, ) } } @@ -293,19 +313,44 @@ fun TvProfileSelectionScreen( } } - // PIN entry dialog — shown when a PIN-protected profile is selected. + // PIN entry — shown when a PIN-protected profile is selected, or for the + // primary profile's PIN before manage mode. val pinProfile = state.pinProfile if (pinProfile != null) { TvPinEntryDialog( - profileName = pinProfile.name, - profileAvatar = pinProfile.avatarRef(), + profile = pinProfile, errorMessage = state.pinError, isVerifying = state.isVerifyingPin, + prompt = if (state.pinForManagement) "Enter this PIN to manage profiles" else "Enter your PIN", onPinEntered = viewModel::onPinEntered, onDismiss = { viewModel.onPinDialogDismissed() }, ) } + // A new TV offers to open the first profile picked automatically. + val rememberOffer = state.rememberOffer + if (rememberOffer != null) { + TvOptionDialog( + title = "Open ${rememberOffer.name} automatically?", + options = listOf( + TvDialogOption( + key = "remember", + title = "Remember", + subtitle = "Open ${rememberOffer.name} when Prairie starts on this TV.", + onClick = { viewModel.answerRememberOffer(remember = true) }, + ), + TvDialogOption( + key = "ask", + title = "Ask every time", + subtitle = "Show Who's watching when Prairie starts. Change this in Settings > General > Profile Selection.", + onClick = { viewModel.answerRememberOffer(remember = false) }, + ), + ), + onDismiss = { viewModel.answerRememberOffer(remember = false) }, + initialFocusKey = "remember", + ) + } + // Delete confirmation — modeled as a two-option dialog (TV-native). val deleteCandidate = state.deleteCandidate if (deleteCandidate != null) { @@ -347,28 +392,25 @@ private fun ProfileTileGrid( val rowCount = (itemCount + ProfileGridColumns - 1) / ProfileGridColumns Column( modifier = Modifier - .width(ProfileGridWidth) + .focusGroup() .onFocusChanged { if (!it.hasFocus) onProfileFocused(null) }, - verticalArrangement = Arrangement.spacedBy(56.dp), + verticalArrangement = Arrangement.spacedBy(30.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { repeat(rowCount) { rowIndex -> val firstItemIndex = rowIndex * ProfileGridColumns val itemsInRow = minOf(ProfileGridColumns, itemCount - firstItemIndex) Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy( - ProfileGridGap, - Alignment.CenterHorizontally, - ), + horizontalArrangement = Arrangement.spacedBy(ProfileColumnGap), verticalAlignment = Alignment.Top, ) { repeat(itemsInRow) { columnIndex -> val itemIndex = firstItemIndex + columnIndex - Box(modifier = Modifier.width(ProfileTileSize), contentAlignment = Alignment.TopCenter) { + Box(modifier = Modifier.width(ProfileColumnWidth), contentAlignment = Alignment.TopCenter) { when { itemIndex < profiles.size -> { val profile = profiles[itemIndex] - TvProfileCard( + TvProfileTile( profile = profile, manageMode = isManageMode, onClick = { @@ -386,7 +428,7 @@ private fun ProfileTileGrid( }, ) } - itemIndex == profiles.size && canAddProfile -> TvAddProfileCard( + itemIndex == profiles.size && canAddProfile -> TvAddProfileTile( onClick = onAddProfile, modifier = Modifier.onFocusChanged { if (it.isFocused) onProfileFocused(null) @@ -400,146 +442,84 @@ private fun ProfileTileGrid( } } +/** A white ring around a focused avatar, drawn outside it. */ +private fun Modifier.focusRing(focused: Boolean): Modifier = drawBehind { + if (focused) { + drawCircle( + color = MarqueeColors.Ink, + radius = size.minDimension / 2f + 6.dp.toPx(), + center = Offset(size.width / 2f, size.height / 2f), + style = Stroke(width = 3.dp.toPx()), + ) + } +} + @OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun TvProfileCard( +private fun TvProfileTile( profile: Profile, manageMode: Boolean, onClick: () -> Unit, onDelete: () -> Unit, modifier: Modifier = Modifier, ) { - val avatar = profile.avatarRef() - val avatarText = remember(avatar, profile.name) { - profileAvatarDisplayText(avatar, profile.name) - } - val avatarImage = rememberProfileAvatarImage(avatar) - - val shape = RoundedCornerShape(ProfileTileCornerRadius) - val cardFocus = prairieCardDefaults(shape = shape) - val tileTint = profile.tintColor() - // Per-profile tinted focus halo ("this profile is alive"), mirroring tvOS - // ProfileTile's colored glow. - val interactionSource = remember { MutableInteractionSource() } - val isFocused by interactionSource.collectIsFocusedAsState() - val haloElevation by animateDpAsState( - targetValue = if (isFocused) 20.dp else 0.dp, - label = "profileTileHalo", - ) + var focused by remember { mutableStateOf(false) } + val reduceMotion = rememberReduceMotion() + val scale by animateFloatAsState(if (focused && !reduceMotion) 1.12f else 1f, label = "tvProfileTileScale") Column(horizontalAlignment = Alignment.CenterHorizontally) { - Card( - onClick = onClick, - interactionSource = interactionSource, - shape = CardDefaults.shape(shape = shape), - scale = cardFocus.scale, - border = cardFocus.border, - glow = cardFocus.glow, + Box( modifier = modifier - .size(ProfileTileSize) - .shadow( - elevation = haloElevation, - shape = shape, - clip = false, - ambientColor = tileTint, - spotColor = tileTint, - ), - ) { - Box( - modifier = Modifier - .fillMaxSize() - .background(tileTint) - .border( - width = 1.dp, - color = Color.White.copy(alpha = 0.14f), - shape = shape, - ), - contentAlignment = Alignment.Center, - ) { - if (avatarImage != null) { - ThumbhashImage( - url = avatarImage.url, - thumbhash = null, - contentDescription = profile.name, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - transparent = true, - cacheKey = avatarImage.cacheKey, - onError = avatarImage.onLoadFailed, - ) - Box( - modifier = Modifier - .fillMaxSize() - .background( - Brush.verticalGradient( - 0f to Color.Transparent, - 1f to Color.Black.copy(alpha = 0.22f), - ), - ), - ) - } else { - Text( - text = avatarText, - fontSize = if (isEmojiAvatar(avatar)) { - 70.sp - } else { - 60.sp - }, - fontWeight = FontWeight.Bold, - color = Color.White.copy(alpha = 0.94f), - ) - } - - // Primary (crown) + child (leaf) + PIN (lock) indicator badges, - // top-trailing — tvOS ProfileTile surfaces these so a household - // reads at a glance. - if (profile.isPrimary || profile.isChild || profile.hasPin) { - Row( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(12.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - if (profile.isPrimary) ProfileBadge(Icons.Filled.WorkspacePremium, "Primary profile") - if (profile.isChild) ProfileBadge(Icons.Filled.ChildCare, "Child profile") - if (profile.hasPin) ProfileBadge(Icons.Filled.Lock, "PIN protected") - } + .onFocusChanged { focused = it.isFocused } + .clickable( + role = Role.Button, + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ) + .semantics { + contentDescription = profile.name + stateDescription = listOfNotNull( + "PIN protected".takeIf { profile.hasPin }, + "Kids profile".takeIf { profile.isChild }, + "Edit".takeIf { manageMode }, + ).joinToString(", ") } - - if (manageMode) { - // Pencil badge signals the card edits instead of selects. - Box( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(8.dp) - .size(26.dp) - .background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(13.dp)), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Filled.Edit, - contentDescription = "Edit", - tint = Color.White, - modifier = Modifier.size(18.dp), - ) - } + .graphicsLayer { scaleX = scale; scaleY = scale } + .shadow(if (focused) 18.dp else 0.dp, CircleShape, clip = false) + .focusRing(focused), + ) { + MarqueeProfileAvatar( + profile, + size = ProfileAvatarSize, + baseSize = 92.dp, + modifier = Modifier.alpha(if (manageMode) 0.6f else 1f), + ) + if (manageMode) { + // Pencil: the tile edits instead of selects. + Box(Modifier.size(ProfileAvatarSize), contentAlignment = Alignment.Center) { + Icon(Icons.Filled.Edit, contentDescription = null, tint = Color.White, modifier = Modifier.size(30.dp)) } } } - Spacer(modifier = Modifier.height(12.dp)) Text( text = profile.name, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.88f), + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + color = if (focused) MarqueeColors.Ink else MarqueeColors.InkSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = if (focused) 22.dp else 13.dp), ) // The server never deletes the primary profile (409 // primary_profile_protected), so don't offer it. if (manageMode && !profile.isPrimary) { - Spacer(modifier = Modifier.height(8.dp)) - TvHeroActionPill( - label = "Delete", - icon = Icons.Filled.Delete, - variant = TvPillVariant.Ghost, + TvMarqueeButton( + text = "Delete", onClick = onDelete, + kind = TvMarqueeButtonKind.Plain, + compact = true, + icon = Icons.Filled.Delete, + modifier = Modifier.padding(top = 4.dp), ) } } @@ -547,129 +527,48 @@ private fun TvProfileCard( @OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun TvAddProfileCard(onClick: () -> Unit, modifier: Modifier = Modifier) { - val shape = RoundedCornerShape(ProfileTileCornerRadius) - val interactionSource = remember { MutableInteractionSource() } - val isFocused by interactionSource.collectIsFocusedAsState() - val surfaceAlpha = if (isFocused) 0.14f else 0.06f - val strokeAlpha = if (isFocused) 0.70f else 0.28f - val plusAlpha = if (isFocused) 1.0f else 0.60f - Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { - Surface( - onClick = onClick, - interactionSource = interactionSource, - shape = ClickableSurfaceDefaults.shape(shape = shape), - colors = ClickableSurfaceDefaults.colors( - containerColor = Color.Transparent, - contentColor = Color.White, - focusedContainerColor = Color.Transparent, - focusedContentColor = Color.White, - pressedContainerColor = Color.Transparent, - pressedContentColor = Color.White, - ), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.10f), - border = ClickableSurfaceDefaults.border( - border = Border( - border = BorderStroke(0.dp, Color.Transparent), - shape = shape, - ), - focusedBorder = Border( - border = BorderStroke(0.dp, Color.Transparent), - shape = shape, - ), - ), - glow = ClickableSurfaceDefaults.glow( - focusedGlow = Glow( - elevationColor = Color.Black.copy(alpha = 0.50f), - elevation = 22.dp, - ), - ), - modifier = Modifier.size(ProfileTileSize), - ) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.White.copy(alpha = surfaceAlpha), shape) - .border( - width = if (isFocused) 4.dp else 0.dp, - color = if (isFocused) Color.White else Color.Transparent, - shape = RoundedCornerShape(ProfileTileCornerRadius + 4.dp), - ), - contentAlignment = Alignment.Center, - ) { - val dashColor = Color.White.copy(alpha = strokeAlpha) - Canvas(modifier = Modifier.fillMaxSize()) { - drawRoundRect( - color = dashColor, - cornerRadius = CornerRadius( - ProfileTileCornerRadius.toPx(), - ProfileTileCornerRadius.toPx(), - ), - style = Stroke( - width = 2.dp.toPx(), - pathEffect = PathEffect.dashPathEffect( - floatArrayOf(8.dp.toPx(), 6.dp.toPx()), - ), - ), - ) - } - Canvas(modifier = Modifier.size(AddProfilePlusSize)) { - val color = Color.White.copy(alpha = plusAlpha) - val stroke = AddProfilePlusStrokeWidth.toPx() - val center = size.width / 2f - drawLine( - color = color, - start = Offset(center, 0f), - end = Offset(center, size.height), - strokeWidth = stroke, - cap = StrokeCap.Round, - ) - drawLine( - color = color, - start = Offset(0f, center), - end = Offset(size.width, center), - strokeWidth = stroke, - cap = StrokeCap.Round, +private fun TvAddProfileTile(onClick: () -> Unit, modifier: Modifier = Modifier) { + var focused by remember { mutableStateOf(false) } + val reduceMotion = rememberReduceMotion() + val scale by animateFloatAsState(if (focused && !reduceMotion) 1.12f else 1f, label = "tvAddTileScale") + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box( + modifier = modifier + .onFocusChanged { focused = it.isFocused } + .clickable( + role = Role.Button, + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ) + .semantics { contentDescription = "Add profile" } + .graphicsLayer { scaleX = scale; scaleY = scale } + .focusRing(focused) + .size(ProfileAvatarSize) + .background(Color.White.copy(alpha = if (focused) 0.14f else 0f), CircleShape) + .drawBehind { + val stroke = 2.dp.toPx() + drawCircle( + color = Color.White.copy(alpha = if (focused) 0.7f else 0.25f), + radius = size.minDimension / 2f - stroke / 2f, + style = Stroke(width = stroke, pathEffect = PathEffect.dashPathEffect(floatArrayOf(6.dp.toPx(), 5.dp.toPx()))), ) - } - } + }, + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Filled.Add, + contentDescription = null, + tint = MarqueeColors.Ink.copy(alpha = if (focused) 1f else 0.4f), + modifier = Modifier.size(33.dp), + ) } - Spacer(modifier = Modifier.height(12.dp)) Text( - text = "Add Profile", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.55f), - ) - } -} - -@Composable -private fun ProfileBadge(icon: androidx.compose.ui.graphics.vector.ImageVector, description: String) { - Box( - modifier = Modifier - .size(34.dp) - .background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(17.dp)), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = icon, - contentDescription = description, - tint = Color.White, - modifier = Modifier.size(18.dp), + text = "Add profile", + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + color = MarqueeColors.Ink.copy(alpha = if (focused) 1f else 0.62f), + modifier = Modifier.padding(top = if (focused) 22.dp else 13.dp), ) } } - -private fun Profile.tintColor(): Color { - val palette = listOf( - Color(0xFFB56F5F), - Color(0xFF6B7C93), - Color(0xFF8A785E), - Color(0xFF607C6A), - Color(0xFF92656E), - Color(0xFF6F668B), - Color(0xFF577B83), - Color(0xFFA28453), - ) - return palette[kotlin.math.abs(id.hashCode()) % palette.size] -} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvProfileSelectionViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvProfileSelectionViewModel.kt index f9e3b1aad4..8a94218ec4 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvProfileSelectionViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvProfileSelectionViewModel.kt @@ -2,19 +2,23 @@ package org.prairieserver.prairie.tv.ui.screens.profiles import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.prairieserver.prairie.common.ui.marquee.SignInHandoff import org.prairieserver.prairie.model.profile.Profile import org.prairieserver.prairie.model.profile.authorizedProfileToken +import org.prairieserver.prairie.model.profile.householdPrimary import org.prairieserver.prairie.model.server.ServerContract import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.AuthScopeSnapshot import org.prairieserver.prairie.repository.AuthRepository import org.prairieserver.prairie.repository.ProfileCommitResult import org.prairieserver.prairie.repository.ProfileRepository -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch +import org.prairieserver.prairie.repository.ProfileVerificationRecovery +import org.prairieserver.prairie.common.profiles.ProfileLaunchPreferences data class TvProfileSelectionUiState( val profiles: List<Profile> = emptyList(), @@ -26,11 +30,21 @@ data class TvProfileSelectionUiState( val isManageMode: Boolean = false, // PIN entry flow. val pinProfile: Profile? = null, + /** The open PIN prompt is the primary profile's, unlocking manage mode rather than selecting it. */ + val pinForManagement: Boolean = false, val isVerifyingPin: Boolean = false, val pinError: String? = null, // Profile pending delete confirmation (manage mode). val deleteCandidate: Profile? = null, val isDeleting: Boolean = false, + /** Manage mode just started for "Add profile"; the screen opens the add form and consumes this. */ + val openAddProfile: Boolean = false, + /** The signed-in account's username, shown under the title; null until read. */ + val accountName: String? = null, + /** A one-profile household is being opened straight after sign-in; the picker stays hidden. */ + val openingOnlyProfile: Boolean = false, + /** The profile just picked on a new TV, while the TV asks whether to open it automatically. */ + val rememberOffer: Profile? = null, ) /** @@ -41,6 +55,9 @@ data class TvProfileSelectionUiState( class TvProfileSelectionViewModel( private val profileRepository: ProfileRepository, private val authRepository: AuthRepository? = null, + private val consumeSkipsSingleProfile: () -> Boolean = SignInHandoff::consumeSkipsSingleProfilePicker, + private val profileVerificationRecovery: ProfileVerificationRecovery? = null, + private val launchPreferences: ProfileLaunchPreferences? = null, ) : ViewModel() { private val _uiState = MutableStateFlow(TvProfileSelectionUiState()) @@ -52,9 +69,57 @@ class TvProfileSelectionViewModel( /** Monotonic generation for profile-list loads; see [loadProfiles]. */ private var loadAttempt: Int = 0 + /** + * Read once, on the first load: a sign-in that just finished asked to + * skip a one-person picker (see [SignInHandoff]). + */ + private var skipsSingleProfile: Boolean? = null + + /** "Add profile" asked for manage mode; open the add form once it starts. */ + private var addAfterUnlock = false + init { loadProfiles() reloadWhenUpdateRequiredLifts() + reloadWhenStaleProfileCleared() + leaveManageModeWhenSessionEnds() + } + + /** + * A load sent with a profile token the server no longer accepts fails with + * `profile_verification_required`. The recovery then clears that profile, + * and the grid reloads without it instead of leaving the error up. + */ + private fun reloadWhenStaleProfileCleared() { + val recovery = profileVerificationRecovery ?: return + viewModelScope.launch { + recovery.profileCleared.collect { + dropStaleRememberOffer() + loadProfiles() + } + } + } + + /** + * Closes an open remember offer whose profile is no longer the active one + * (cleared by recovery, or another identity took over). An offer from a + * commit that landed after this check began stays. + */ + private suspend fun dropStaleRememberOffer() { + val offer = _uiState.value.rememberOffer ?: return + if (profileRepository.getActiveProfileId() == offer.id) return + _uiState.update { if (it.rememberOffer == offer) it.copy(rememberOffer = null) else it } + } + + /** Cancel on a re-prompt for the primary's PIN ends the session; manage mode goes with it. */ + private fun leaveManageModeWhenSessionEnds() { + viewModelScope.launch { + profileRepository.householdManagement.isActive.collect { active -> + if (!active && _uiState.value.isManageMode) { + _uiState.update { it.copy(isManageMode = false, deleteCandidate = null) } + } + } + } } /** @@ -95,7 +160,9 @@ class TvProfileSelectionViewModel( viewModelScope.launch { _uiState.update { it.copy(isLoading = true, error = null) } val scope = profileRepository.captureIdentityScope() - val isAdmin = (authRepository?.getCurrentUser() as? ApiResult.Success)?.data?.role?.equals("admin", ignoreCase = true) == true + val skipSingle = skipsSingleProfile ?: consumeSkipsSingleProfile().also { skipsSingleProfile = it } + val user = (authRepository?.getCurrentUser() as? ApiResult.Success)?.data + val isAdmin = user?.role?.equals("admin", ignoreCase = true) == true val listed = profileRepository.listProfiles() // Two separate reasons to drop this response: a newer load // superseded it, or the identity it was fetched under is gone. @@ -103,6 +170,7 @@ class TvProfileSelectionViewModel( if (!profileRepository.identityScopeUnchanged(scope)) { // The displayed grid is gone, so its scope must go with it. gridScope = null + profileRepository.householdManagement.end() _uiState.update { it.copy( isLoading = false, @@ -112,6 +180,9 @@ class TvProfileSelectionViewModel( deleteCandidate = null, ) } + // A pick committed while this load was in flight also moves + // the identity; its offer is still current. + dropStaleRememberOffer() return@launch } when (val result = listed) { @@ -121,15 +192,23 @@ class TvProfileSelectionViewModel( // failed reload under a NEW identity left the OLD grid // qualified by the NEW scope. gridScope = scope + // A household with one profile and no PIN doesn't need a + // picker after signing in: open it, once. + val only = result.data.singleOrNull()?.takeIf { skipSingle && !it.hasPin } + skipsSingleProfile = false + if (!isAdmin) profileRepository.householdManagement.end() _uiState.update { it.copy( isLoading = false, + openingOnlyProfile = only != null, + accountName = user?.username ?: it.accountName, profiles = result.data, canManageProfiles = isAdmin, isManageMode = if (isAdmin) it.isManageMode else false, deleteCandidate = if (isAdmin) it.deleteCandidate else null, ) } + if (only != null) onProfileSelected(only) } is ApiResult.Error -> { _uiState.update { @@ -148,15 +227,73 @@ class TvProfileSelectionViewModel( } } + /** + * Entering manage mode acts as the household's primary profile, the only + * one the server lets manage the others: the picker has no selected profile + * on TV. A PIN-locked primary is verified first; Cancel there leaves manage + * mode off. The primary is never selected, so watching still needs a pick. + */ fun toggleManageMode() { - if (!_uiState.value.canManageProfiles) return - _uiState.update { - it.copy( - isManageMode = !it.isManageMode, - // Leaving manage mode clears any pending delete prompt. - deleteCandidate = if (it.isManageMode) null else it.deleteCandidate, - ) + val state = _uiState.value + if (!state.canManageProfiles) return + if (state.isManageMode) { + exitManageMode() + return + } + enterManageMode(thenAdd = false) + } + + /** + * "Add profile" creates a household profile, so it needs the same primary + * profile step as Manage; the add form opens once manage mode is on. + */ + fun requestAddProfile() { + val state = _uiState.value + if (!state.canManageProfiles) return + if (state.isManageMode && profileRepository.householdManagement.isActive.value) { + _uiState.update { it.copy(openAddProfile = true) } + } else { + enterManageMode(thenAdd = true) + } + } + + fun onAddProfileConsumed() { + _uiState.update { it.copy(openAddProfile = false) } + } + + private fun enterManageMode(thenAdd: Boolean) { + val primary = _uiState.value.profiles.householdPrimary() + if (primary == null) { + _uiState.update { it.copy(error = "Couldn't find the primary profile needed to manage profiles.") } + return + } + pinAttempt++ + addAfterUnlock = thenAdd + if (primary.hasPin) { + _uiState.update { + it.copy(pinProfile = primary, pinForManagement = true, pinError = null, isVerifyingPin = false) + } + return } + profileRepository.householdManagement.begin(primary, profileToken = null, scope = gridScope) + onManageModeStarted() + } + + private fun onManageModeStarted() { + val add = addAfterUnlock + addAfterUnlock = false + _uiState.update { it.copy(isManageMode = true, openAddProfile = add) } + } + + private fun exitManageMode() { + profileRepository.householdManagement.end() + // Leaving manage mode clears any pending delete prompt. + _uiState.update { it.copy(isManageMode = false, deleteCandidate = null) } + } + + override fun onCleared() { + profileRepository.householdManagement.end() + super.onCleared() } /** @@ -182,7 +319,7 @@ class TvProfileSelectionViewModel( if (profile.hasPin) { // Open the PIN dialog; actual selection happens in onPinEntered. _uiState.update { - it.copy(pinProfile = profile, pinError = null, isVerifyingPin = false) + it.copy(pinProfile = profile, pinForManagement = false, pinError = null, isVerifyingPin = false) } return } @@ -193,15 +330,17 @@ class TvProfileSelectionViewModel( } fun onPinDialogDismissed() { + addAfterUnlock = false // Abandon any in-flight verification for the dismissed profile. pinAttempt++ _uiState.update { - it.copy(pinProfile = null, pinError = null, isVerifyingPin = false) + it.copy(pinProfile = null, pinForManagement = false, pinError = null, isVerifyingPin = false) } } fun onPinEntered(pin: String) { val profile = _uiState.value.pinProfile ?: return + val forManagement = _uiState.value.pinForManagement val attempt = ++pinAttempt _uiState.update { it.copy(isVerifyingPin = true, pinError = null) } viewModelScope.launch { @@ -222,11 +361,23 @@ class TvProfileSelectionViewModel( // gate on the issued token (matches phone) — never commit on // a bare 200, nor on a valid=true carrying no proof. val token = r.data.authorizedProfileToken() - if (token != null) { + if (token != null && forManagement) { + // An account or server change during the round trip + // voids this answer; the reload shows the new identity. + if (!profileRepository.identityScopeUnchanged(scope)) { + onPinDialogDismissed() + loadProfiles() + return@launch + } + // Kept for manage mode only; the device's selection is untouched. + profileRepository.householdManagement.begin(profile, token, scope) + _uiState.update { it.copy(pinProfile = null, pinForManagement = false, isVerifyingPin = false) } + onManageModeStarted() + } else if (token != null) { commitSelection(profile, token, scope) _uiState.update { it.copy(pinProfile = null, isVerifyingPin = false) } } else { - _uiState.update { it.copy(isVerifyingPin = false, pinError = "Incorrect PIN") } + _uiState.update { it.copy(isVerifyingPin = false, pinError = "Wrong PIN. Try again.") } } } is ApiResult.Error -> _uiState.update { @@ -252,6 +403,7 @@ class TvProfileSelectionViewModel( ) { viewModelScope.launch { val result = profileRepository.selectProfile(profile.id, profileToken, expectedScope) + if (result != ProfileCommitResult.Committed) _uiState.update { it.copy(openingOnlyProfile = false) } if (result == ProfileCommitResult.ScopeChanged) { // Identity moved under us — don't route into this profile, and // drop the grid with it. A retained grid keeps D-pad focus on @@ -261,15 +413,38 @@ class TvProfileSelectionViewModel( profiles = emptyList(), selectedProfileId = null, pinProfile = null, + pinForManagement = false, isVerifyingPin = false, pinError = null, deleteCandidate = null, isManageMode = false, ) } + profileRepository.householdManagement.end() loadProfiles() return@launch } + if (result == ProfileCommitResult.Committed && launchPreferences?.isRememberOfferPending == true) { + // A new TV: ask whether to open this profile automatically before Home. + _uiState.update { it.copy(rememberOffer = profile) } + return@launch + } + _uiState.update { it.copy(selectedProfileId = profile.id) } + } + } + + /** + * Answers the new TV's offer and continues into the picked profile. The + * dialog can stay up indefinitely, so an answer for a profile that is no + * longer the active one (cleared by recovery meanwhile) neither saves nor + * navigates; the offer stays pending for the next pick. + */ + fun answerRememberOffer(remember: Boolean) { + val profile = _uiState.value.rememberOffer ?: return + _uiState.update { it.copy(rememberOffer = null) } + viewModelScope.launch { + if (profileRepository.getActiveProfileId() != profile.id) return@launch + launchPreferences?.answerRememberOffer(remember) _uiState.update { it.copy(selectedProfileId = profile.id) } } } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsScreen.kt index 66adf006fd..7eb8173f07 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsScreen.kt @@ -257,6 +257,7 @@ fun TvRecommendationsScreen( } else -> TvSkylineSectionFeed( surfaceKey = "for_you", + onMarqueeTranslated = viewModel::refresh, sections = visibleSections, onItemClick = onRecommendationItemClick, // Both tokens are monotonic, so their sum is too — which is all diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/requests/TvMyRequestsScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/requests/TvMyRequestsScreen.kt deleted file mode 100644 index 474aed2e4f..0000000000 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/requests/TvMyRequestsScreen.kt +++ /dev/null @@ -1,261 +0,0 @@ -package org.prairieserver.prairie.tv.ui.screens.requests - -import androidx.compose.foundation.background -import androidx.compose.foundation.focusGroup -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.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Cancel -import androidx.compose.material.icons.filled.Inbox -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -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.foundation.lazy.rememberLazyListState -import androidx.compose.runtime.DisposableEffect -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.onFocusChanged -import org.prairieserver.prairie.tv.ui.focus.rememberTvFlatReturnRestoration -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import org.prairieserver.prairie.model.request.canCancel -import org.prairieserver.prairie.tv.ui.components.TvErrorScreen -import org.prairieserver.prairie.tv.ui.components.TvLoadingScreen -import org.prairieserver.prairie.tv.ui.shell.TvTopMenuLayout -import org.prairieserver.prairie.tv.ui.theme.PrairieBlue -import org.prairieserver.prairie.tv.ui.theme.Spacing -import org.prairieserver.prairie.tv.ui.theme.sectionEyebrow -import org.prairieserver.prairie.viewmodel.MyRequestsViewModel -import org.koin.compose.viewmodel.koinViewModel - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -fun TvMyRequestsScreen( - onOpenLibraryItem: (contentId: String) -> Unit = {}, - onOpenRequestDetail: (mediaType: String, tmdbId: Int) -> Unit = { _, _ -> }, - onInitialContentFocus: () -> Unit = {}, - viewModel: MyRequestsViewModel = koinViewModel(), -) { - val state by viewModel.uiState.collectAsState() - val visibleRequests = state.requests.filterTvMediaRequests() - val restoreItemFocusRequester = remember { FocusRequester() } - var attachedRequesterId by remember { mutableStateOf<String?>(null) } - val listState = rememberLazyListState() - - // Rows are identified by the REQUEST, not by what they open. A row with a - // library item opens that item while every other row opens the request - // detail, so two rows can share a navigation target; keying restoration on - // it would send focus to whichever came first. request.id is the row. - // - // Projected from the FILTERED list, because that is what is rendered — the - // view model's unfiltered list would put every index in a different - // coordinate space from the rows these indices address. - // - // Nothing here paginates, so hasMore is false and the page hunt never runs; - // the restoration is purely resolve, scroll, confirm. - val restoration = rememberTvFlatReturnRestoration( - itemIds = visibleRequests.map { it.id }, - hasMore = false, - isLoadingMore = false, - // Refresh here is a button rather than a resume hook, so it cannot - // collide with entry the way the personal lists' does — but it still - // REPLACES the list, and a viewer who presses it and opens a row - // before it lands would otherwise restore against the outgoing one. - isReplacingContent = state.isRefreshing, - errorMessage = state.error, - surfaceKey = "my-requests", - onLoadMore = {}, - scrollToItem = { itemIndex -> listState.scrollToItem(itemIndex + requestsHeaderSlots(state.error)) }, - requestFocus = restoreItemFocusRequester::requestFocus, - onRestored = onInitialContentFocus, - ) - - // No separate first-entry path. On a fresh arrival the restoration already - // targets row zero, so a second requester would race it — and worse, the - // one that ran first was UNATTACHED whenever restoration owned row zero, - // meaning it reported a content handoff it had not actually made. One - // claimant, and it only reports once focus is confirmed. - - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - ) { - MyRequestsHeader( - isRefreshing = state.isRefreshing, - onRefresh = viewModel::refresh, - ) - when { - state.isLoading && state.requests.isEmpty() -> TvLoadingScreen() - state.error != null && state.requests.isEmpty() -> TvErrorScreen( - message = state.error ?: "Failed to load your requests.", - onRetry = viewModel::load, - ) - visibleRequests.isEmpty() -> EmptyMyRequests() - else -> LazyColumn( - state = listState, - modifier = Modifier - .fillMaxSize() - .focusGroup(), - verticalArrangement = Arrangement.spacedBy(14.dp), - contentPadding = PaddingValues( - start = Spacing.safeArea, - end = Spacing.safeArea, - bottom = 56.dp, - ), - ) { - state.error?.let { error -> - item { - Text( - text = error, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - ) - } - } - itemsIndexed(visibleRequests, key = { _, request -> request.id }) { index, request -> - if (index == restoration.requesterItemIndex) { - DisposableEffect(request.id) { - attachedRequesterId = request.id - restoration.onRequesterAttached(request.id) - onDispose { - // Only if this row is still the owner. When the - // requester moves, the new row can attach - // before the old one disposes, and an - // unconditional clear would erase the live - // attachment and lose the restore. - if (attachedRequesterId == request.id) { - attachedRequesterId = null - restoration.onRequesterAttached(null) - } - } - } - } - TvRequestListCard( - request = request, - onClick = { - restoration.onItemClicked(itemId = request.id, index = index) - // In-library items open library detail; everything else - // opens the request detail (phone parity — rows are always - // actionable, not only when a library item exists). - val contentId = request.libraryContentId - if (contentId != null) onOpenLibraryItem(contentId) - else onOpenRequestDetail(request.mediaType, request.tmdbId) - }, - focusRequester = restoreItemFocusRequester - .takeIf { index == restoration.requesterItemIndex }, - // hasFocus, not isFocused: the card's own Surface owns - // focus below this modifier, so isFocused never fires - // here and the restoration could never confirm. - modifier = Modifier.onFocusChanged { - if (it.hasFocus) { - restoration.onItemFocused(request.id, index) - } else { - restoration.onItemFocusLost(request.id) - } - }, - trailing = { - if (request.canCancel()) { - TvRequestActionPill( - label = if (state.actionInFlightId == request.id) "Canceling" else "Cancel", - icon = Icons.Filled.Cancel, - onClick = { viewModel.cancel(request.id) }, - enabled = state.actionInFlightId != request.id, - ) - } - }, - ) - } - item { Spacer(modifier = Modifier.height(8.dp)) } - } - } - } -} - -@Composable -private fun MyRequestsHeader( - isRefreshing: Boolean, - onRefresh: () -> Unit, -) { - Column( - modifier = Modifier.padding( - start = Spacing.safeArea, - end = Spacing.safeArea, - top = TvTopMenuLayout.contentTopInset, - bottom = Spacing.lg, - ), - verticalArrangement = Arrangement.spacedBy(Spacing.md), - ) { - Text( - text = "REQUESTS", - style = sectionEyebrow, - color = PrairieBlue.copy(alpha = 0.92f), - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "My Requests", - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onBackground, - ) - TvRequestActionPill( - label = if (isRefreshing) "Refreshing" else "Refresh", - icon = Icons.Filled.Refresh, - onClick = onRefresh, - enabled = !isRefreshing, - ) - } - } -} - -@Composable -private fun EmptyMyRequests() { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - androidx.tv.material3.Icon( - imageVector = Icons.Filled.Inbox, - contentDescription = null, - tint = Color.White.copy(alpha = 0.62f), - modifier = Modifier.height(30.dp), - ) - Text( - text = "No requests yet", - style = MaterialTheme.typography.headlineSmall, - color = Color.White, - ) - Text( - text = "Requested movies and series will appear here.", - style = MaterialTheme.typography.bodyMedium, - color = Color.White.copy(alpha = 0.68f), - ) - } - } -} - -/** The error banner, when shown, is one list slot ahead of the request rows. */ -private fun requestsHeaderSlots(error: String?): Int = if (error != null) 1 else 0 diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/requests/TvRequestComponents.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/requests/TvRequestComponents.kt index 2b70c3fcd9..88caa2154b 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/requests/TvRequestComponents.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/requests/TvRequestComponents.kt @@ -1,307 +1,249 @@ package org.prairieserver.prairie.tv.ui.screens.requests import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Movie -import androidx.compose.material.icons.filled.Tv +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.PriorityHigh +import androidx.compose.material.icons.filled.Schedule import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.semantics.contentDescription +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.unit.TextUnit import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults -import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Surface import androidx.tv.material3.Text import org.prairieserver.prairie.common.cards.LocalCardPresentation +import org.prairieserver.prairie.common.requests.color import org.prairieserver.prairie.common.ui.components.ThumbhashImage import org.prairieserver.prairie.model.request.MediaRequest -import org.prairieserver.prairie.model.request.RequestAvailability +import org.prairieserver.prairie.model.request.RequestDisplayState import org.prairieserver.prairie.model.request.RequestMediaResult -import org.prairieserver.prairie.model.request.RequestMediaType -import org.prairieserver.prairie.model.request.RequestOutcome -import org.prairieserver.prairie.model.request.RequestStatus -import org.prairieserver.prairie.model.request.badgeStatus -import org.prairieserver.prairie.model.request.requestDisplayLabel +import org.prairieserver.prairie.model.request.RequestProgress +import org.prairieserver.prairie.model.request.RequestStatusTint import org.prairieserver.prairie.model.request.requestPosterUrl -import org.prairieserver.prairie.model.request.targetSummary -import org.prairieserver.prairie.tv.ui.components.TvCardWidth -import org.prairieserver.prairie.tv.ui.theme.PrairieBlue -import org.prairieserver.prairie.tv.ui.theme.DarkOnPrimary import org.prairieserver.prairie.tv.ui.theme.RowDimens -import org.prairieserver.prairie.tv.ui.theme.cardScaled +import org.prairieserver.prairie.tv.ui.theme.PrairieSecondaryText import org.prairieserver.prairie.tv.ui.theme.prairieCardDefaults -@OptIn(ExperimentalTvMaterial3Api::class) +internal fun RequestStatusTint.tvColor(): Color = color(neutral = PrairieSecondaryText) + +/** Dense Skyline poster width, scaled by the card presentation preference. */ +@Composable +internal fun tvRequestCardWidth(): Dp = RowDimens.DensePosterWidth * LocalCardPresentation.current.posterSize.posterScale + +/** + * Poster card for a TMDB search/discover result in the Skyline rows' grammar + * (`TvMediaCard`): a title already requested or in the library gets a small + * corner badge, and the caption keeps its year. + */ @Composable fun TvRequestCard( result: RequestMediaResult, onClick: () -> Unit, modifier: Modifier = Modifier, + /** Applied to the focusable card itself rather than the card-and-caption column. */ cardModifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, focusRequester: FocusRequester? = null, + width: Dp = tvRequestCardWidth(), ) { - val cardShape = RoundedCornerShape(8.dp) - val cardFocus = prairieCardDefaults(shape = cardShape) - val caption = LocalCardPresentation.current.caption + TvRequestPosterCard( + title = result.title, + year = result.year, + posterPath = result.posterPath, + progress = RequestProgress.of(result.availability, result.request), + showsStatusInCaption = false, + onClick = onClick, + onLongClick = onLongClick, + focusRequester = focusRequester, + modifier = modifier, + cardModifier = cardModifier, + width = width, + ) +} - Column(modifier = modifier.width(TvCardWidth.cardScaled())) { +/** Poster card for a request record (the user's own, or one an admin decides on): its status is the caption. */ +@Composable +fun TvRequestRecordCard( + record: MediaRequest, + onClick: () -> Unit, + modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, + focusRequester: FocusRequester? = null, + width: Dp = tvRequestCardWidth(), +) { + TvRequestPosterCard( + title = record.title, + year = record.year, + posterPath = record.posterPath, + progress = RequestProgress.of(record), + showsStatusInCaption = true, + onClick = onClick, + onLongClick = onLongClick, + focusRequester = focusRequester, + modifier = modifier, + width = width, + ) +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun TvRequestPosterCard( + title: String, + year: Int?, + posterPath: String?, + progress: RequestProgress?, + showsStatusInCaption: Boolean, + onClick: () -> Unit, + onLongClick: (() -> Unit)?, + focusRequester: FocusRequester?, + modifier: Modifier, + width: Dp, + cardModifier: Modifier = Modifier, +) { + val caption = LocalCardPresentation.current.caption + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val shape = RoundedCornerShape(8.dp) + val focus = prairieCardDefaults(shape = shape) + val label = listOfNotNull(title, year?.takeIf { it > 0 }?.toString(), progress?.shortLabel).joinToString(", ") + Column(modifier = modifier.width(width)) { Card( onClick = onClick, - shape = CardDefaults.shape(shape = cardShape), - scale = cardFocus.scale, - border = cardFocus.border, - glow = cardFocus.glow, - modifier = Modifier - .then(cardModifier) + onLongClick = onLongClick, + interactionSource = interactionSource, + shape = CardDefaults.shape(shape = shape), + scale = focus.scale, + border = focus.border, + glow = focus.glow, + modifier = cardModifier .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) - .size(RowDimens.PosterWidth.cardScaled(), RowDimens.PosterHeight.cardScaled()), + .size(width, width * 1.5f) + .semantics { contentDescription = label }, ) { - Box(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.fillMaxSize().background(Color.White.copy(alpha = 0.06f))) { ThumbhashImage( - url = requestPosterUrl(result.posterPath), + url = requestPosterUrl(posterPath), thumbhash = null, - contentDescription = result.title, + contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), ) - TvRequestStatusChip( - status = result.badgeStatus(), - outcome = null, - modifier = Modifier - .align(Alignment.TopStart) - .padding(8.dp), - ) + if (!showsStatusInCaption && progress != null) { + val badgeSize = (width * RowDimens.WatchedBadgeSizeFraction) + .coerceIn(RowDimens.WatchedBadgeMinSize, RowDimens.WatchedBadgeMaxSize) + val badgePadding = (width * RowDimens.WatchedBadgePaddingFraction) + .coerceIn(RowDimens.WatchedBadgeMinPadding, RowDimens.WatchedBadgeMaxPadding) + TvRequestPosterBadge( + state = progress.display, + size = badgeSize, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(badgePadding), + ) + } } } if (caption.showsTitle) { - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(11.dp)) Text( - text = result.title, - style = MaterialTheme.typography.titleSmall, - color = Color.White.copy(alpha = 0.82f), - maxLines = 2, + text = title, + style = MaterialTheme.typography.titleSmall.copy(fontSize = 15.5.sp, lineHeight = 18.5.sp), + color = if (isFocused) Color.White else Color.White.copy(alpha = 0.78f), + maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.fillMaxWidth(), ) - if (caption.showsMetadata) { - RequestMetaLine(mediaType = result.mediaType, year = result.year) - } - } - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -fun TvRequestListCard( - request: MediaRequest, - onClick: (() -> Unit)?, - modifier: Modifier = Modifier, - trailing: @Composable (() -> Unit)? = null, - focusRequester: FocusRequester? = null, -) { - val shape = RoundedCornerShape(10.dp) - Surface( - onClick = onClick ?: {}, - enabled = onClick != null, - shape = ClickableSurfaceDefaults.shape(shape), - colors = ClickableSurfaceDefaults.colors( - containerColor = Color.White.copy(alpha = 0.06f), - contentColor = Color.White, - focusedContainerColor = Color.White.copy(alpha = 0.16f), - focusedContentColor = Color.White, - pressedContainerColor = Color.White.copy(alpha = 0.18f), - pressedContentColor = Color.White, - ), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.015f), - modifier = modifier - .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(14.dp), - horizontalArrangement = Arrangement.spacedBy(14.dp), - ) { - Box( - modifier = Modifier - .width(72.dp) - .aspectRatio(2f / 3f) - .background(Color.White.copy(alpha = 0.08f), RoundedCornerShape(8.dp)), - ) { - ThumbhashImage( - url = requestPosterUrl(request.posterPath), - thumbhash = null, - contentDescription = request.title, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), + when { + showsStatusInCaption && progress != null -> TvRequestStatusLabel( + progress = progress, + fontSize = 14.sp, + color = Color.White.copy(alpha = 0.70f), ) - } - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(7.dp), - ) { - Text( - text = request.title, - style = MaterialTheme.typography.titleMedium, - color = Color.White, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + caption.showsMetadata && year != null && year > 0 -> Text( + text = year.toString(), + style = MaterialTheme.typography.bodySmall.copy(fontSize = 14.sp, lineHeight = 18.sp), + color = Color.White.copy(alpha = 0.70f), ) - RequestMetaLine(mediaType = request.mediaType, year = request.year) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - TvRequestStatusChip(status = request.status, outcome = null) - TvRequestStatusChip(status = request.outcome, outcome = request.outcome) - } - request.targetSummary()?.let { summary -> - Text( - text = summary, - style = MaterialTheme.typography.bodySmall, - color = Color.White.copy(alpha = 0.70f), - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - request.lastError.takeIf { it.isNotBlank() }?.let { error -> - Text( - text = error, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } } - trailing?.invoke() } } } +/** A white glyph on a filled status disc, in the watched badge's geometry. */ @Composable -fun TvRequestStatusChip( - status: String, - outcome: String?, - modifier: Modifier = Modifier, -) { - val normalized = status.ifBlank { outcome.orEmpty() } - val (background, foreground) = when (normalized.lowercase()) { - RequestAvailability.Available, RequestStatus.Completed -> PrairieBlue to DarkOnPrimary - RequestStatus.Pending, RequestOutcome.Active -> Color(0xFFFFD166).copy(alpha = 0.92f) to Color.Black - RequestStatus.Failed, RequestOutcome.Failed -> MaterialTheme.colorScheme.error to MaterialTheme.colorScheme.onError - RequestOutcome.Cancelled, RequestOutcome.Declined -> Color.White.copy(alpha = 0.14f) to Color.White.copy(alpha = 0.80f) - else -> Color.Black.copy(alpha = 0.64f) to Color.White +internal fun TvRequestPosterBadge(state: RequestDisplayState, size: Dp, modifier: Modifier = Modifier) { + val icon = when (state) { + RequestDisplayState.Pending -> Icons.Filled.Schedule + RequestDisplayState.OnTheWay -> Icons.Filled.ArrowDownward + RequestDisplayState.InLibrary -> Icons.Filled.Check + is RequestDisplayState.NeedsAttention -> Icons.Filled.PriorityHigh + is RequestDisplayState.Unavailable -> return } Box( - modifier = modifier - .background(background, RoundedCornerShape(100.dp)) - .padding(horizontal = 9.dp, vertical = 4.dp), + modifier = modifier.size(size).background(state.tint.tvColor(), CircleShape), contentAlignment = Alignment.Center, ) { - Text( - text = normalized.requestDisplayLabel(), - style = MaterialTheme.typography.labelSmall, - color = foreground, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Icon(imageVector = icon, contentDescription = null, tint = Color.White, modifier = Modifier.size(size * 0.6f)) } } -@OptIn(ExperimentalTvMaterial3Api::class) +/** Status dot and label. */ @Composable -fun TvRequestActionPill( - label: String, - icon: ImageVector?, - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, -) { - Surface( - onClick = onClick, - enabled = enabled, - shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(100.dp)), - colors = ClickableSurfaceDefaults.colors( - containerColor = Color.White.copy(alpha = 0.10f), - contentColor = Color.White, - focusedContainerColor = Color.White.copy(alpha = 0.92f), - focusedContentColor = DarkOnPrimary, - pressedContainerColor = Color.White.copy(alpha = 0.92f), - pressedContentColor = DarkOnPrimary, - disabledContainerColor = Color.White.copy(alpha = 0.05f), - disabledContentColor = Color.White.copy(alpha = 0.42f), - ), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f), - modifier = modifier, - ) { - Row( - modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - if (icon != null) { - Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(18.dp)) - } - Text(text = label, style = MaterialTheme.typography.labelLarge) - } - } -} - -@Composable -private fun RequestMetaLine( - mediaType: String, - year: Int?, +internal fun TvRequestStatusLabel( + progress: RequestProgress, modifier: Modifier = Modifier, + text: String = progress.shortLabel, + fontSize: TextUnit = 14.sp, + fontWeight: FontWeight = FontWeight.Normal, + color: Color = Color.White.copy(alpha = 0.70f), + dotSize: Dp = 6.dp, ) { Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), + horizontalArrangement = Arrangement.spacedBy(dotSize), ) { - Icon( - imageVector = mediaType.icon(), - contentDescription = null, - tint = Color.White.copy(alpha = 0.62f), - modifier = Modifier.size(13.dp), - ) + Box(modifier = Modifier.size(dotSize).background(progress.tint.tvColor(), CircleShape)) Text( - text = listOfNotNull(mediaType.requestDisplayLabel(), year?.toString()).joinToString(" • "), - style = MaterialTheme.typography.bodySmall, - color = Color.White.copy(alpha = 0.62f), + text = text, + style = MaterialTheme.typography.bodySmall.copy(fontSize = fontSize, lineHeight = fontSize * 1.3f), + fontWeight = fontWeight, + color = color, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } - -fun RequestMediaResult.canOpenLibraryDetail(): Boolean = - availability == RequestAvailability.Available && !libraryContentId.isNullOrBlank() - -fun RequestMediaResult.canRequest(): Boolean = - availability != RequestAvailability.Available && request.requestable - -private fun String.icon(): ImageVector = when (this) { - RequestMediaType.Series -> Icons.Default.Tv - else -> Icons.Default.Movie -} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/requests/TvRequestDetailScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/requests/TvRequestDetailScreen.kt index 4b04fa3c4c..f0eec0d92c 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/requests/TvRequestDetailScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/requests/TvRequestDetailScreen.kt @@ -1,5 +1,8 @@ package org.prairieserver.prairie.tv.ui.screens.requests +import org.prairieserver.prairie.common.requests.rememberRequestRouter +import org.prairieserver.prairie.model.request.RequestMediaResult +import androidx.compose.ui.graphics.vector.ImageVector import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.focusable @@ -9,12 +12,22 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -25,54 +38,71 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add +import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Button +import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -import org.prairieserver.prairie.common.ui.components.ThumbhashImage +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf +import org.prairieserver.prairie.common.requests.RequestRowCopy +import org.prairieserver.prairie.common.requests.RequestStageTrack +import org.prairieserver.prairie.model.catalog.ExternalRatings +import org.prairieserver.prairie.model.request.AdminRequestAction +import org.prairieserver.prairie.model.request.RequestDisplayState import org.prairieserver.prairie.model.request.RequestMediaDetail -import org.prairieserver.prairie.model.request.RequestState -import org.prairieserver.prairie.model.request.reasonMessage +import org.prairieserver.prairie.model.request.RequestMediaType +import org.prairieserver.prairie.model.request.RequestProgress +import org.prairieserver.prairie.model.request.RequestStep +import org.prairieserver.prairie.model.request.RequestTargetSummary import org.prairieserver.prairie.model.request.requestBackdropUrl -import org.prairieserver.prairie.model.request.requestDisplayLabel import org.prairieserver.prairie.model.request.requestPosterUrl +import org.prairieserver.prairie.repository.RequestsRepository +import org.prairieserver.prairie.tv.ui.components.PillKind +import org.prairieserver.prairie.tv.ui.components.SquaredPillSurface import org.prairieserver.prairie.tv.ui.components.TvErrorScreen +import org.prairieserver.prairie.tv.ui.components.TvHeroFactToken import org.prairieserver.prairie.tv.ui.components.TvLoadingScreen +import org.prairieserver.prairie.tv.ui.components.rememberAmbientBackdropTintState import org.prairieserver.prairie.tv.ui.focus.TvContentInitialFocusMaxAttempts import org.prairieserver.prairie.tv.ui.focus.TvObservedFocusResult import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved -import org.prairieserver.prairie.tv.ui.theme.RowDimens -import org.prairieserver.prairie.tv.ui.theme.PrairieBlue -import org.prairieserver.prairie.tv.ui.theme.cardScaled -import org.prairieserver.prairie.tv.ui.theme.sectionEyebrow +import org.prairieserver.prairie.tv.ui.screens.detail.TvDetailHero +import org.prairieserver.prairie.tv.ui.screens.detail.TvDetailHorizontalInset +import org.prairieserver.prairie.tv.ui.screens.detail.TvDetailSectionHeader +import org.prairieserver.prairie.tv.ui.screens.detail.tvDetailPageSurfaceColor +import org.prairieserver.prairie.tv.ui.theme.DarkSurfaceElevated +import org.prairieserver.prairie.tv.ui.theme.PrairieOnSurface +import org.prairieserver.prairie.viewmodel.RequestDetailUiState import org.prairieserver.prairie.viewmodel.RequestDetailViewModel -import org.koin.compose.viewmodel.koinViewModel -import org.koin.core.parameter.parametersOf +import org.prairieserver.prairie.viewmodel.RequestPrimaryAction /** - * TV request detail — the 10-foot counterpart to the phone RequestDetailScreen. - * Reuses the shared [RequestDetailViewModel] (load + submitRequest) keyed by - * (mediaType, tmdbId). Shows title/metadata/genres/overview and one primary - * action: Request when the title is requestable, otherwise the request status. + * TV request detail on the same page surface and hero as a library title + * (`TvDetailHero`), like tvOS `RequestDetailView`: a STATUS / REQUESTED / + * QUALITY summary with a labeled stage track, one primary action that morphs + * in place, admin decisions, Cancel, and a "More like this" row. + * + * Focus: the primary action is one stable node whose label and look follow + * [RequestPrimaryAction], so focus stays put across request → requesting → + * pending. */ -@OptIn(ExperimentalTvMaterial3Api::class) @Composable fun TvRequestDetailScreen( mediaType: String, tmdbId: Int, onBack: () -> Unit, + onOpenLibraryItem: (contentId: String) -> Unit = {}, + onOpenRequestDetail: (mediaType: String, tmdbId: Int) -> Unit = { _, _ -> }, /** * False while the shell has an overlay (a cascade panel or the profile * menu) that Back should close first. This handler registers after the @@ -82,22 +112,25 @@ fun TvRequestDetailScreen( backEnabled: Boolean = true, onInitialContentFocus: () -> Unit = {}, viewModel: RequestDetailViewModel = koinViewModel { parametersOf(mediaType, tmdbId) }, + repository: RequestsRepository = koinInject(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val router = rememberRequestRouter(repository, onOpenLibraryItem, onOpenRequestDetail) val loadingFocusRequester = remember { FocusRequester() } val primaryActionFocusRequester = remember { FocusRequester() } var pageHasFocus by remember { mutableStateOf(false) } var pageHadFocus by remember { mutableStateOf(false) } - val showsLoading = state.isLoading && state.detail == null - // The title, not the loaded detail: the refresh after a submit replaces the + var primaryHasFocus by remember { mutableStateOf(false) } + var confirmingDecline by remember { mutableStateOf(false) } + val showsLoading = state.detail == null && state.error == null + // The title, not the loaded detail: refreshes after an action replace the // detail and must not re-run the claim below. val detailKey = state.detail?.let { "${it.mediaType}:${it.tmdbId}" } val detailArrived by rememberUpdatedState(detailKey != null) // Nothing here is focusable until the detail loads, and the row that opened - // the page is about to be disposed, so Compose would re-home focus onto the - // top bar's Search button. Hold it on the loading indicator instead: focus - // stays in the page, and a move to the bar while this loads is the viewer's. + // the page is about to be disposed. Hold focus on the loading indicator so + // it stays in the page. LaunchedEffect(showsLoading) { if (!showsLoading) return@LaunchedEffect requestFocusUntilObserved( @@ -108,11 +141,10 @@ fun TvRequestDetailScreen( ) } - // Hand focus to the primary action when the detail arrives, but only if it - // is still in the page (or never got here). A viewer who went up to the bar - // or into the profile menu while this loaded keeps their place. Read in the - // composition that swaps the indicator for the content, so it is the focus - // from before the swap. + // Hand focus to the primary action when the detail arrives, but only if + // focus is still in the page (or never got here). Observed on the action + // itself: the synopsis above it is focusable too, and focus entering the + // page first lands there. val claimOnArrival = remember(detailKey) { pageHasFocus || !pageHadFocus } LaunchedEffect(detailKey) { if (detailKey == null || !claimOnArrival) return@LaunchedEffect @@ -120,44 +152,77 @@ fun TvRequestDetailScreen( maxAttempts = TvContentInitialFocusMaxAttempts, awaitAttempt = { withFrameNanos { } }, requestFocus = primaryActionFocusRequester::requestFocus, - isFocused = { pageHasFocus }, + isFocused = { primaryHasFocus }, ) if (result == TvObservedFocusResult.Focused) onInitialContentFocus() } BackHandler(enabled = backEnabled) { onBack() } - Column( + if (confirmingDecline) { + AlertDialog( + onDismissRequest = { confirmingDecline = false }, + containerColor = DarkSurfaceElevated, + titleContentColor = PrairieOnSurface, + textContentColor = PrairieOnSurface.copy(alpha = 0.76f), + title = { Text("Decline this request?", color = PrairieOnSurface) }, + text = { Text("The person who asked for it will see it as declined.", color = PrairieOnSurface.copy(alpha = 0.76f)) }, + confirmButton = { + // Keep first, so a stray press doesn't decide on someone's request. + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button(onClick = { confirmingDecline = false }) { Text("Keep") } + Button(onClick = { + confirmingDecline = false + viewModel.moderate(AdminRequestAction.Decline) + }) { Text("Decline") } + } + }, + ) + } + + val backdropUrl = state.detail?.let { requestBackdropUrl(it.backdropPath) ?: requestPosterUrl(it.posterPath) } + val pageTint = rememberAmbientBackdropTintState() + LaunchedEffect(backdropUrl) { pageTint.set(item = null, url = backdropUrl) } + + Box( modifier = Modifier .fillMaxSize() .onFocusChanged { pageHasFocus = it.hasFocus if (it.hasFocus) pageHadFocus = true } - .background(MaterialTheme.colorScheme.background), + .background(tvDetailPageSurfaceColor(pageTint.accent)), ) { + val detail = state.detail when { - showsLoading -> RequestDetailLoading(focusRequester = loadingFocusRequester) - state.error != null && state.detail == null -> TvErrorScreen( - message = state.error ?: "Failed to load this title.", - onRetry = viewModel::load, - ) - state.detail != null -> RequestDetailContent( - detail = state.detail!!, - isSubmitting = state.isSubmitting, - notice = state.notice, - error = state.error, - onRequest = viewModel::submitRequest, + detail != null -> RequestDetailContent( + detail = detail, + state = state, + backdropUrl = backdropUrl, primaryActionFocusRequester = primaryActionFocusRequester, + onPrimaryFocusChanged = { primaryHasFocus = it }, + onPrimary = { + when (val action = state.primaryAction) { + RequestPrimaryAction.Request -> viewModel.submitRequest() + is RequestPrimaryAction.OpenInLibrary -> onOpenLibraryItem(action.contentId) + else -> Unit + } + }, + onModerate = { action -> + if (action == AdminRequestAction.Decline) confirmingDecline = true else viewModel.moderate(action) + }, + onCancel = viewModel::cancel, + onOpenRecommendation = router::openResult, ) + state.error != null -> TvErrorScreen(message = state.error.orEmpty(), onRetry = viewModel::load) + else -> RequestDetailLoading(focusRequester = loadingFocusRequester) } } } /** * The loading indicator plus a spinner-sized focus target over it, so focus - * can wait inside the page. Kept small and centred: D-pad Up from it has to - * find the top bar by geometry, which a full-screen target would not. + * can wait inside the page. */ @Composable private fun RequestDetailLoading(focusRequester: FocusRequester) { @@ -172,194 +237,240 @@ private fun RequestDetailLoading(focusRequester: FocusRequester) { } } -/** - * Content starts below the shell's top navigation, which is a `TopStart` - * overlay drawn over every screen rather than something that reserves space. - * Without this the title rendered *through* the nav row and the eyebrow was - * clipped off the top edge entirely. - */ -private val ShellNavInset = 96.dp - -/** TV-safe horizontal margin; the outer ~5% of a panel is not reliably visible. */ -private val SafeHorizontal = 48.dp - -/** - * The overview is a full TMDB synopsis — often several hundred words, which on - * a 10-foot display filled the screen and pushed the Request action off the - * bottom. Clamped to a readable teaser; the point of this screen is deciding - * whether to request, not reading the whole plot. - */ -private const val OverviewMaxLines = 4 - -@OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun RequestDetailContent( detail: RequestMediaDetail, - isSubmitting: Boolean, - notice: String?, - error: String?, - onRequest: () -> Unit, + state: RequestDetailUiState, + backdropUrl: String?, primaryActionFocusRequester: FocusRequester, + onPrimaryFocusChanged: (Boolean) -> Unit, + onPrimary: () -> Unit, + onModerate: (AdminRequestAction) -> Unit, + onCancel: () -> Unit, + onOpenRecommendation: (RequestMediaResult) -> Unit, ) { - Box(modifier = Modifier.fillMaxSize()) { - // Backdrop, scrimmed hard enough that body copy stays legible over the - // busiest frame. Absent artwork simply leaves the background colour. - requestBackdropUrl(detail.backdropPath)?.let { url -> - ThumbhashImage( - url = url, - thumbhash = null, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), + val progress = state.progress?.takeIf { state.showsStatus } + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 70.dp), + ) { + item(key = "hero", contentType = "detail-hero") { + TvDetailHero( + title = detail.title, + seriesTitle = null, + logoUrl = null, + backdropUrl = backdropUrl, + backdropThumbhash = null, + sourceTokens = detail.genres.take(3), + ratingChip = detail.contentRating.takeIf { it.isNotBlank() }, + overview = detail.overview.takeIf { it.isNotBlank() }, + tagline = detail.tagline.takeIf { it.isNotBlank() }, + factsLine = detail.factTokens(), + directorText = detail.creditText(), + extraHeight = if (progress != null) StatusStripHeight else 0.dp, + playbackSummary = progress?.let { { RequestStatusStrip(progress = it, state = state) } }, + actions = { + Row( + horizontalArrangement = Arrangement.spacedBy(9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RequestPrimaryActionPill( + state = state, + focusRequester = primaryActionFocusRequester, + onClick = onPrimary, + modifier = Modifier.onFocusChanged { onPrimaryFocusChanged(it.hasFocus) }, + ) + state.moderationActions.forEach { action -> + val (icon, title) = when (action) { + AdminRequestAction.Approve -> Icons.Filled.Check to "Approve" + AdminRequestAction.Decline -> Icons.Filled.Close to "Decline" + AdminRequestAction.Retry -> Icons.Filled.Refresh to "Retry" + } + RequestActionPill(icon = icon, title = title, onClick = { onModerate(action) }) + } + if (state.canCancel) { + RequestActionPill(icon = Icons.Filled.Close, title = "Cancel Request", onClick = onCancel) + } + state.actionErrorMessage?.let { message -> + Text( + text = message, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 14.sp), + color = PrairieOnSurface.copy(alpha = 0.72f), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + }, ) } - Box( - modifier = Modifier - .fillMaxSize() - .background( - Brush.horizontalGradient( - 0f to MaterialTheme.colorScheme.background, - 0.55f to MaterialTheme.colorScheme.background.copy(alpha = 0.94f), - 1f to Color.Transparent, - ), - ), - ) - Box( - modifier = Modifier - .fillMaxSize() - .background( - Brush.verticalGradient( - 0f to MaterialTheme.colorScheme.background.copy(alpha = 0.86f), - 0.4f to Color.Transparent, - 1f to MaterialTheme.colorScheme.background.copy(alpha = 0.92f), - ), - ), - ) + val recommendations = state.recommendations + if (recommendations.isNotEmpty()) { + item(key = "more-like-this", contentType = "rail") { + Column(verticalArrangement = Arrangement.spacedBy(20.dp)) { + TvDetailSectionHeader(title = "More like this", modifier = Modifier.padding(horizontal = TvDetailHorizontalInset)) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(20.dp), + contentPadding = PaddingValues(horizontal = TvDetailHorizontalInset, vertical = 8.dp), + ) { + items(recommendations, key = { "${it.mediaType}:${it.tmdbId}" }) { result -> + TvRequestCard(result = result, onClick = { onOpenRecommendation(result) }) + } + } + } + } + } + } +} - Row( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding( - PaddingValues( - start = SafeHorizontal, - end = SafeHorizontal, - top = ShellNavInset, - bottom = 48.dp, - ), - ), - horizontalArrangement = Arrangement.spacedBy(32.dp), - ) { - requestPosterUrl(detail.posterPath)?.let { url -> - ThumbhashImage( - url = url, - thumbhash = null, - contentDescription = detail.title, - contentScale = ContentScale.Crop, - modifier = Modifier - .size(RowDimens.PosterWidth.cardScaled(), RowDimens.PosterHeight.cardScaled()) - .clip(RoundedCornerShape(10.dp)), - ) +/** + * The page's one primary action: a white Request / Open in Library pill, or + * the request's status with its dot. Never disabled and never swapped for + * another node, so a submit can't drop focus mid-morph; presses on a status + * do nothing. + */ +@Composable +private fun RequestPrimaryActionPill( + state: RequestDetailUiState, + focusRequester: FocusRequester, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val action = state.primaryAction + SquaredPillSurface( + kind = PillKind.Primary, + onClick = { if (action.isInteractive) onClick() }, + modifier = modifier.height(38.dp), + focusRequester = focusRequester, + capsule = true, + stableHero = true, + contentPadding = PaddingValues(horizontal = 20.dp), + ) { foreground -> + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) { + when (action) { + RequestPrimaryAction.Request -> Icon(Icons.Filled.Add, contentDescription = null, tint = foreground, modifier = Modifier.size(18.dp)) + RequestPrimaryAction.Submitting -> CircularProgressIndicator(color = foreground, strokeWidth = 2.dp, modifier = Modifier.size(14.dp)) + is RequestPrimaryAction.OpenInLibrary -> + Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, tint = foreground, modifier = Modifier.size(16.dp)) + is RequestPrimaryAction.Status -> + Box(modifier = Modifier.size(9.dp).background(action.state.tint.tvColor(), CircleShape)) + RequestPrimaryAction.Loading -> Unit } + Text( + text = state.primaryActionTitle, + style = MaterialTheme.typography.titleLarge.copy(fontSize = 14.sp), + color = foreground, + maxLines = 1, + ) + } + } +} - Column( - // Held to the scrimmed side so the backdrop stays visible on the - // right rather than sitting behind the text. - modifier = Modifier.widthIn(max = 900.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = "REQUEST", - style = sectionEyebrow, - color = PrairieBlue.copy(alpha = 0.92f), - ) - Text( - text = detail.title, - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onBackground, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) +/** + * A secondary action in the hero row (Approve, Decline, Retry, Cancel), in the + * row's 38dp capsule so every control in it shares one height. + */ +@Composable +private fun RequestActionPill(icon: ImageVector, title: String, onClick: () -> Unit) { + SquaredPillSurface( + kind = PillKind.Secondary, + onClick = onClick, + modifier = Modifier.height(38.dp), + focusRequester = null, + capsule = true, + stableHero = true, + contentPadding = PaddingValues(horizontal = 18.dp), + ) { foreground -> + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(icon, contentDescription = null, tint = foreground, modifier = Modifier.size(16.dp)) + Text( + text = title, + style = MaterialTheme.typography.titleLarge.copy(fontSize = 14.sp), + color = foreground, + maxLines = 1, + ) + } + } +} - val meta = buildList { - detail.year?.takeIf { it > 0 }?.let { add(it.toString()) } - detail.runtime?.takeIf { it > 0 }?.let { add("${it} min") } - detail.contentRating.takeIf { it.isNotBlank() }?.let { add(it) } - detail.voteAverage?.takeIf { it > 0 }?.let { add("★ ${"%.1f".format(it)}") } - detail.genres.take(3).takeIf { it.isNotEmpty() }?.let { add(it.joinToString(" · ")) } - }.joinToString(" · ") - if (meta.isNotBlank()) { - Text( - text = meta, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } +/** Extra hero height for the status strip and labeled track. */ +private val StatusStripHeight = 64.dp - if (detail.tagline.isNotBlank()) { - Text( - text = detail.tagline, - style = MaterialTheme.typography.titleMedium.copy(fontStyle = FontStyle.Italic), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.86f), - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) +/** + * The labeled summary under the synopsis, in the playback summary's grammar, + * plus the stage track with its step names. + */ +@Composable +private fun RequestStatusStrip(progress: RequestProgress, state: RequestDetailUiState) { + val record = state.displayedRecord + Column(modifier = Modifier.padding(top = 7.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + SummaryField(label = "STATUS", value = progress.longLabel, dot = progress.tint.tvColor()) + if (record != null) { + RequestRowCopy.day(record.createdAt)?.let { SummaryField(label = "REQUESTED", value = it) } + (RequestTargetSummary.text(record.targets) ?: RequestTargetSummary.qualities(record.targets))?.let { + SummaryField(label = "QUALITY", value = it) } - - if (detail.overview.isNotBlank()) { + } + } + Column(modifier = Modifier.width(420.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { + RequestStageTrack(progress = progress, height = 3.dp) + Row { + RequestStep.entries.forEach { step -> + val color = when { + step == progress.currentStep -> progress.tint.tvColor() + step.ordinal < progress.completedSteps -> PrairieOnSurface + else -> PrairieOnSurface.copy(alpha = 0.45f) + } Text( - text = detail.overview, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.92f), - maxLines = OverviewMaxLines, - overflow = TextOverflow.Ellipsis, + text = step.title, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 14.sp, fontWeight = FontWeight.SemiBold), + color = color, + maxLines = 1, + modifier = Modifier.weight(1f), ) } - - // One pill in every state, never swapped for another node. The - // status used to be plain text, which left the page with nothing - // to focus, and replacing Request with it after a submit dropped - // the focused node the same way. Only Request is enabled; the - // status or reason renders disabled so it reads as not - // actionable. A disabled TV Surface still takes focus (its - // clickable is focusable regardless of enabled), so the page - // keeps a focus target in every state, like tvOS - // RequestDetailView's single primary action. - val request = detail.request - TvRequestActionPill( - label = request.primaryActionLabel(isSubmitting), - icon = Icons.Filled.Add.takeIf { request.requestable }, - onClick = onRequest, - enabled = request.requestable && !isSubmitting, - modifier = Modifier - .padding(top = 12.dp) - .focusRequester(primaryActionFocusRequester), - ) - - notice?.let { - Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary) - } - error?.let { - Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error) - } } } } } -/** - * Label for the detail's primary action: Request while the title is - * requestable, otherwise the existing request's status, otherwise the reason - * it cannot be requested. Status tokens use [requestDisplayLabel]; reasons use - * the shared [reasonMessage] policy for readable sentences and unknown codes. - */ -private fun RequestState.primaryActionLabel(isSubmitting: Boolean): String { - if (requestable) return if (isSubmitting) "Requesting…" else "Request" - val status = status?.takeIf { it.isNotBlank() } - return when { - status != null -> "Request status: ${status.requestDisplayLabel()}" - else -> reasonMessage() ?: "Unavailable" +@Composable +private fun SummaryField(label: String, value: String, dot: Color? = null) { + Column(modifier = Modifier.width(150.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 14.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.55.sp), + color = Color.White.copy(alpha = 0.48f), + maxLines = 1, + ) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) { + if (dot != null) Box(modifier = Modifier.size(6.dp).background(dot, CircleShape)) + Text( + text = value, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 14.sp, fontWeight = FontWeight.SemiBold), + color = Color.White.copy(alpha = 0.9f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } + +/** `2026 · 2h 25m · TMDB 7.9`, with genres after it as source tokens. */ +private fun RequestMediaDetail.factTokens(): List<TvHeroFactToken> = buildList { + year?.takeIf { it > 0 }?.let { add(TvHeroFactToken.TextToken(it.toString())) } + if (mediaType == RequestMediaType.Series) { + numberOfSeasons?.takeIf { it > 0 }?.let { add(TvHeroFactToken.TextToken(if (it == 1) "1 season" else "$it seasons")) } + } else { + runtime?.takeIf { it > 0 }?.let { add(TvHeroFactToken.TextToken(if (it >= 60) "${it / 60}h ${it % 60}m" else "${it}m")) } + } + ExternalRatings.tmdb(voteAverage)?.let { add(TvHeroFactToken.ExternalRating(it)) } +} + +private fun RequestMediaDetail.creditText(): String? = when { + director.isNotBlank() -> "Directed by $director" + creators.isNotEmpty() -> "Created by ${creators.take(2).joinToString(", ")}" + else -> null +} + diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/requests/TvRequestsPage.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/requests/TvRequestsPage.kt new file mode 100644 index 0000000000..4dd91a59a1 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/requests/TvRequestsPage.kt @@ -0,0 +1,821 @@ +package org.prairieserver.prairie.tv.ui.screens.requests + +import androidx.compose.runtime.rememberCoroutineScope +import org.prairieserver.prairie.common.requests.rememberRequestRouter +import org.prairieserver.prairie.viewmodel.RequestApprovalsUiState +import org.prairieserver.prairie.model.request.RequestDiscoverySection +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Inbox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf +import org.prairieserver.prairie.common.cards.LocalCardPresentation +import org.prairieserver.prairie.common.requests.RequestRowCopy +import org.prairieserver.prairie.common.requests.RequestStageTrack +import org.prairieserver.prairie.model.catalog.ExternalRatings +import org.prairieserver.prairie.model.feature.RequestsFeatureStore +import org.prairieserver.prairie.model.request.AdminRequestAction +import org.prairieserver.prairie.model.request.MediaRequest +import org.prairieserver.prairie.model.request.RequestAttention +import org.prairieserver.prairie.model.request.RequestDisplayState +import org.prairieserver.prairie.model.request.RequestMediaResult +import org.prairieserver.prairie.model.request.RequestProgress +import org.prairieserver.prairie.model.request.requestBackdropUrl +import org.prairieserver.prairie.model.request.requestPosterUrl +import org.prairieserver.prairie.model.section.SectionItem +import org.prairieserver.prairie.repository.RequestsRepository +import org.prairieserver.prairie.tv.ui.components.TvDialog +import org.prairieserver.prairie.tv.ui.components.TvDialogAction +import org.prairieserver.prairie.tv.ui.components.TvDialogActionStyle +import org.prairieserver.prairie.tv.ui.components.TvErrorScreen +import org.prairieserver.prairie.tv.ui.components.TvFocusMarquee +import org.prairieserver.prairie.tv.ui.components.TvHeroFactToken +import org.prairieserver.prairie.tv.ui.components.TvMarqueeContent +import org.prairieserver.prairie.tv.ui.components.TvRootHeroBackdrop +import org.prairieserver.prairie.tv.ui.components.TvSectionHeader +import org.prairieserver.prairie.tv.ui.components.LocalAmbientBackdropTint +import org.prairieserver.prairie.tv.ui.components.TvSkylineBringIntoViewSpec +import org.prairieserver.prairie.tv.ui.components.TvSkylineItemSpacing +import org.prairieserver.prairie.tv.ui.components.TvSkylineMarqueeBottomGap +import org.prairieserver.prairie.tv.ui.components.TvSkylineRowBandBottomInset +import org.prairieserver.prairie.tv.ui.components.TvSkylineRowBandMaxFraction +import org.prairieserver.prairie.tv.ui.components.TvSkylineRowCardVerticalPadding +import org.prairieserver.prairie.tv.ui.components.TvSkylineRowPreviewSpacing +import org.prairieserver.prairie.tv.ui.components.rememberAmbientBackdropTintState +import org.prairieserver.prairie.tv.ui.components.tvSkylineRowBandHeight +import org.prairieserver.prairie.tv.ui.focus.TvContentInitialFocusMaxAttempts +import org.prairieserver.prairie.tv.ui.focus.TvObservedFocusResult +import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved +import org.prairieserver.prairie.tv.ui.theme.DarkSurfaceElevated +import org.prairieserver.prairie.tv.ui.theme.PrairieOnSurface +import org.prairieserver.prairie.tv.ui.theme.Spacing +import org.prairieserver.prairie.tv.ui.theme.TvSkyline +import org.prairieserver.prairie.viewmodel.MyRequestsViewModel +import org.prairieserver.prairie.viewmodel.RequestApprovalsViewModel +import org.prairieserver.prairie.viewmodel.RequestsViewModel + +/** + * The top-bar Requests tab in the Skyline layout Home and the library tabs + * use: an ambient backdrop and a passive preview panel that follow the focused + * card, including its request status and stage track, over horizontally + * scrolling rows in the bottom band (the tvOS `TVRequestsPage`). Rows: requests + * waiting for this admin's approval, the user's own requests, failed requests + * (admins), then the discover carousels. + * + * Focus: the page claims the first card when the tab is selected, and the last + * focused card when it returns from a detail page. Up from the first row + * reaches the top bar through the shell. Loading, empty, and error states own + * focus themselves, so entry always lands somewhere. + */ +@Composable +fun TvRequestsPage( + onOpenLibraryItem: (contentId: String) -> Unit, + onOpenRequestDetail: (mediaType: String, tmdbId: Int) -> Unit, + onInitialContentFocus: () -> Unit, + onFocusHandoffFailed: () -> Unit = {}, + /** Shell token bumped when the tab is selected from the bar. */ + focusRequest: Int = 0, + hub: RequestsViewModel = koinViewModel(key = "tv-requests-hub") { parametersOf(false) }, + /** For Cancel on the user's own cards; the hub's list shows the rows. */ + mine: MyRequestsViewModel = koinViewModel(key = "tv-requests-mine") { parametersOf(false) }, + approvals: RequestApprovalsViewModel = koinViewModel(key = "tv-requests-approvals") { parametersOf(false) }, + featureStore: RequestsFeatureStore = koinInject(), + repository: RequestsRepository = koinInject(), +) { + val hubState by hub.uiState.collectAsState() + val mineState by mine.uiState.collectAsState() + val approvalsState by approvals.uiState.collectAsState() + val canModerate by featureStore.canModerate.collectAsState() + var hasLoaded by rememberSaveable { mutableStateOf(false) } + var actionTarget by remember { mutableStateOf<TvRequestItem?>(null) } + val scope = rememberCoroutineScope() + + // Rows appear together once every read for the page has answered, so the + // first row never changes under a focused card. Re-read on every return. + suspend fun load() { + val discover = scope.launch { hub.fetch() } + if (featureStore.canModerate.value) approvals.fetch() + discover.join() + hasLoaded = true + } + LaunchedEffect(Unit) { load() } + // Moderation can be confirmed after the page's first load. + LaunchedEffect(canModerate) { + if (canModerate && hasLoaded) approvals.fetch() + } + + val rows = if (hasLoaded) tvRequestRows(hubState.myRequests, hubState.sections, approvalsState, canModerate) else emptyList() + // A failed action's reason shows for a few seconds; an unconfirmed decision + // stays until a read settles it. + // The view models outlive this page's composition (a detail push and + // Back re-enter it), so remember what was already shown and replay nothing. + var transientMessage by remember { mutableStateOf<String?>(null) } + var seenFailedActions by rememberSaveable { mutableStateOf(approvalsState.failedActions) } + var seenCancelMessage by rememberSaveable { mutableStateOf(mineState.actionErrorMessage) } + LaunchedEffect(approvalsState.failedActions) { + if (approvalsState.failedActions <= seenFailedActions) return@LaunchedEffect + seenFailedActions = approvalsState.failedActions + transientMessage = approvalsState.lastActionError + delay(ActionMessageMs) + transientMessage = null + } + LaunchedEffect(mineState.actionErrorMessage) { + val message = mineState.actionErrorMessage + if (message == null || message == seenCancelMessage) return@LaunchedEffect + seenCancelMessage = message + transientMessage = message + delay(ActionMessageMs) + if (transientMessage == message) transientMessage = null + } + // An admin whose approval queue couldn't load sees why, instead of a page + // that silently lacks its approval rows. + val approvalsError = approvalsState.error?.takeIf { canModerate && hasLoaded } + ?.let { "Couldn't load requests waiting for approval" } + val actionMessage = approvalsState.actionErrorMessage ?: transientMessage ?: approvalsError + + val router = rememberRequestRouter(repository, onOpenLibraryItem, onOpenRequestDetail) + fun open(item: TvRequestItem) { + when (item) { + is TvRequestItem.Record -> router.openRecord(item.record) + is TvRequestItem.Approval -> router.openModerationRecord(item.record) + is TvRequestItem.Result -> router.openResult(item.result) + } + } + + fun actionsFor(item: TvRequestItem): List<TvRequestAction> = + item.actions(canCancel = mineState::canCancel, canAct = approvalsState::canAct) + + val target = actionTarget + val targetActions = target?.let(::actionsFor).orEmpty() + // A card's actions can lapse while its dialog is up (an action on it + // started elsewhere); the dialog closes rather than offer nothing. + LaunchedEffect(target, targetActions.isEmpty()) { + if (target != null && targetActions.isEmpty()) actionTarget = null + } + if (target != null && targetActions.isNotEmpty()) { + TvRequestActionDialog( + item = target, + actions = targetActions, + onDismiss = { actionTarget = null }, + onAction = { action, record -> + actionTarget = null + when (action) { + // The hub's list shows the row; it settles an uncertain cancel. + TvRequestAction.Cancel -> mine.cancel(record, refresh = { hub.fetch() }) + TvRequestAction.Approve -> approvals.perform(AdminRequestAction.Approve, record) + TvRequestAction.Decline -> approvals.perform(AdminRequestAction.Decline, record) + TvRequestAction.Retry -> approvals.perform(AdminRequestAction.Retry, record) + } + }, + ) + } + + Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { + if (rows.isEmpty()) { + TvRequestsPlaceholder( + hasLoaded = hasLoaded, + error = hubState.error, + focusRequest = focusRequest, + onRetry = { + hasLoaded = false + scope.launch { load() } + }, + onInitialContentFocus = onInitialContentFocus, + onFocusHandoffFailed = onFocusHandoffFailed, + ) + } else { + TvRequestsFeed( + rows = rows, + focusRequest = focusRequest, + onOpen = ::open, + // A card with nothing to offer ignores the long-press. + onLongPress = { item -> if (actionsFor(item).isNotEmpty()) actionTarget = item }, + onInitialContentFocus = onInitialContentFocus, + onFocusHandoffFailed = onFocusHandoffFailed, + ) + } + if (actionMessage != null) { + Text( + text = actionMessage, + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 16.sp), + color = PrairieOnSurface, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(start = TvSkyline.safeAreaX, bottom = 20.dp) + .background(DarkSurfaceElevated.copy(alpha = 0.92f), RoundedCornerShape(50)) + .padding(horizontal = 14.dp, vertical = 8.dp), + ) + } + } +} + +// ── Rows ───────────────────────────────────────────────────── + +internal data class TvRequestRow( + val id: String, + val title: String, + val items: List<TvRequestItem>, + val label: String? = null, +) { + /** Page-unique focus key: the same title can sit in two rows. */ + fun key(item: TvRequestItem): String = "$id|${item.id}" +} + +internal sealed interface TvRequestItem { + val id: String + + /** One of the signed-in user's requests. */ + data class Record(val record: MediaRequest) : TvRequestItem { + override val id: String get() = "request:${record.id}" + } + + /** Someone's request, shown to an admin who can decide on it. */ + data class Approval(val record: MediaRequest) : TvRequestItem { + override val id: String get() = "approval:${record.id}" + } + + data class Result(val result: RequestMediaResult) : TvRequestItem { + override val id: String get() = "tmdb:${result.mediaType}:${result.tmdbId}" + } +} + +private fun tvRequestRows( + myRequests: List<MediaRequest>, + sections: List<RequestDiscoverySection>, + approvals: RequestApprovalsUiState, + showsApprovals: Boolean, +): List<TvRequestRow> = buildList { + if (showsApprovals && approvals.awaitingApproval.isNotEmpty()) { + add(TvRequestRow("approvals", "Waiting for your approval", approvals.awaitingApproval.map { TvRequestItem.Approval(it) })) + } + val mine = myRequests.filterTvMediaRequests() + if (mine.isNotEmpty()) add(TvRequestRow("mine", "Your requests", mine.map { TvRequestItem.Record(it) })) + if (showsApprovals && approvals.failed.isNotEmpty()) { + add(TvRequestRow("failed", "Failed requests", approvals.failed.map { TvRequestItem.Approval(it) })) + } + sections.filterTvRequestSections().forEachIndexed { index, section -> + add( + TvRequestRow( + id = "discover:${section.key}", + title = section.title, + items = section.results.map { TvRequestItem.Result(it) }, + label = if (index == 0) "Discover" else null, + ), + ) + } +} + +// ── Feed ───────────────────────────────────────────────────── + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun TvRequestsFeed( + rows: List<TvRequestRow>, + focusRequest: Int, + onOpen: (TvRequestItem) -> Unit, + onLongPress: (TvRequestItem) -> Unit, + onInitialContentFocus: () -> Unit, + onFocusHandoffFailed: () -> Unit, +) { + val tintState = rememberAmbientBackdropTintState() + val bandState = rememberLazyListState() + val rowStates = remember { mutableMapOf<String, LazyListState>() } + val requesters = remember { mutableMapOf<String, FocusRequester>() } + fun requesterFor(key: String) = requesters.getOrPut(key) { FocusRequester() } + + var focusedKey by remember { mutableStateOf<String?>(null) } + /** The card holding focus right now, for confirming an entry claim landed on its target. */ + var focusedCardKey by remember { mutableStateOf<String?>(null) } + /** Last focused card, restored when the page returns from a detail page. */ + var lastFocusedKey by rememberSaveable { mutableStateOf<String?>(null) } + var appliedFocusRequest by rememberSaveable { mutableStateOf(0) } + // Set when a card opens a page. Focus moves while this page is torn down + // (an outer route takes the whole shell out of composition), and those + // moves must not replace the card the viewer actually opened. + var leaving by remember { mutableStateOf(false) } + fun openFrom(key: String, item: TvRequestItem) { + lastFocusedKey = key + leaving = true + onOpen(item) + } + var previewKey by remember { mutableStateOf<String?>(null) } + val latestRows by rememberUpdatedState(rows) + + fun locate(key: String?): Pair<Int, Int>? { + if (key == null) return null + latestRows.forEachIndexed { rowIndex, row -> + val itemIndex = row.items.indexOfFirst { row.key(it) == key } + if (itemIndex >= 0) return rowIndex to itemIndex + } + return null + } + + // Entry: the first card when the tab is selected from the bar, the last + // focused card on a return from detail. + LaunchedEffect(focusRequest) { + val fromBar = focusRequest != appliedFocusRequest + appliedFocusRequest = focusRequest + val first = latestRows.first().let { it.key(it.items.first()) } + val target = if (fromBar) first else lastFocusedKey?.takeIf { locate(it) != null } ?: first + val (rowIndex, itemIndex) = locate(target) ?: (0 to 0) + bandState.scrollToItem(rowIndex) + rowStates[latestRows[rowIndex].id]?.scrollToItem(itemIndex) + val result = requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = { requesterFor(target).requestFocus() }, + // The target card itself: focus entering the page lands on some + // other card first, and that must not count as the claim. + isFocused = { focusedCardKey == target }, + ) + if (result == TvObservedFocusResult.Focused) onInitialContentFocus() else onFocusHandoffFailed() + } + + // The band's vertical bring-into-view is off (TvSkylineBringIntoViewSpec), + // so the focused row is scrolled to the top of the band explicitly, as + // Home's feed does. + val focusedRowIndex = locate(focusedKey)?.first + LaunchedEffect(focusedRowIndex, rows.size) { + if (focusedRowIndex != null && focusedRowIndex in rows.indices) bandState.animateScrollToItem(focusedRowIndex) + } + + // The preview follows focus after a short rest, as Home's marquee does. + LaunchedEffect(focusedKey) { + val key = focusedKey ?: return@LaunchedEffect + if (previewKey != null) delay(MarqueeRestMs) + previewKey = key + } + val preview = remember(previewKey, rows) { + val key = previewKey ?: rows.first().let { it.key(it.items.first()) } + locate(key)?.let { (rowIndex, itemIndex) -> rows[rowIndex].let { row -> TvRequestPreview.of(row.items[itemIndex], row) } } + } + LaunchedEffect(preview?.content?.heroBackdropUrl) { + preview?.let { tintState.set(it.content.source, it.content.heroBackdropUrl) } + } + + CompositionLocalProvider( + LocalAmbientBackdropTint provides tintState, + LocalBringIntoViewSpec provides TvSkylineBringIntoViewSpec, + ) { + BoxWithConstraints( + modifier = Modifier.fillMaxSize(), + ) { + val presentation = LocalCardPresentation.current + val bandHeight = tvSkylineRowBandHeight(presentation).coerceAtMost(maxHeight * TvSkylineRowBandMaxFraction) + val trailingPadding = (bandHeight - TvSkylineRowBandBottomInset).coerceAtLeast(0.dp) + val previews = remember(rows) { mutableMapOf<String, TvRequestPreview>() } + + TvRootHeroBackdrop(content = preview?.content, modifier = Modifier.fillMaxSize()) + TvFocusMarquee( + content = preview?.content, + startPadding = TvSkyline.safeAreaX, + topPadding = TvSkyline.barTopInset + TvSkyline.barHeight, + bottomPadding = bandHeight + TvSkylineMarqueeBottomGap, + modifier = Modifier.fillMaxSize(), + footer = { content -> + val shown = if (content.id == preview?.content?.id) preview else previews[content.id] + shown?.let { TvRequestPreviewStatus(it) } + }, + ) + preview?.let { previews[it.content.id] = it } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(bandHeight) + .align(Alignment.BottomStart) + .clipToBounds(), + ) { + LazyColumn( + state = bandState, + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(TvSkylineRowPreviewSpacing), + contentPadding = PaddingValues(bottom = trailingPadding), + ) { + itemsIndexed(rows, key = { _, row -> row.id }) { _, row -> + val rowState = rowStates.getOrPut(row.id) { LazyListState() } + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + TvSectionHeader( + title = row.title, + eyebrow = row.label, + modifier = Modifier.padding(start = TvSkyline.safeAreaX, end = Spacing.safeArea), + ) + LazyRow( + state = rowState, + modifier = Modifier.focusRestorer(), + horizontalArrangement = Arrangement.spacedBy(TvSkylineItemSpacing), + contentPadding = PaddingValues( + start = TvSkyline.safeAreaX, + end = Spacing.safeArea, + top = TvSkylineRowCardVerticalPadding, + bottom = TvSkylineRowCardVerticalPadding, + ), + ) { + itemsIndexed(row.items, key = { _, item -> item.id }) { _, item -> + val key = row.key(item) + val cardModifier = Modifier.onFocusChanged { + if (it.hasFocus) { + focusedCardKey = key + } else if (focusedCardKey == key) { + focusedCardKey = null + } + if (it.hasFocus && !leaving) { + focusedKey = key + lastFocusedKey = key + } + } + when (item) { + is TvRequestItem.Record -> TvRequestRecordCard( + record = item.record, + onClick = { openFrom(key, item) }, + onLongClick = { onLongPress(item) }, + focusRequester = requesterFor(key), + modifier = cardModifier, + ) + is TvRequestItem.Approval -> TvRequestRecordCard( + record = item.record, + onClick = { openFrom(key, item) }, + onLongClick = { onLongPress(item) }, + focusRequester = requesterFor(key), + modifier = cardModifier, + ) + is TvRequestItem.Result -> TvRequestCard( + result = item.result, + onClick = { openFrom(key, item) }, + focusRequester = requesterFor(key), + modifier = cardModifier, + ) + } + } + } + } + } + } + } + } + } +} + +private const val MarqueeRestMs = 150L +private const val ActionMessageMs = 4_000L + +/** What the preview panel shows for the focused card. */ +internal data class TvRequestPreview( + val content: TvMarqueeContent, + val progress: RequestProgress?, + val statusText: String?, +) { + companion object { + fun of(item: TvRequestItem, row: TvRequestRow): TvRequestPreview { + val meta = mutableListOf<TvHeroFactToken>() + val title: String + val overview: String? + val posterPath: String? + val backdropPath: String? + val mediaType: String + val progress: RequestProgress? + val statusText: String? + val year: Int? + when (item) { + is TvRequestItem.Record, is TvRequestItem.Approval -> { + val record = (item as? TvRequestItem.Record)?.record ?: (item as TvRequestItem.Approval).record + title = record.title + overview = record.overview + posterPath = record.posterPath + backdropPath = record.backdropPath + mediaType = record.mediaType + year = record.year + progress = RequestProgress.of(record) + statusText = RequestRowCopy.status(record, progress) + } + is TvRequestItem.Result -> { + val result = item.result + title = result.title + overview = result.overview + posterPath = result.posterPath + backdropPath = result.backdropPath + mediaType = result.mediaType + year = result.year + // Like the detail page: a title that can't be requested has no track. + progress = RequestProgress.of(result.availability, result.request) + ?.takeUnless { it.display is RequestDisplayState.Unavailable } + statusText = progress?.longLabel + } + } + year?.takeIf { it > 0 }?.let { meta += TvHeroFactToken.TextToken(it.toString()) } + meta += TvHeroFactToken.TextToken(RequestRowCopy.mediaTypeLabel(mediaType)) + when (item) { + is TvRequestItem.Record -> RequestRowCopy.day(item.record.createdAt)?.let { meta += TvHeroFactToken.TextToken("Requested $it") } + is TvRequestItem.Approval -> RequestRowCopy.day(item.record.createdAt)?.let { meta += TvHeroFactToken.TextToken("Requested $it") } + is TvRequestItem.Result -> ExternalRatings.tmdb(item.result.voteAverage)?.let { meta += TvHeroFactToken.ExternalRating(it) } + } + val posterUrl = requestPosterUrl(posterPath) + val backdropUrl = requestBackdropUrl(backdropPath) + val content = TvMarqueeContent( + id = row.key(item), + title = title, + logoUrl = null, + badges = emptyList(), + metaParts = meta, + synopsis = overview?.takeIf { it.isNotBlank() }, + detailLine = null, + specLine = null, + backdropUrl = backdropUrl, + backdropThumbhash = null, + posterUrl = posterUrl, + posterThumbhash = null, + isEpisode = false, + // The tint samples the same artwork the backdrop shows. + source = SectionItem( + contentId = row.key(item), + type = mediaType, + title = title, + posterUrl = posterUrl, + backdropUrl = backdropUrl, + ), + ) + return TvRequestPreview(content, progress, statusText) + } + } +} + +/** The status sentence and stage track under the preview's synopsis. */ +@Composable +private fun TvRequestPreviewStatus(preview: TvRequestPreview) { + val progress = preview.progress ?: return + Row( + modifier = Modifier.padding(top = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(11.dp), + ) { + TvRequestStatusLabel( + progress = progress, + text = preview.statusText ?: progress.longLabel, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = PrairieOnSurface, + modifier = Modifier.weight(1f, fill = false), + ) + RequestStageTrack(progress = progress, height = 3.dp, modifier = Modifier.width(120.dp)) + } +} + +// ── Placeholders ───────────────────────────────────────────── + +/** + * The page before it has rows: a loading skeleton in the feed's geometry, the + * empty state, or the error. Each owns focus, so entry from the bar always + * lands in the page. + */ +@Composable +private fun TvRequestsPlaceholder( + hasLoaded: Boolean, + error: String?, + focusRequest: Int, + onRetry: () -> Unit, + onInitialContentFocus: () -> Unit, + onFocusHandoffFailed: () -> Unit, +) { + val owner = remember { FocusRequester() } + var ownerHasFocus by remember { mutableStateOf(false) } + val state = when { + !hasLoaded -> "loading" + error != null -> "error" + else -> "empty" + } + LaunchedEffect(focusRequest, state) { + // The error view owns its Retry button; hand the shell back its bar + // rather than holding the handoff open. + if (state == "error") { + onFocusHandoffFailed() + return@LaunchedEffect + } + val result = requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = owner::requestFocus, + isFocused = { ownerHasFocus }, + ) + if (result == TvObservedFocusResult.Focused) onInitialContentFocus() else onFocusHandoffFailed() + } + when (state) { + "error" -> TvErrorScreen(message = error.orEmpty(), onRetry = onRetry) + else -> { + val label = if (state == "loading") "Loading requests" else "Nothing here yet" + Box( + modifier = Modifier + .fillMaxSize() + .focusRequester(owner) + .onFocusChanged { ownerHasFocus = it.isFocused } + .semantics { contentDescription = label } + .focusable(), + ) { + if (state == "loading") TvRequestsLoadingView() else TvRequestsEmptyState() + } + } + } +} + +@Composable +private fun TvRequestsEmptyState() { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon(imageVector = Icons.Outlined.Inbox, contentDescription = null, tint = Color.White.copy(alpha = 0.62f), modifier = Modifier.size(36.dp)) + Text(text = "Nothing here yet", style = MaterialTheme.typography.headlineSmall, color = Color.White) + Text( + text = "Use Search to find a movie or series to request", + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = 0.68f), + ) + } +} + +/** + * First frame while rows load: the same preview-and-row geometry as the feed, + * so the real page replaces it in place instead of building itself from a + * black canvas. + */ +@Composable +private fun TvRequestsLoadingView() { + val presentation = LocalCardPresentation.current + val cardWidth = tvRequestCardWidth() + val bar = Color.White.copy(alpha = 0.10f) + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val bandHeight = tvSkylineRowBandHeight(presentation).coerceAtMost(maxHeight * TvSkylineRowBandMaxFraction) + Column( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(start = TvSkyline.safeAreaX, bottom = bandHeight + TvSkylineMarqueeBottomGap), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box(modifier = Modifier.size(260.dp, 36.dp).background(Color.White.copy(alpha = 0.14f), RoundedCornerShape(6.dp))) + Row(horizontalArrangement = Arrangement.spacedBy(7.dp)) { + listOf(59.dp, 41.dp, 75.dp).forEach { Box(modifier = Modifier.size(it, 11.dp).background(bar, RoundedCornerShape(3.dp))) } + } + Box(modifier = Modifier.size(360.dp, 9.dp).background(bar, RoundedCornerShape(3.dp))) + Box(modifier = Modifier.size(305.dp, 9.dp).background(bar, RoundedCornerShape(3.dp))) + Text( + text = "Loading Requests", + style = MaterialTheme.typography.bodyMedium, + color = PrairieOnSurface.copy(alpha = 0.72f), + modifier = Modifier.padding(top = 2.dp), + ) + } + Column( + modifier = Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .height(bandHeight) + .padding(start = TvSkyline.safeAreaX), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box(modifier = Modifier.size(140.dp, 18.dp).background(bar, RoundedCornerShape(3.dp))) + Row( + modifier = Modifier.padding(top = TvSkylineRowCardVerticalPadding), + horizontalArrangement = Arrangement.spacedBy(TvSkylineItemSpacing), + ) { + repeat(8) { + Box(modifier = Modifier.size(cardWidth, cardWidth * 1.5f).background(bar, RoundedCornerShape(8.dp))) + } + } + } + } +} + +// ── Long-press actions ─────────────────────────────────────── + +internal enum class TvRequestAction(val label: String) { + Cancel("Cancel Request"), + Approve("Approve"), + Decline("Decline"), + Retry("Retry"), +} + +/** The long-press actions a card offers right now; empty when it has none. */ +internal fun TvRequestItem.actions( + canCancel: (MediaRequest) -> Boolean, + canAct: (MediaRequest) -> Boolean, +): List<TvRequestAction> = when (this) { + is TvRequestItem.Record -> if (canCancel(record)) listOf(TvRequestAction.Cancel) else emptyList() + is TvRequestItem.Approval -> if (!canAct(record)) { + emptyList() + } else { + when (val display = RequestDisplayState.of(record)) { + RequestDisplayState.Pending -> listOf(TvRequestAction.Approve, TvRequestAction.Decline) + is RequestDisplayState.NeedsAttention -> + if (display.attention == RequestAttention.Failed) listOf(TvRequestAction.Retry) else emptyList() + else -> emptyList() + } + } + is TvRequestItem.Result -> emptyList() +} + +/** [actions] is non-empty and [item] is a request card; the caller decides both. */ +@Composable +private fun TvRequestActionDialog( + item: TvRequestItem, + actions: List<TvRequestAction>, + onDismiss: () -> Unit, + onAction: (TvRequestAction, MediaRequest) -> Unit, +) { + val record = when (item) { + is TvRequestItem.Record -> item.record + is TvRequestItem.Approval -> item.record + is TvRequestItem.Result -> return + } + var confirmingDecline by remember(item) { mutableStateOf(false) } + if (confirmingDecline) { + // Keep comes first and takes focus, so a stray press doesn't decide on + // someone's request. + TvDialog( + title = "Decline this request?", + message = "${record.title} will show as declined to the person who asked for it.", + onDismissRequest = onDismiss, + actions = listOf( + TvDialogAction(label = "Keep", onClick = onDismiss), + TvDialogAction( + label = "Decline", + onClick = { onAction(TvRequestAction.Decline, record) }, + style = TvDialogActionStyle.Destructive, + ), + ), + ) + return + } + // Close comes first and takes focus, so a stray press doesn't send a + // non-retryable action. + TvDialog( + title = record.title, + message = if (item is TvRequestItem.Approval) "Requested by someone on this server." else RequestProgress.of(record).longLabel, + onDismissRequest = onDismiss, + actions = listOf(TvDialogAction(label = "Close", onClick = onDismiss)) + actions.map { action -> + TvDialogAction( + label = action.label, + // Decline asks first. + onClick = { + if (action == TvRequestAction.Decline) confirmingDecline = true else onAction(action, record) + }, + style = when (action) { + TvRequestAction.Cancel, TvRequestAction.Decline -> TvDialogActionStyle.Destructive + TvRequestAction.Approve, TvRequestAction.Retry -> TvDialogActionStyle.Primary + }, + ) + }, + ) +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/requests/TvRequestsScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/requests/TvRequestsScreen.kt deleted file mode 100644 index 03e02b0e29..0000000000 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/requests/TvRequestsScreen.kt +++ /dev/null @@ -1,653 +0,0 @@ -package org.prairieserver.prairie.tv.ui.screens.requests - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.focusGroup -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.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Inbox -import androidx.compose.material.icons.filled.Movie -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Icon as M3Icon -import androidx.compose.material3.OutlinedTextField -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.runtime.withFrameNanos -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusProperties -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp -import androidx.tv.material3.Button -import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import org.koin.compose.viewmodel.koinViewModel -import org.prairieserver.prairie.model.request.CreateMediaRequest -import org.prairieserver.prairie.model.request.RequestAvailability -import org.prairieserver.prairie.model.request.RequestDiscoverySection -import org.prairieserver.prairie.model.request.RequestMediaResult -import org.prairieserver.prairie.model.request.RequestMediaType -import org.prairieserver.prairie.model.request.reasonMessage -import org.prairieserver.prairie.tv.ui.components.TvErrorScreen -import org.prairieserver.prairie.tv.ui.components.TvFilterChip -import org.prairieserver.prairie.tv.ui.components.TvLoadingScreen -import org.prairieserver.prairie.tv.ui.components.tvOutlinedTextFieldColors -import org.prairieserver.prairie.tv.ui.focus.TvContentInitialFocusMaxAttempts -import org.prairieserver.prairie.tv.ui.focus.TvObservedFocusResult -import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved -import org.prairieserver.prairie.tv.ui.screens.search.TV_SEARCH_QUERY_MAX_LENGTH -import org.prairieserver.prairie.tv.ui.shell.TvTopMenuLayout -import org.prairieserver.prairie.tv.ui.theme.DarkSurfaceElevated -import org.prairieserver.prairie.tv.ui.theme.PrairieBlue -import org.prairieserver.prairie.tv.ui.theme.PrairieOnSurface -import org.prairieserver.prairie.tv.ui.theme.Spacing -import org.prairieserver.prairie.tv.ui.theme.sectionEyebrow -import org.prairieserver.prairie.viewmodel.RequestSearchViewModel -import org.prairieserver.prairie.viewmodel.RequestsViewModel - -private val requestMediaFilters = listOf( - RequestMediaType.All to "All", - RequestMediaType.Movie to "Movies", - RequestMediaType.Series to "Series", -) - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -fun TvRequestsScreen( - onOpenLibraryItem: (contentId: String) -> Unit, - onOpenMyRequests: () -> Unit, - onOpenRequestDetail: (mediaType: String, tmdbId: Int) -> Unit = { _, _ -> }, - onInitialContentFocus: () -> Unit = {}, - viewModel: RequestsViewModel = koinViewModel(), - searchViewModel: RequestSearchViewModel = koinViewModel(), - actionViewModel: TvRequestsViewModel = koinViewModel(), -) { - val state by viewModel.uiState.collectAsState() - val searchState by searchViewModel.uiState.collectAsState() - val actionState by actionViewModel.actionState.collectAsState() - val visibleSearchResults = searchState.results.filterTvRequestResults() - val visibleDiscoverSections = state.sections.filterTvRequestSections() - val searchFieldFocusRequester = remember { FocusRequester() } - // Observed per REGION. requestFocusUntilObserved tests isFocused() before - // it requests anything, so a screen-wide flag meant the post-search claim - // was skipped outright: you are in the search field when the results land, - // the flag is already true, and focus never moves to them. - var focusedRegion by remember { mutableStateOf<TvRequestsFocusRegion?>(null) } - val setFocusedRegion: (TvRequestsFocusRegion, Boolean) -> Unit = { region, focused -> - if (focused) { - focusedRegion = region - } else if (focusedRegion == region) { - focusedRegion = null - } - } - val firstFilterChipFocusRequester = remember { FocusRequester() } - val firstResultFocusRequester = remember { FocusRequester() } - val hasSubmittedQuery = searchState.hasSubmittedQuery - val hasSearchResults = visibleSearchResults.isNotEmpty() - val firstDiscoverSectionKey = visibleDiscoverSections.firstOrNull { it.results.isNotEmpty() }?.key - val hasDiscoverResults = firstDiscoverSectionKey != null - val showDiscoverRows = !hasSubmittedQuery || (searchState.error != null && hasDiscoverResults) - val hasFocusableResult = hasSearchResults || (showDiscoverRows && hasDiscoverResults) - var initialFocusRequested by remember { mutableStateOf(false) } - var focusResultsAfterSearch by remember { mutableStateOf(false) } - var pendingRequest by remember { mutableStateOf<RequestMediaResult?>(null) } - // The highest successGeneration this screen has already refreshed for. - // rememberSaveable so a restored composition (config change, tab - // save/restore) doesn't re-run the success refresh for a create that was - // handled before the screen went away. - var consumedSuccessGeneration by rememberSaveable { mutableStateOf(0) } - - fun refreshRequests() { - actionViewModel.clearNotices() - viewModel.refresh() - } - - fun handleRequestItemClick(item: RequestMediaResult) { - if (actionState.submittingKey != null) return - when { - item.canOpenLibraryDetail() -> onOpenLibraryItem(item.libraryContentId.orEmpty()) - item.canRequest() -> pendingRequest = item - // Non-actionable (already requested / pending / unavailable): open the - // request detail so the user can see metadata + current status, instead - // of just a one-shot message (phone parity). - else -> onOpenRequestDetail(item.mediaType, item.tmdbId) - } - } - - LaunchedEffect(searchFieldFocusRequester) { - if (initialFocusRequested) return@LaunchedEffect - // Seventh false handover: onInitialContentFocus() told the shell content - // had focus regardless of whether the claim landed. - val landed = requestFocusUntilObserved( - maxAttempts = TvContentInitialFocusMaxAttempts, - awaitAttempt = { withFrameNanos { } }, - requestFocus = searchFieldFocusRequester::requestFocus, - isFocused = { focusedRegion == TvRequestsFocusRegion.Field }, - ) - if (landed == TvObservedFocusResult.Focused) onInitialContentFocus() - initialFocusRequested = true - } - - LaunchedEffect(focusResultsAfterSearch, searchState.isLoading, hasSearchResults) { - if (focusResultsAfterSearch && !searchState.isLoading) { - val target = if (hasSearchResults) { - firstResultFocusRequester - } else { - firstFilterChipFocusRequester - } - val targetRegion = if (hasSearchResults) { - TvRequestsFocusRegion.Results - } else { - TvRequestsFocusRegion.Chips - } - requestFocusUntilObserved( - maxAttempts = TvContentInitialFocusMaxAttempts, - awaitAttempt = { withFrameNanos { } }, - requestFocus = target::requestFocus, - isFocused = { focusedRegion == targetRegion }, - ) - focusResultsAfterSearch = false - } - } - - // Once a create succeeds, refresh discover/search so the just-requested card - // reflects its new status. The create itself runs in the ViewModel's scope - // (see TvRequestsViewModel) so it survives navigating away; this only tidies - // the rows when the user is still on this screen. Only UNCONSUMED - // generations fire the refresh — re-entering the screen with the ViewModel - // still holding a past success must not re-fire it; instead the stale - // "Request submitted." notice is dropped so it doesn't resurface. - LaunchedEffect(actionState.successGeneration) { - if (actionState.successGeneration > consumedSuccessGeneration) { - consumedSuccessGeneration = actionState.successGeneration - viewModel.refresh() - if (searchState.query.isNotBlank()) { - searchViewModel.search(searchState.page) - } - } else if (actionState.message != null) { - // Already-consumed success on re-entry: clear the success banner - // only (errors stay visible until acted on). - actionViewModel.consumeSuccessNotice() - } - } - - pendingRequest?.let { item -> - AlertDialog( - onDismissRequest = { pendingRequest = null }, - containerColor = DarkSurfaceElevated, - titleContentColor = PrairieOnSurface, - textContentColor = PrairieOnSurface.copy(alpha = 0.76f), - title = { Text("Request ${item.title}?") }, - text = { Text("This will submit a ${item.mediaType} request for your profile.") }, - confirmButton = { - Button( - onClick = { - pendingRequest = null - actionViewModel.submit(item.requestKey(), item.toCreateMediaRequest()) - }, - ) { - Text("Request") - } - }, - dismissButton = { - Button(onClick = { pendingRequest = null }) { - Text("Cancel") - } - }, - ) - } - - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - ) { - Column(modifier = Modifier.fillMaxSize()) { - RequestsHeader( - query = searchState.query, - mediaType = searchState.mediaType, - searchStatus = requestSearchStatusText( - query = searchState.submittedQuery, - total = visibleSearchResults.size, - isSearching = searchState.isLoading, - error = searchState.error, - ), - isRefreshing = state.isRefreshing || actionState.submittingKey != null, - searchFieldFocusRequester = searchFieldFocusRequester, - firstFilterChipFocusRequester = firstFilterChipFocusRequester, - firstResultFocusRequester = firstResultFocusRequester, - hasFocusableResult = hasFocusableResult, - onFieldFocusChanged = { setFocusedRegion(TvRequestsFocusRegion.Field, it) }, - onChipsFocusChanged = { setFocusedRegion(TvRequestsFocusRegion.Chips, it) }, - onQueryChanged = { query -> searchViewModel.onQueryChanged(query) }, - onSearch = { - focusResultsAfterSearch = searchState.query.isNotBlank() - searchViewModel.search() - }, - onMediaTypeChanged = { type -> - searchViewModel.onMediaTypeChanged(type) - if (searchState.query.isNotBlank()) { - searchViewModel.search() - } - }, - onRefresh = ::refreshRequests, - onOpenMyRequests = onOpenMyRequests, - ) - when { - state.isLoading && state.sections.isEmpty() -> TvLoadingScreen() - state.error != null && state.sections.isEmpty() && !hasSubmittedQuery -> TvErrorScreen( - message = state.error ?: "Requests are unavailable.", - onRetry = viewModel::load, - ) - visibleDiscoverSections.none { it.results.isNotEmpty() } && !hasSubmittedQuery -> RequestsEmptyState( - message = state.error ?: "Search movies and series to request them.", - ) - else -> LazyColumn( - modifier = Modifier - .fillMaxSize() - .onFocusChanged { - setFocusedRegion(TvRequestsFocusRegion.Results, it.hasFocus) - }, - verticalArrangement = Arrangement.spacedBy(24.dp), - contentPadding = PaddingValues(bottom = 56.dp), - ) { - if (actionState.error != null || searchState.error != null || state.error != null || actionState.message != null) { - item(contentType = "request-notice") { - RequestNotice( - text = actionState.error ?: searchState.error ?: state.error ?: actionState.message.orEmpty(), - isError = actionState.error != null || searchState.error != null || state.error != null, - ) - } - } - - searchEmptyMessage( - query = searchState.query, - isSearching = searchState.isLoading, - hasSubmittedQuery = hasSubmittedQuery, - hasSearchResults = hasSearchResults, - hasSearchError = searchState.error != null, - )?.let { message -> - item(key = "search-empty", contentType = "request-search-empty") { - RequestSearchEmptyItem(message = message) - } - } - - if (hasSearchResults) { - item(key = "search-results", contentType = "request-search-results") { - RequestResultRow( - title = "Search Results", - results = visibleSearchResults, - firstItemFocusRequester = firstResultFocusRequester, - firstItemCardModifier = Modifier.focusProperties { - up = firstFilterChipFocusRequester - }, - submittingKey = actionState.submittingKey, - onItemClick = ::handleRequestItemClick, - ) - } - } - - if (showDiscoverRows) { - items( - visibleDiscoverSections, - key = { it.key }, - contentType = { "request-section" }, - ) { section -> - RequestSectionRow( - section = section, - firstItemFocusRequester = firstResultFocusRequester - .takeIf { !hasSearchResults && section.key == firstDiscoverSectionKey }, - firstItemCardModifier = Modifier.focusProperties { - up = firstFilterChipFocusRequester - }, - submittingKey = actionState.submittingKey, - onItemClick = ::handleRequestItemClick, - ) - } - } - item { Spacer(modifier = Modifier.height(8.dp)) } - } - } - } - - } -} - -@Composable -private fun RequestSectionRow( - section: RequestDiscoverySection, - firstItemFocusRequester: FocusRequester?, - firstItemCardModifier: Modifier = Modifier, - submittingKey: String?, - onItemClick: (RequestMediaResult) -> Unit, -) { - RequestResultRow( - title = section.title, - results = section.results, - firstItemFocusRequester = firstItemFocusRequester, - firstItemCardModifier = firstItemCardModifier, - submittingKey = submittingKey, - onItemClick = onItemClick, - ) -} - -@Composable -private fun RequestResultRow( - title: String, - results: List<RequestMediaResult>, - firstItemFocusRequester: FocusRequester?, - firstItemCardModifier: Modifier = Modifier, - submittingKey: String?, - onItemClick: (RequestMediaResult) -> Unit, -) { - if (results.isEmpty()) return - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - text = title, - style = MaterialTheme.typography.headlineLarge, - color = Color.White, - modifier = Modifier.padding(horizontal = Spacing.safeArea), - ) - LazyRow( - modifier = Modifier.focusGroup(), - horizontalArrangement = Arrangement.spacedBy(20.dp), - contentPadding = PaddingValues( - start = Spacing.safeArea, - end = Spacing.safeArea, - top = 10.dp, - bottom = 22.dp, - ), - ) { - itemsIndexed( - results, - key = { _, item -> item.requestKey() }, - contentType = { _, _ -> "request-result" }, - ) { index, item -> - TvRequestCard( - result = if (submittingKey == item.requestKey()) { - item.copy(request = item.request.copy(status = "submitting", requestable = false)) - } else { - item - }, - onClick = { onItemClick(item) }, - focusRequester = firstItemFocusRequester.takeIf { index == 0 }, - cardModifier = if (index == 0) firstItemCardModifier else Modifier, - ) - } - } - } -} - -@Composable -private fun RequestSearchEmptyItem(message: String) { - Text( - text = message, - style = MaterialTheme.typography.bodyLarge, - color = Color.White.copy(alpha = 0.72f), - modifier = Modifier.padding(horizontal = Spacing.safeArea), - ) -} - -/** Focus regions a claim on this screen can aim at. */ -private enum class TvRequestsFocusRegion { Field, Chips, Results } - -@Composable -private fun RequestsHeader( - query: String, - mediaType: String?, - searchStatus: String?, - isRefreshing: Boolean, - searchFieldFocusRequester: FocusRequester, - firstFilterChipFocusRequester: FocusRequester, - firstResultFocusRequester: FocusRequester, - hasFocusableResult: Boolean, - onFieldFocusChanged: (Boolean) -> Unit, - onChipsFocusChanged: (Boolean) -> Unit, - onQueryChanged: (String) -> Unit, - onSearch: () -> Unit, - onMediaTypeChanged: (String?) -> Unit, - onRefresh: () -> Unit, - onOpenMyRequests: () -> Unit, -) { - Column( - modifier = Modifier.padding( - start = Spacing.safeArea, - end = Spacing.safeArea, - top = TvTopMenuLayout.contentTopInset, - bottom = Spacing.lg, - ), - verticalArrangement = Arrangement.spacedBy(Spacing.md), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "REQUESTS", - style = sectionEyebrow, - color = PrairieBlue.copy(alpha = 0.92f), - ) - if (searchStatus != null) { - RequestSearchStatusPill(text = searchStatus) - } - } - Column( - modifier = Modifier.widthIn(max = 410.dp), - verticalArrangement = Arrangement.spacedBy(Spacing.sm), - ) { - OutlinedTextField( - value = query, - onValueChange = { onQueryChanged(it.take(TV_SEARCH_QUERY_MAX_LENGTH)) }, - singleLine = true, - placeholder = { - Text( - text = "Search movies and series to request", - style = MaterialTheme.typography.bodySmall, - color = Color.White.copy(alpha = 0.56f), - ) - }, - leadingIcon = { - M3Icon( - imageVector = Icons.Filled.Movie, - contentDescription = null, - tint = Color.White.copy(alpha = 0.72f), - ) - }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Search, - ), - keyboardActions = KeyboardActions(onSearch = { onSearch() }), - textStyle = MaterialTheme.typography.bodyMedium.copy(color = Color.White), - modifier = Modifier - .fillMaxWidth() - .height(48.dp) - .onFocusChanged { onFieldFocusChanged(it.isFocused) } - .focusRequester(searchFieldFocusRequester) - .focusProperties { down = firstFilterChipFocusRequester }, - colors = tvOutlinedTextFieldColors(), - ) - LazyRow( - modifier = Modifier.onFocusChanged { onChipsFocusChanged(it.hasFocus) }, - horizontalArrangement = Arrangement.spacedBy(10.dp), - contentPadding = PaddingValues(end = Spacing.xs), - verticalAlignment = Alignment.CenterVertically, - ) { - itemsIndexed(requestMediaFilters) { index, (type, label) -> - val chipModifier = Modifier - .then( - if (index == 0) { - Modifier.focusRequester(firstFilterChipFocusRequester) - } else { - Modifier - }, - ) - .then( - if (index == 0 && hasFocusableResult) { - Modifier.focusProperties { down = firstResultFocusRequester } - } else { - Modifier - }, - ) - TvFilterChip( - text = label, - selected = mediaType == type, - onClick = { onMediaTypeChanged(type) }, - modifier = chipModifier, - ) - } - } - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Find Something New", - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onBackground, - ) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - TvRequestActionPill( - label = "My Requests", - icon = Icons.Filled.Inbox, - onClick = onOpenMyRequests, - ) - TvRequestActionPill( - label = if (isRefreshing) "Refreshing" else "Refresh", - icon = Icons.Filled.Refresh, - onClick = onRefresh, - enabled = !isRefreshing, - ) - } - } - } -} - -@Composable -private fun RequestsEmptyState(message: String) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - androidx.tv.material3.Icon( - imageVector = Icons.Filled.Movie, - contentDescription = null, - tint = Color.White.copy(alpha = 0.62f), - modifier = Modifier.height(30.dp), - ) - Text( - text = message, - style = MaterialTheme.typography.headlineSmall, - color = Color.White, - ) - } - } -} - -@Composable -private fun RequestSearchStatusPill(text: String) { - val shape = RoundedCornerShape(100.dp) - Box( - modifier = Modifier - .border(1.dp, Color.White.copy(alpha = 0.08f), shape) - .background(Color.White.copy(alpha = 0.045f), shape) - .padding(horizontal = 12.dp, vertical = 6.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = text, - style = MaterialTheme.typography.labelMedium, - color = Color.White.copy(alpha = 0.68f), - ) - } -} - -@Composable -private fun RequestNotice(text: String, isError: Boolean) { - Text( - text = text, - style = MaterialTheme.typography.bodyMedium, - color = if (isError) MaterialTheme.colorScheme.error else PrairieBlue, - modifier = Modifier.padding(horizontal = Spacing.safeArea), - ) -} - -private fun RequestMediaResult.toCreateMediaRequest(): CreateMediaRequest = CreateMediaRequest( - mediaType = mediaType, - tmdbId = tmdbId, - title = title, - year = year, - overview = overview, - posterPath = posterPath, - backdropPath = backdropPath, -) - -private fun RequestMediaResult.requestKey(): String = "$mediaType-$tmdbId" - -private fun RequestMediaResult.nonActionableMessage(): String = when { - availability == RequestAvailability.Available -> "This title is already in your library." - request.status?.isNotBlank() == true -> "$title is already ${request.status}." - else -> request.reasonMessage() ?: "This title cannot be requested right now." -} - -private fun searchEmptyMessage( - query: String, - isSearching: Boolean, - hasSubmittedQuery: Boolean, - hasSearchResults: Boolean, - hasSearchError: Boolean, -): String? = when { - !hasSubmittedQuery -> "Search movies and series to request them." - isSearching -> "Searching..." - hasSearchError -> null - hasSearchResults -> null - else -> "No matches for \"${query.trim()}\"." -} - -private fun requestSearchStatusText( - query: String, - total: Int, - isSearching: Boolean, - error: String?, -): String? = when { - query.isBlank() -> null - isSearching -> "Searching..." - error != null && total == 0 -> "Search unavailable" - total == 0 -> "No results" - total == 1 -> "1 result" - else -> "$total results" -} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/requests/TvRequestsViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/requests/TvRequestsViewModel.kt deleted file mode 100644 index eaf89e9981..0000000000 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/requests/TvRequestsViewModel.kt +++ /dev/null @@ -1,99 +0,0 @@ -package org.prairieserver.prairie.tv.ui.screens.requests - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import org.prairieserver.prairie.model.request.CreateMediaRequest -import org.prairieserver.prairie.network.ApiResult -import org.prairieserver.prairie.network.errorMessage -import org.prairieserver.prairie.repository.RequestsRepository -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -/** - * Owns request-creation for the TV Requests discover/search screen. - * - * The confirm dialog used to fire `repository.create` from the composable's - * [androidx.compose.runtime.rememberCoroutineScope], which is cancelled the - * moment the screen leaves composition — so tapping "My Requests" (or Back) - * right after confirming silently cancelled the in-flight POST and dropped the - * result state with the composition. Running it in [viewModelScope] keeps the - * create alive across the natural forward navigation to My Requests, and the - * outcome is published in [actionState] as durable state the screen observes - * instead of composition-local `remember`s. Mirrors the shared - * [org.prairieserver.prairie.viewmodel.RequestDetailViewModel.submitRequest] pattern - * the phone routes creation through. - */ -data class TvRequestsActionState( - val submittingKey: String? = null, - val message: String? = null, - val error: String? = null, - val successGeneration: Int = 0, -) - -class TvRequestsViewModel( - private val repository: RequestsRepository, -) : ViewModel() { - - private val _actionState = MutableStateFlow(TvRequestsActionState()) - val actionState: StateFlow<TvRequestsActionState> = _actionState.asStateFlow() - - /** - * Submits [request], guarding against double-submit while any create is in - * flight. [requestKey] identifies the card so the row can render its - * submitting overlay; on success [TvRequestsActionState.successGeneration] - * ticks so the screen can refresh discover/search to pick up the new status. - */ - fun submit(requestKey: String, request: CreateMediaRequest) { - if (_actionState.value.submittingKey != null) return - _actionState.update { - it.copy(submittingKey = requestKey, message = null, error = null) - } - viewModelScope.launch { - when (val result = repository.create(request)) { - is ApiResult.Success -> { - _actionState.update { - it.copy( - submittingKey = null, - message = "Request submitted.", - error = null, - successGeneration = it.successGeneration + 1, - ) - } - } - is ApiResult.Error -> { - _actionState.update { - it.copy( - submittingKey = null, - error = result.errorMessage("Failed to submit request"), - ) - } - } - is ApiResult.NetworkError -> { - _actionState.update { - it.copy( - submittingKey = null, - error = "Network error. Check your connection.", - ) - } - } - } - } - } - - /** Clears any surfaced success/error notice (e.g. on manual refresh). */ - fun clearNotices() { - _actionState.update { it.copy(message = null, error = null) } - } - - /** - * Clears only the success notice, keeping any error visible. Called when - * the screen detects an already-consumed success on re-entry so a stale - * "Request submitted." doesn't resurface. - */ - fun consumeSuccessNotice() { - _actionState.update { it.copy(message = null) } - } -} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/search/TvSearchScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/search/TvSearchScreen.kt index fe734a38ef..be69d8a60a 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/search/TvSearchScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/search/TvSearchScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.snapshotFlow +import org.prairieserver.prairie.common.requests.rememberRequestRouter import org.prairieserver.prairie.tv.ui.focus.TvReturnTarget import org.prairieserver.prairie.tv.ui.focus.TvReturnTargetSaver import org.prairieserver.prairie.tv.ui.focus.TvReturnRelocation @@ -86,7 +87,6 @@ import org.prairieserver.prairie.tv.ui.components.TvFilterChip import org.prairieserver.prairie.tv.ui.components.TvSectionHeader import org.prairieserver.prairie.tv.ui.components.tvOutlinedTextFieldColors import org.prairieserver.prairie.tv.ui.screens.requests.TvRequestCard -import org.prairieserver.prairie.tv.ui.screens.requests.canOpenLibraryDetail import org.prairieserver.prairie.tv.ui.screens.requests.filterTvRequestResults import org.prairieserver.prairie.tv.ui.shell.TvTopMenuLayout import org.prairieserver.prairie.tv.ui.theme.ElevatedSurface @@ -94,6 +94,8 @@ import org.prairieserver.prairie.tv.ui.theme.Spacing import org.prairieserver.prairie.viewmodel.RequestSearchViewModel import kotlinx.coroutines.delay import org.koin.compose.koinInject +import org.prairieserver.prairie.common.ui.OnViewerAccessChanged +import org.prairieserver.prairie.network.AccessChangeSignals import org.koin.compose.viewmodel.koinViewModel internal fun shouldFocusSearchField( @@ -116,6 +118,7 @@ fun TvSearchScreen( requestSearchViewModel: RequestSearchViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() + OnViewerAccessChanged(koinInject<AccessChangeSignals>(), viewModel.accessChanges) { viewModel.refreshForAccessChange() } val requestState by requestSearchViewModel.uiState.collectAsState() val requestsFeatureStore: RequestsFeatureStore = koinInject() val requestsEnabled by requestsFeatureStore.isEnabled.collectAsState() @@ -802,6 +805,7 @@ private fun TvRequestSearchSection( onOpenRequestDetail: (mediaType: String, tmdbId: Int) -> Unit, onOpenLibraryItem: (contentId: String) -> Unit, ) { + val router = rememberRequestRouter(org.koin.compose.koinInject(), onOpenLibraryItem, onOpenRequestDetail) if (!requestsEnabled || query.trim().length < 2 || !shouldShow) return // This section is a full-span footer item inside TvCatalogGrid, so the @@ -846,11 +850,7 @@ private fun TvRequestSearchSection( result = item, onClick = { onItemClicked(item, index) - if (item.canOpenLibraryDetail()) { - onOpenLibraryItem(item.libraryContentId.orEmpty()) - } else { - onOpenRequestDetail(item.mediaType, item.tmdbId) - } + router.openResult(item) }, // The restore target wins the slot when it is this // card: index zero can be both, and two requesters diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/search/TvSearchViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/search/TvSearchViewModel.kt index f4722c46a2..4b0e37d990 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/search/TvSearchViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/search/TvSearchViewModel.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.isActive import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -52,6 +53,8 @@ class TvSearchViewModel( private val personalDataRepository: PersonalDataRepository, private val libraryScopeStore: TvLibraryScopeStore, ) : ViewModel() { + /** Access changes this ViewModel has applied, kept while its screen is away. */ + val accessChanges = org.prairieserver.prairie.network.AccessChangeCursor() data class UiState( val query: String = "", @@ -78,6 +81,9 @@ class TvSearchViewModel( private val _uiState = MutableStateFlow(UiState()) val uiState: StateFlow<UiState> = _uiState.asStateFlow() + // Declared before init, which starts the first load and stores its job here. + private var mediaTypesJob: Job? = null + init { loadAvailableMediaTypes() } /** @@ -87,13 +93,17 @@ class TvSearchViewModel( * unavailable it falls back to All. */ private fun loadAvailableMediaTypes() { - viewModelScope.launch { + // A reload after an access change replaces an unfinished one, so the + // older library list cannot land after it. + mediaTypesJob?.cancel() + mediaTypesJob = viewModelScope.launch { val libraries = when (val result = personalDataRepository.listUserLibraries()) { is ApiResult.Success -> result.data else -> return@launch } val caps = libraries.tvMediaModeCapabilities() val showAudiobooks = libraryScopeStore.getShowAudiobooksTab() + ensureActive() // The "Audiobooks" chip sends type=audiobook, so gate it on an // actual audiobook-like library — hasAudio also covers music, which // wouldn't match the audiobook filter. @@ -184,6 +194,15 @@ class TvSearchViewModel( } } + /** + * The server reported an access change: libraries (and so the media-type + * chips) and the titles a query can match may differ under the new policy. + */ + fun refreshForAccessChange() { + loadAvailableMediaTypes() + if (_uiState.value.query.isNotBlank()) submitSearch() + } + fun submitSearch() { // Bump the generation (mirroring onQueryChanged) so a cancelled in-flight // load-more's response fails the stale-generation guard instead of diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/servers/TvServerListScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/servers/TvServerListScreen.kt index 93271b5191..7c1afde219 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/servers/TvServerListScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/servers/TvServerListScreen.kt @@ -50,6 +50,7 @@ import androidx.tv.material3.Text import org.prairieserver.prairie.discovery.DiscoveryHit import org.prairieserver.prairie.discovery.normalizeDiscoveryUrl import org.prairieserver.prairie.model.server.ServerEntry +import org.prairieserver.prairie.tv.ui.screens.settings.SettingsBackground import org.prairieserver.prairie.tv.ui.focus.rememberTvContentInitialFocus import org.prairieserver.prairie.tv.ui.components.TvDialogOption import org.prairieserver.prairie.tv.ui.components.TvOptionDialog @@ -133,7 +134,7 @@ fun TvServerListScreen( modifier = Modifier .fillMaxSize() .then(contentInitialFocus) - .background(ServerSettingsBackground) + .background(SettingsBackground) .padding(start = 44.dp, top = Spacing.safeArea, end = 44.dp, bottom = Spacing.xxxl), ) { Row(horizontalArrangement = Arrangement.spacedBy(32.dp)) { @@ -371,8 +372,6 @@ private fun DiscoveredRow( } } } - -private val ServerSettingsBackground = Color(0xFF17181A) private val ServerListMaxWidth = 620.dp private val ServerRowShape = RoundedCornerShape(8.dp) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/servers/TvServerListViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/servers/TvServerListViewModel.kt index f0f61fe9ca..6c420a47b1 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/servers/TvServerListViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/servers/TvServerListViewModel.kt @@ -13,6 +13,8 @@ import org.prairieserver.prairie.network.TokenManager import org.prairieserver.prairie.repository.AuthRepository import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job +import org.prairieserver.prairie.common.profiles.ProfileLaunchPreferences +import org.prairieserver.prairie.tv.profiles.TvActiveProfileReset import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -49,6 +51,8 @@ class TvServerListViewModel( private val tokenManager: TokenManager, private val authRepository: AuthRepository, private val lanDiscovery: LanDiscovery, + private val profileLaunchPreferences: ProfileLaunchPreferences, + private val activeProfileReset: TvActiveProfileReset, ) : ViewModel() { private val _uiState = MutableStateFlow(TvServerListUiState()) @@ -189,14 +193,9 @@ class TvServerListViewModel( // Switch the registry + token scope and probe the target server.s contract. authRepository.switchToServer(serverId) - val accessToken = tokenManager.getAccessToken() - val activeEntry = serverRegistry.activeEntry.value - val profileId = activeEntry?.profileId ?: tokenManager.getProfileId() - val destination = when { - accessToken.isNullOrBlank() -> TvServerSwitchDestination.Login - profileId.isNullOrBlank() -> TvServerSwitchDestination.ProfileSelection - else -> TvServerSwitchDestination.Home - } + // Land on the deepest screen the new server's stored credentials + // can reach — preserves the signed-in user when tokens are present. + val destination = switchDestination() _uiState.update { it.copy(pendingSwitchToId = null, switchedTo = destination) @@ -292,18 +291,39 @@ class TvServerListViewModel( // idempotent when the registry is already there). authRepository.switchToServer(promotedId) - val accessToken = tokenManager.getAccessToken() - val activeEntry = serverRegistry.activeEntry.value - val profileId = activeEntry?.profileId ?: tokenManager.getProfileId() - val destination = when { - accessToken.isNullOrBlank() -> TvServerSwitchDestination.Login - profileId.isNullOrBlank() -> TvServerSwitchDestination.ProfileSelection - else -> TvServerSwitchDestination.Home - } + // Land on the deepest screen the promoted server's stored + // credentials can reach — preserves that server's session if tokens + // are present, otherwise falls back to Login. + val destination = switchDestination() _uiState.update { it.copy(switchedTo = destination) } } } + + /** + * The deepest screen the now-active server's stored credentials reach. + * + * Profile Selection covers a server switch too (prairie-apple design §7.5): + * when it would ask at launch (Every Time, or a timed choice that has run + * out), the server's saved profile is cleared and Who's Watching shows + * instead. Otherwise switching servers would reopen a protected profile + * there without its PIN. Clearing before navigating keeps the picker from + * loading under an identity that is about to change. + */ + private suspend fun switchDestination(): TvServerSwitchDestination { + val accessToken = tokenManager.getAccessToken() + val activeEntry = serverRegistry.activeEntry.value + val profileId = activeEntry?.profileId ?: tokenManager.getProfileId() + return when { + accessToken.isNullOrBlank() -> TvServerSwitchDestination.Login + profileId.isNullOrBlank() -> TvServerSwitchDestination.ProfileSelection + profileLaunchPreferences.requiresSelectionAtLaunch() -> { + activeProfileReset.clearActiveProfile() + TvServerSwitchDestination.ProfileSelection + } + else -> TvServerSwitchDestination.Home + } + } } internal fun mergeDiscoveryLists( diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvSettingsScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvSettingsScreen.kt index 197fab1ed0..4b1f2e3943 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvSettingsScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvSettingsScreen.kt @@ -1,5 +1,6 @@ package org.prairieserver.prairie.tv.ui.screens.settings +import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.annotation.StringRes import androidx.compose.foundation.background @@ -35,13 +36,14 @@ import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.border import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Logout -import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.automirrored.filled.Undo +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.outlined.ClosedCaption +import androidx.compose.material.icons.outlined.Dns +import androidx.compose.material.icons.outlined.MonitorHeart +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.SmartDisplay import androidx.compose.material.icons.filled.ChevronRight -import androidx.compose.material.icons.filled.ClosedCaption -import androidx.compose.material.icons.filled.Dns -import androidx.compose.material.icons.filled.MonitorHeart -import androidx.compose.material.icons.filled.PlayCircle -import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -62,8 +64,10 @@ import org.prairieserver.prairie.model.settings.SeekDirection import org.prairieserver.prairie.model.settings.SeekIntervalSupport import org.prairieserver.prairie.model.settings.SeekIntervals import org.prairieserver.prairie.model.settings.SeekMedia -import org.prairieserver.prairie.tv.ui.components.TvDialogOption -import org.prairieserver.prairie.tv.ui.components.TvOptionDialog +import org.prairieserver.prairie.tv.ui.components.TvDialog +import org.prairieserver.prairie.tv.ui.components.TvDialogAction +import org.prairieserver.prairie.tv.ui.components.TvDialogActionStyle +import org.prairieserver.prairie.tv.ui.components.tvDialogSurface import org.prairieserver.prairie.tv.ui.focus.claimFocusOrReport import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved import org.prairieserver.prairie.tv.ui.focus.TvObservedFocusResult @@ -78,6 +82,7 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily @@ -100,6 +105,9 @@ import org.prairieserver.prairie.update.releaseUrlOrNull import org.prairieserver.prairie.update.statusLabel import org.prairieserver.prairie.common.settings.CardPresentationSource import org.prairieserver.prairie.common.settings.CardPresentationSupport +import org.prairieserver.prairie.common.settings.TitleArtStore +import org.prairieserver.prairie.common.settings.UseProfileSettingsCopy +import org.prairieserver.prairie.model.settings.PlaybackSettingsKeys import org.prairieserver.prairie.model.settings.CardCaption import org.prairieserver.prairie.model.settings.CardPosterSize import org.prairieserver.prairie.model.settings.CardPresentation @@ -116,6 +124,13 @@ import org.prairieserver.prairie.model.settings.SubtitlePositionPreset import org.prairieserver.prairie.model.settings.pointSize import org.prairieserver.prairie.tv.BuildConfig import org.prairieserver.prairie.tv.R +import org.prairieserver.prairie.tv.ui.screens.settings.diagnostics.TvPrivacyPolicyDialog +import org.prairieserver.prairie.common.ui.components.ProfileAvatarRef +import org.prairieserver.prairie.common.ui.components.ThumbhashImage +import org.prairieserver.prairie.common.ui.components.profileAvatarDisplayText +import org.prairieserver.prairie.common.ui.components.rememberProfileAvatarImage +import androidx.compose.ui.layout.ContentScale +import org.prairieserver.prairie.common.profiles.ProfileLaunchBehavior import org.prairieserver.prairie.tv.data.preferences.SubtitleMode import org.prairieserver.prairie.tv.ui.screens.player.TvSubtitleAppearanceOptions import org.prairieserver.prairie.tv.ui.screens.settings.diagnostics.TvDiagnosticsSettingsPane @@ -123,7 +138,9 @@ import org.prairieserver.prairie.tv.ui.screens.settings.diagnostics.TvDiagnostic import org.prairieserver.prairie.tv.ui.theme.FocusedContainer import org.prairieserver.prairie.tv.ui.theme.FocusedContent import org.prairieserver.prairie.tv.ui.theme.Spacing +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.common.watchparty.WatchPartyExperiment import org.prairieserver.prairie.viewmodel.HomeViewModel import kotlinx.coroutines.delay @@ -172,6 +189,12 @@ fun TvSettingsScreen( var detailFocusRequest by remember { mutableStateOf(0) } var showSignOutConfirm by remember { mutableStateOf(false) } + LaunchedEffect(state.playbackOverridesReset) { + val landed = state.playbackOverridesReset ?: return@LaunchedEffect + Toast.makeText(context, UseProfileSettingsCopy.notice(landed), Toast.LENGTH_SHORT).show() + viewModel.onPlaybackOverridesResetShown() + } + LaunchedEffect(Unit) { // Was four attempts judged on requestFocus() returning true — that is // acceptance, not arrival. onInitialContentFocus() hands content focus @@ -274,14 +297,18 @@ fun TvSettingsScreen( onAutoPlayNextChanged = viewModel::onAutoPlayNextChanged, onIntroSkipModeChanged = viewModel::onIntroSkipModeChanged, onAutoSkipCreditsChanged = viewModel::onAutoSkipCreditsChanged, + onHideUnwatchedEpisodeImagesChanged = viewModel::setHideUnwatchedEpisodeImages, + onHideUnwatchedEpisodeOverviewsChanged = viewModel::setHideUnwatchedEpisodeOverviews, onMatchContentFrameRateChanged = viewModel::onMatchContentFrameRateChanged, onDolbyVisionEnabledChanged = viewModel::onDolbyVisionEnabledChanged, onDvProfile7HDR10FallbackChanged = viewModel::onDvProfile7HDR10FallbackChanged, onForceHdrPassthroughChanged = viewModel::onForceHdrPassthroughChanged, + onTrueBlackBarsChanged = viewModel::onTrueBlackBarsChanged, onResumeRewindSecondsChanged = viewModel::onResumeRewindSecondsChanged, onPassOutThresholdChanged = viewModel::onPassOutThresholdChanged, onNextUpPromptSecondsChanged = viewModel::onNextUpPromptSecondsChanged, onResetPlaybackOverrides = viewModel::resetPlaybackOverrides, + onUseProfileSetting = viewModel::onUseProfileSetting, seekIntervals = viewModel.seekIntervals, onSubtitleModeChanged = viewModel::onSubtitleModeChanged, onSubtitleLanguageChanged = viewModel::onSubtitleLanguageChanged, @@ -294,6 +321,7 @@ fun TvSettingsScreen( onSubtitleFontSizeChanged = viewModel::setSubtitleFontSize, onSubtitleFontFamilyChanged = viewModel::setSubtitleFontFamily, onSubtitleFontColorChanged = viewModel::setSubtitleFontColor, + onSubtitleTextOpacityChanged = viewModel::setSubtitleTextOpacity, onSubtitleTextOutlineChanged = viewModel::setSubtitleTextOutline, onSubtitleTextOutlineColorChanged = viewModel::setSubtitleTextOutlineColor, onSubtitleBackgroundStyleChanged = viewModel::setSubtitleBackgroundStyle, @@ -304,13 +332,14 @@ fun TvSettingsScreen( onSubtitleDeviceOverrideEnabledChanged = viewModel::setSubtitleDeviceOverrideEnabled, onSubtitleMatchesDeviceChanged = viewModel::onSubtitleMatchesDeviceChanged, onShowAudiobooksTabChanged = viewModel::onShowAudiobooksTabChanged, + onProfileLaunchBehaviorChanged = viewModel::onProfileLaunchBehaviorChanged, ) if (showSignOutConfirm) { TvSettingsConfirmDialog( - title = "Sign Out", - message = "You will be returned to the login screen.", - confirmLabel = "Sign Out", + title = "Sign out?", + message = "This TV goes back to the sign-in screen. Your profiles and watch history stay on the server.", + confirmLabel = "Sign out", onConfirm = { showSignOutConfirm = false viewModel.onSignOut(context) @@ -320,44 +349,46 @@ fun TvSettingsScreen( } } +/** + * The rail categories, in tvOS `TVSettingsCategory` order, with its rail + * descriptions, pane blurbs, and outlined glyphs. `stethoscope` has no Material + * twin; MonitorHeart is the nearest "check the patient" glyph. + */ internal enum class TvSettingsCategory( val title: String, - val eyebrow: String, + val railDescription: String, val blurb: String, val icon: ImageVector, ) { General( title = "General", - eyebrow = "PREFERENCES", + railDescription = "App and navigation", blurb = "App-level options for this Android TV.", - icon = Icons.Filled.Settings, + icon = Icons.Outlined.Settings, ), Playback( title = "Playback", - eyebrow = "PREFERENCES", - blurb = "Streaming, episode, and playback behavior for this device.", - icon = Icons.Filled.PlayCircle, + railDescription = "Quality and episodes", + blurb = "Streaming quality and episode behavior for this Android TV.", + icon = Icons.Outlined.SmartDisplay, ), Subtitles( title = "Subtitles", - eyebrow = "PREFERENCES", - blurb = "Language, behavior, and subtitle appearance.", - icon = Icons.Filled.ClosedCaption, + railDescription = "Language and appearance", + blurb = "Language, behavior, and on-screen appearance.", + icon = Icons.Outlined.ClosedCaption, ), - // tvOS `TVSettingsCategory` puts Diagnostics fourth, ahead of Server, under - // its own SUPPORT eyebrow. `stethoscope` has no Material twin; MonitorHeart - // is the nearest "check the patient" glyph. Diagnostics( title = "Diagnostics", - eyebrow = "SUPPORT", + railDescription = "Reports and support", blurb = "Review and send diagnostics to this Prairie server.", - icon = Icons.Filled.MonitorHeart, + icon = Icons.Outlined.MonitorHeart, ), Server( title = "Server", - eyebrow = "CONNECTION", - blurb = "Active server, device pairing, and account tools.", - icon = Icons.Filled.Dns, + railDescription = "Connection and version", + blurb = "The Prairie server this Android TV is connected to.", + icon = Icons.Outlined.Dns, ), } @@ -408,6 +439,7 @@ private fun SettingsSplitLayout( onCategorySelected: (TvSettingsCategory) -> Unit, onEnterCategory: (TvSettingsCategory) -> Unit, onShowAudiobooksTabChanged: (Boolean) -> Unit, + onProfileLaunchBehaviorChanged: (ProfileLaunchBehavior) -> Unit, onSwitchProfile: () -> Unit, onManageServers: () -> Unit, onOpenDiagnosticsReport: (reportId: String) -> Unit, @@ -418,14 +450,18 @@ private fun SettingsSplitLayout( onAutoPlayNextChanged: (Boolean) -> Unit, onIntroSkipModeChanged: (IntroSkipMode) -> Unit, onAutoSkipCreditsChanged: (Boolean) -> Unit, + onHideUnwatchedEpisodeImagesChanged: (Boolean) -> Unit, + onHideUnwatchedEpisodeOverviewsChanged: (Boolean) -> Unit, onMatchContentFrameRateChanged: (Boolean) -> Unit, onDolbyVisionEnabledChanged: (Boolean) -> Unit, onDvProfile7HDR10FallbackChanged: (Boolean) -> Unit, onForceHdrPassthroughChanged: (Boolean) -> Unit, + onTrueBlackBarsChanged: (Boolean) -> Unit, onResumeRewindSecondsChanged: (Int) -> Unit, onPassOutThresholdChanged: (Int) -> Unit, onNextUpPromptSecondsChanged: (Int) -> Unit, onResetPlaybackOverrides: () -> Unit, + onUseProfileSetting: (String) -> Unit, seekIntervals: SeekIntervalSettingsModel? = null, onSubtitleModeChanged: (SubtitleMode) -> Unit, onSubtitleLanguageChanged: (String) -> Unit, @@ -438,6 +474,7 @@ private fun SettingsSplitLayout( onSubtitleFontSizeChanged: (SubtitleFontSizePreset) -> Unit, onSubtitleFontFamilyChanged: (String) -> Unit, onSubtitleFontColorChanged: (String) -> Unit, + onSubtitleTextOpacityChanged: (Int) -> Unit, onSubtitleTextOutlineChanged: (Boolean) -> Unit, onSubtitleTextOutlineColorChanged: (String) -> Unit, onSubtitleBackgroundStyleChanged: (SubtitleBackgroundStylePreset) -> Unit, @@ -452,8 +489,8 @@ private fun SettingsSplitLayout( modifier = Modifier .fillMaxSize() .background(SettingsBackground) - // tvOS TVSettingsView: safeAreaX 88pt + HStack spacing 64pt, with a - // 430pt rail — halved to Android dp. Full-screen surface (the shell + // tvOS TVSettingsView: safeAreaX 88pt + HStack spacing 52pt, with a + // 490pt rail — halved to Android dp. Full-screen surface (the shell // hides the top bar on this route), so only the safe-area inset. .padding( start = 44.dp, @@ -461,7 +498,7 @@ private fun SettingsSplitLayout( end = 44.dp, bottom = Spacing.xxxl, ), - horizontalArrangement = Arrangement.spacedBy(32.dp), + horizontalArrangement = Arrangement.spacedBy(26.dp), verticalAlignment = Alignment.Top, ) { SettingsRail( @@ -478,7 +515,7 @@ private fun SettingsSplitLayout( }, onSwitchProfile = onSwitchProfile, onRequestSignOut = onRequestSignOut, - modifier = Modifier.width(200.dp), + modifier = Modifier.width(245.dp), ) SettingsDetailPane( state = state, @@ -489,6 +526,7 @@ private fun SettingsSplitLayout( detailFocusRequester = detailFocusRequester, onDetailFocusChanged = onDetailFocusChanged, onShowAudiobooksTabChanged = onShowAudiobooksTabChanged, + onProfileLaunchBehaviorChanged = onProfileLaunchBehaviorChanged, onManageServers = onManageServers, onOpenDiagnosticsReport = onOpenDiagnosticsReport, onQualityPresetSelected = onQualityPresetSelected, @@ -496,14 +534,18 @@ private fun SettingsSplitLayout( onAutoPlayNextChanged = onAutoPlayNextChanged, onIntroSkipModeChanged = onIntroSkipModeChanged, onAutoSkipCreditsChanged = onAutoSkipCreditsChanged, + onHideUnwatchedEpisodeImagesChanged = onHideUnwatchedEpisodeImagesChanged, + onHideUnwatchedEpisodeOverviewsChanged = onHideUnwatchedEpisodeOverviewsChanged, onMatchContentFrameRateChanged = onMatchContentFrameRateChanged, onDolbyVisionEnabledChanged = onDolbyVisionEnabledChanged, onDvProfile7HDR10FallbackChanged = onDvProfile7HDR10FallbackChanged, onForceHdrPassthroughChanged = onForceHdrPassthroughChanged, + onTrueBlackBarsChanged = onTrueBlackBarsChanged, onResumeRewindSecondsChanged = onResumeRewindSecondsChanged, onPassOutThresholdChanged = onPassOutThresholdChanged, onNextUpPromptSecondsChanged = onNextUpPromptSecondsChanged, onResetPlaybackOverrides = onResetPlaybackOverrides, + onUseProfileSetting = onUseProfileSetting, seekIntervals = seekIntervals, onSubtitleModeChanged = onSubtitleModeChanged, onSubtitleLanguageChanged = onSubtitleLanguageChanged, @@ -516,6 +558,7 @@ private fun SettingsSplitLayout( onSubtitleFontSizeChanged = onSubtitleFontSizeChanged, onSubtitleFontFamilyChanged = onSubtitleFontFamilyChanged, onSubtitleFontColorChanged = onSubtitleFontColorChanged, + onSubtitleTextOpacityChanged = onSubtitleTextOpacityChanged, onSubtitleTextOutlineChanged = onSubtitleTextOutlineChanged, onSubtitleTextOutlineColorChanged = onSubtitleTextOutlineColorChanged, onSubtitleBackgroundStyleChanged = onSubtitleBackgroundStyleChanged, @@ -568,12 +611,13 @@ private fun SettingsRail( ) { Text( text = "Settings", - style = MaterialTheme.typography.displayMedium.copy(fontSize = 22.sp, lineHeight = 26.sp), + style = MaterialTheme.typography.displayMedium.copy(fontSize = 24.sp, lineHeight = 29.sp), + fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding(start = 10.dp, bottom = 10.dp), + modifier = Modifier.padding(start = 12.dp, bottom = 10.dp), ) SettingsAccountRow( - name = state.profileName ?: state.user?.username ?: "-", + name = state.profileName ?: state.user?.username ?: "Prairie", subtitle = accountSubtitle(state), avatar = state.profileAvatar, onClick = onSwitchProfile, @@ -605,21 +649,18 @@ private fun SettingsRail( ) Text( text = "Prairie ${clientVersionLabel(BuildConfig.DISPLAY_VERSION, BuildConfig.BUILD_NUMBER)}", - style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, - fontSize = 14.sp, - letterSpacing = 1.sp, - ), - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.72f), + style = MaterialTheme.typography.bodySmall.copy(fontSize = 14.sp, lineHeight = 18.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), modifier = Modifier.padding(start = 12.dp, top = 6.dp), ) } } /** - * tvOS `TVSettingsRailRowStyle` parity: rows rest transparent, the selected - * category keeps a soft white fill + hairline border while unfocused, and the - * focused row inverts to the white platter. + * tvOS `TVSettingsRailRowStyle` parity: a glyph beside the category title and + * its rail description. Rows rest transparent, the selected category keeps a + * soft white fill + hairline border while unfocused, and the focused row + * inverts to the white platter. */ @OptIn(ExperimentalTvMaterial3Api::class) @Composable @@ -658,7 +699,7 @@ private fun SettingsRailCategoryRow( scale = ClickableSurfaceDefaults.scale(focusedScale = 1.0f), modifier = (focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier) .fillMaxWidth() - .height(38.dp) + .height(RailRowHeight) .focusProperties { right = rightFocusRequester ?: FocusRequester.Default } @@ -669,20 +710,34 @@ private fun SettingsRailCategoryRow( .fillMaxSize() .padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(9.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { Icon( imageVector = category.icon, contentDescription = null, tint = foreground, - modifier = Modifier.size(15.dp), - ) - Text( - text = category.title, - style = MaterialTheme.typography.titleMedium.copy(fontSize = 14.sp, lineHeight = 18.sp), - color = foreground, - modifier = Modifier.weight(1f), + modifier = Modifier.size(17.dp), ) + Column(modifier = Modifier.weight(1f)) { + // One line each: the row is a fixed height, so a wrapped + // description at a large font scale would be cut off mid-line. + Text( + text = category.title, + style = MaterialTheme.typography.titleMedium.copy(fontSize = 15.sp, lineHeight = 18.sp), + fontWeight = FontWeight.Medium, + color = foreground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + // TV text keeps its 14sp floor, a little above tvOS's 16pt. + Text( + text = category.railDescription, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 14.sp, lineHeight = 17.sp), + color = foreground.copy(alpha = 0.62f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } @@ -720,7 +775,7 @@ private fun SettingsRailActionRow( scale = ClickableSurfaceDefaults.scale(focusedScale = 1.0f), modifier = Modifier .fillMaxWidth() - .height(38.dp) + .height(40.dp) .onFocusChanged { if (it.isFocused) onFocused() }, ) { Row( @@ -734,7 +789,7 @@ private fun SettingsRailActionRow( imageVector = icon, contentDescription = null, tint = foreground, - modifier = Modifier.size(15.dp), + modifier = Modifier.size(17.dp), ) Text( text = label, @@ -756,6 +811,7 @@ private fun SettingsDetailPane( detailFocusRequester: FocusRequester, onDetailFocusChanged: (Boolean) -> Unit, onShowAudiobooksTabChanged: (Boolean) -> Unit, + onProfileLaunchBehaviorChanged: (ProfileLaunchBehavior) -> Unit, onManageServers: () -> Unit, onOpenDiagnosticsReport: (reportId: String) -> Unit, /** Receives a [QualityPresets] preset id. */ @@ -764,14 +820,18 @@ private fun SettingsDetailPane( onAutoPlayNextChanged: (Boolean) -> Unit, onIntroSkipModeChanged: (IntroSkipMode) -> Unit, onAutoSkipCreditsChanged: (Boolean) -> Unit, + onHideUnwatchedEpisodeImagesChanged: (Boolean) -> Unit, + onHideUnwatchedEpisodeOverviewsChanged: (Boolean) -> Unit, onMatchContentFrameRateChanged: (Boolean) -> Unit, onDolbyVisionEnabledChanged: (Boolean) -> Unit, onDvProfile7HDR10FallbackChanged: (Boolean) -> Unit, onForceHdrPassthroughChanged: (Boolean) -> Unit, + onTrueBlackBarsChanged: (Boolean) -> Unit, onResumeRewindSecondsChanged: (Int) -> Unit, onPassOutThresholdChanged: (Int) -> Unit, onNextUpPromptSecondsChanged: (Int) -> Unit, onResetPlaybackOverrides: () -> Unit, + onUseProfileSetting: (String) -> Unit, seekIntervals: SeekIntervalSettingsModel? = null, onSubtitleModeChanged: (SubtitleMode) -> Unit, onSubtitleLanguageChanged: (String) -> Unit, @@ -784,6 +844,7 @@ private fun SettingsDetailPane( onSubtitleFontSizeChanged: (SubtitleFontSizePreset) -> Unit, onSubtitleFontFamilyChanged: (String) -> Unit, onSubtitleFontColorChanged: (String) -> Unit, + onSubtitleTextOpacityChanged: (Int) -> Unit, onSubtitleTextOutlineChanged: (Boolean) -> Unit, onSubtitleTextOutlineColorChanged: (String) -> Unit, onSubtitleBackgroundStyleChanged: (SubtitleBackgroundStylePreset) -> Unit, @@ -797,24 +858,7 @@ private fun SettingsDetailPane( ) { CompositionLocalProvider(LocalSettingsDetailFocusReporter provides onDetailFocusChanged) { Column(modifier = modifier.fillMaxSize()) { - Text( - text = selectedCategory.eyebrow, - style = SettingsMonoHeaderStyle(), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = selectedCategory.title, - style = MaterialTheme.typography.displaySmall.copy(fontSize = 20.sp, lineHeight = 24.sp), - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding(top = 5.dp), - ) - Text( - text = selectedCategory.blurb, - style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp, lineHeight = 18.sp), - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 5.dp, bottom = 14.dp), - ) + SettingsPaneHeader(selectedCategory) when (selectedCategory) { TvSettingsCategory.General -> TvGeneralSettingsPane( @@ -822,6 +866,7 @@ private fun SettingsDetailPane( homeSectionsViewModel = homeSectionsViewModel, firstFocusRequester = detailFocusRequester, onShowAudiobooksTabChanged = onShowAudiobooksTabChanged, + onProfileLaunchBehaviorChanged = onProfileLaunchBehaviorChanged, onCardPresentationChanged = onCardPresentationChanged, onCardPresentationDeviceOnlyChanged = onCardPresentationDeviceOnlyChanged, onUseProfileCardDefault = onUseProfileCardDefault, @@ -834,14 +879,18 @@ private fun SettingsDetailPane( onAutoPlayNextChanged = onAutoPlayNextChanged, onIntroSkipModeChanged = onIntroSkipModeChanged, onAutoSkipCreditsChanged = onAutoSkipCreditsChanged, + onHideUnwatchedEpisodeImagesChanged = onHideUnwatchedEpisodeImagesChanged, + onHideUnwatchedEpisodeOverviewsChanged = onHideUnwatchedEpisodeOverviewsChanged, onMatchContentFrameRateChanged = onMatchContentFrameRateChanged, onDolbyVisionEnabledChanged = onDolbyVisionEnabledChanged, onDvProfile7HDR10FallbackChanged = onDvProfile7HDR10FallbackChanged, onForceHdrPassthroughChanged = onForceHdrPassthroughChanged, + onTrueBlackBarsChanged = onTrueBlackBarsChanged, onResumeRewindSecondsChanged = onResumeRewindSecondsChanged, onPassOutThresholdChanged = onPassOutThresholdChanged, onNextUpPromptSecondsChanged = onNextUpPromptSecondsChanged, onResetPlaybackOverrides = onResetPlaybackOverrides, + onUseProfileSetting = onUseProfileSetting, seekIntervals = seekIntervals, ) TvSettingsCategory.Subtitles -> TvSubtitleSettingsPane( @@ -855,6 +904,7 @@ private fun SettingsDetailPane( onSubtitleFontSizeChanged = onSubtitleFontSizeChanged, onSubtitleFontFamilyChanged = onSubtitleFontFamilyChanged, onSubtitleFontColorChanged = onSubtitleFontColorChanged, + onSubtitleTextOpacityChanged = onSubtitleTextOpacityChanged, onSubtitleTextOutlineChanged = onSubtitleTextOutlineChanged, onSubtitleTextOutlineColorChanged = onSubtitleTextOutlineColorChanged, onSubtitleBackgroundStyleChanged = onSubtitleBackgroundStyleChanged, @@ -890,24 +940,85 @@ private fun SettingsDetailPane( } } +/** + * tvOS pane header (`TVSettingsView` detail header): the category glyph on a + * graphite tile beside the category title and its blurb. + */ +@Composable +private fun SettingsPaneHeader(category: TvSettingsCategory) { + Row( + modifier = Modifier.padding(bottom = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(9.dp)) + .background(PaneHeaderTile), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = category.icon, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(18.dp), + ) + } + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = category.title, + style = MaterialTheme.typography.displaySmall.copy(fontSize = 21.sp, lineHeight = 25.sp), + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = category.blurb, + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp, lineHeight = 18.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + @Composable private fun TvGeneralSettingsPane( state: TvSettingsViewModel.UiState, homeSectionsViewModel: HomeViewModel, firstFocusRequester: FocusRequester, onShowAudiobooksTabChanged: (Boolean) -> Unit, + onProfileLaunchBehaviorChanged: (ProfileLaunchBehavior) -> Unit, onCardPresentationChanged: (CardPresentation) -> Unit, onCardPresentationDeviceOnlyChanged: (Boolean) -> Unit, onUseProfileCardDefault: () -> Unit, ) { var activeCardPicker by remember { mutableStateOf<CardPresentationPicker?>(null) } + var showProfileLaunchPicker by remember { mutableStateOf(false) } var showHomeSectionsEditor by remember { mutableStateOf(false) } + val titleArtStore: TitleArtStore = koinInject() + // Opening General is a refresh edge for a title art choice made on another + // device. Kept out of the LazyColumn item, which re-enters composition on + // scroll. + LaunchedEffect(titleArtStore) { titleArtStore.refresh() } LazyColumn( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(bottom = Spacing.xxxl), ) { + item { + // tvOS General → PROFILE AT LAUNCH. Device-wide and never synced, + // so it reads the same whichever profile is active. + SettingsGroup(title = "Profile at Launch") { + SettingsValueRow( + label = "Profile Selection", + value = state.profileLaunchBehavior.title, + onClick = { showProfileLaunchPicker = true }, + focusRequester = firstFocusRequester, + ) + SettingsFooterText(text = state.profileLaunchBehavior.description(PROFILE_LAUNCH_DEVICE)) + } + } item { // Device-local, server/profile-specific visibility and order for // the populated rows returned by Home, matching tvOS General. @@ -916,7 +1027,6 @@ private fun TvGeneralSettingsPane( label = "Home Sections", value = "", onClick = { showHomeSectionsEditor = true }, - focusRequester = firstFocusRequester, ) SettingsFooterText( text = "Choose which Home rows are visible and edit the order in which they appear on this Android TV.", @@ -962,12 +1072,16 @@ private fun TvGeneralSettingsPane( ) } SettingsFooterText( - text = "Choices sync with other TVs signed into this profile " + - "unless \"Only This Device\" is on.", + text = "Start with Balanced, Compact, Cinema, or Artwork Only, then fine-tune size " + + "and captions. These choices sync with other TVs on this profile unless Only " + + "This Device is on.", ) } } } + item { + TvTitleArtSettingsGroup(store = titleArtStore) + } item { // tvOS TVGeneralSettingsPane TOP MENU parity: the Audiobooks tab // is opt-in (hidden by default) even when the server has an @@ -983,6 +1097,11 @@ private fun TvGeneralSettingsPane( ) } } + item { + // Settings → Experimental (D1): device-local, never synced. Turning + // Watch Party off hides every entry point and leaves any party. + TvExperimentalSettingsGroup() + } // No Library group — tvOS parity: Apple's TVSettingsView has no such // section (it is iOS-only). On TV these destinations live in the // For You dropdown (Watchlist/Favorites), the profile menu @@ -1038,6 +1157,21 @@ private fun TvGeneralSettingsPane( null -> Unit } + if (showProfileLaunchPicker) { + TvSettingsPickerSheet( + title = "Profile Selection", + options = ProfileLaunchBehavior.entries.map { + PickerOption(it.raw, it.title, detail = it.description(PROFILE_LAUNCH_DEVICE)) + }, + selectedId = state.profileLaunchBehavior.raw, + onSelect = { id -> + onProfileLaunchBehaviorChanged(ProfileLaunchBehavior.fromRaw(id)) + showProfileLaunchPicker = false + }, + onDismiss = { showProfileLaunchPicker = false }, + ) + } + if (showHomeSectionsEditor) { TvHomeSectionsEditor( onDismiss = { showHomeSectionsEditor = false }, @@ -1055,6 +1189,9 @@ private enum class CardPresentationPicker { /** Picker id for the synthetic "Custom" preset row (never on the wire). */ private const val CustomCardPresetId = "custom" +/** How Profile Selection's descriptions name this device. */ +private const val PROFILE_LAUNCH_DEVICE = "this Android TV" + @Composable private fun TvPlaybackSettingsPane( state: TvSettingsViewModel.UiState, @@ -1065,25 +1202,44 @@ private fun TvPlaybackSettingsPane( onAutoPlayNextChanged: (Boolean) -> Unit, onIntroSkipModeChanged: (IntroSkipMode) -> Unit, onAutoSkipCreditsChanged: (Boolean) -> Unit, + onHideUnwatchedEpisodeImagesChanged: (Boolean) -> Unit, + onHideUnwatchedEpisodeOverviewsChanged: (Boolean) -> Unit, onMatchContentFrameRateChanged: (Boolean) -> Unit, onDolbyVisionEnabledChanged: (Boolean) -> Unit, onDvProfile7HDR10FallbackChanged: (Boolean) -> Unit, onForceHdrPassthroughChanged: (Boolean) -> Unit, + onTrueBlackBarsChanged: (Boolean) -> Unit, onResumeRewindSecondsChanged: (Int) -> Unit, onPassOutThresholdChanged: (Int) -> Unit, onNextUpPromptSecondsChanged: (Int) -> Unit, onResetPlaybackOverrides: () -> Unit, + /** Receives the key of the control going back to the profile's value. */ + onUseProfileSetting: (String) -> Unit, seekIntervals: SeekIntervalSettingsModel? = null, ) { - val recovery = org.prairieserver.prairie.common.player.rememberPlaybackRecoverySettings(org.koin.compose.koinInject()) var activePicker by remember { mutableStateOf<PlaybackPicker?>(null) } + var confirmingReset by remember { mutableStateOf(false) } + fun overridden(key: String) = PlaybackSettingsKeys.hasDeviceOverride(state.deviceOverrides, key) + // "Use profile setting" leads every profile-layered picker and is the + // checked choice while the device has no value of its own. + fun profileLayered(key: String, options: List<PickerOption>, selectedId: String) = + Pair( + listOf(PickerOption(UseProfileSettingId, UseProfileSettingsCopy.OPTION)) + options, + if (overridden(key)) selectedId else UseProfileSettingId, + ) + // A switch's "Use Profile Setting" row leaves once used, so focus is + // handed back to the switch above it rather than lost. + val autoPlayNextFocus = remember { FocusRequester() } + val skipCreditsFocus = remember { FocusRequester() } val seekState = seekIntervals?.state?.collectAsState()?.value var seekPicker by remember { mutableStateOf<Pair<SeekMedia, SeekDirection>?>(null) } // Where focus goes when a skip-interval group drops the focused row and // has no row of its own left to take it (the row just above the groups). val seekFallbackFocus = remember { FocusRequester() } + // No "No preference" entry: on a device that means going back to the + // profile's language, which "Use profile setting" already says. val audioLanguages = remember(state.audioLanguage, state.audioLanguageSuggestions) { - LanguageOptions.options( + LanguageOptions.namedOptions( key = SettingKeys.PLAYBACK_AUDIO_LANGUAGE, currentValue = state.audioLanguage, runtimeValues = state.audioLanguageSuggestions, @@ -1103,6 +1259,14 @@ private fun TvPlaybackSettingsPane( onClick = { activePicker = PlaybackPicker.Quality }, focusRequester = firstFocusRequester, ) + SettingsValueRow( + label = "Audio Language", + value = LanguageOptions.label( + state.audioLanguage, + SettingKeys.PLAYBACK_AUDIO_LANGUAGE, + ), + onClick = { activePicker = PlaybackPicker.AudioLanguage }, + ) // tvOS TVPlaybackSettingsPane STREAMING parity: Dolby Vision // (default on; off plays the HDR10 base layer) with the // narrower Profile 7 fallback nested under it — the P7 row @@ -1129,33 +1293,27 @@ private fun TvPlaybackSettingsPane( checked = state.forceHdrPassthrough, onCheckedChange = onForceHdrPassthroughChanged, ) - SettingsFooterText( - text = "Allows HDR playback when this TV doesn't report support. " + - "This does not force the HDMI output into HDR; Android may still " + - "convert the picture to SDR. Enable only if you've confirmed your " + - "TV supports the source format. Unsupported formats may produce " + - "a black screen or incorrect colors.", - ) - } - } - item { - SettingsGroup(title = "Audio") { - SettingsValueRow( - label = "Preferred Audio Language", - value = LanguageOptions.label( - state.audioLanguage, - SettingKeys.PLAYBACK_AUDIO_LANGUAGE, - ), - onClick = { activePicker = PlaybackPicker.AudioLanguage }, - ) - SettingsInfoRow( - label = "Audio Quality", - value = "Best Compatible", + // Android TV only: tvOS composites its own letterbox, so the + // Apple pane has no counterpart (prairie-android#475). + SettingsToggleRow( + label = "True Black Bars", + checked = state.trueBlackBars, + onCheckedChange = onTrueBlackBarsChanged, ) + // tvOS footer: the chosen preset's description, then the + // caveat for Force HDR Passthrough. The True Black Bars + // sentence is Android TV's own. SettingsFooterText( - text = "Uses the preferred language when available, then English. " + - "Within that language, Prairie chooses the highest-quality track " + - "supported by this TV and its current audio output.", + text = ( + QualityPresets.presetFor(state.qualityResolution, state.maxBitrateKbps)?.description + ?: "${QualityPresets.describe(state.qualityResolution, state.maxBitrateKbps)}." + ) + " Force HDR Passthrough allows HDR playback when this TV doesn't report " + + "support. It does not force the HDMI output into HDR; Android may still convert " + + "the picture to SDR. Enable it only if you've confirmed your TV supports the " + + "source format. Unsupported formats may produce a black screen or incorrect colors." + + " True Black Bars stops Prairie from drawing the bars around the picture during " + + "playback. Try it if they look grey in HDR or Dolby Vision, and turn it off again " + + "if they get lighter or show a thin line.", ) } } @@ -1165,7 +1323,17 @@ private fun TvPlaybackSettingsPane( label = "Auto-Play Next Episode", checked = state.autoPlayNext, onCheckedChange = onAutoPlayNextChanged, + focusRequester = autoPlayNextFocus, ) + if (overridden(PlaybackSettingsKeys.AutoPlayNext)) { + UseProfileSettingRow( + setting = "Auto-Play Next Episode", + onClick = { + autoPlayNextFocus.requestFocus() + onUseProfileSetting(PlaybackSettingsKeys.AutoPlayNext) + }, + ) + } SettingsValueRow( label = "Show Next Up", value = nextUpPromptLabel(state.nextUpPromptSeconds), @@ -1175,26 +1343,65 @@ private fun TvPlaybackSettingsPane( // is a select and TV has no segmented control, so this uses the // same value row + picker sheet every other enum here does. SettingsValueRow( - label = stringResource(R.string.settings_intro_skip_title), + label = "Skip Intros", value = stringResource(introSkipModeLabel(state.introSkipMode)), onClick = { activePicker = PlaybackPicker.IntroSkipMode }, ) SettingsToggleRow( - label = "Auto-Skip Credits", + label = "Skip Credits", checked = state.autoSkipCredits, onCheckedChange = onAutoSkipCreditsChanged, + focusRequester = skipCreditsFocus, ) + if (overridden(PlaybackSettingsKeys.AutoSkipCredits)) { + UseProfileSettingRow( + setting = "Skip Credits", + onClick = { + skipCreditsFocus.requestFocus() + onUseProfileSetting(PlaybackSettingsKeys.AutoSkipCredits) + }, + ) + } SettingsValueRow( - label = "Resume Skip-Back", + label = "Rewind on Resume", value = resumeRewindLabel(state.resumeRewindSeconds), onClick = { activePicker = PlaybackPicker.ResumeRewind }, ) SettingsValueRow( - label = "Still-Watching Prompt After", + label = "Still Watching Prompt", value = passOutThresholdLabel(state.passOutThreshold), onClick = { activePicker = PlaybackPicker.PassOutThreshold }, focusRequester = seekFallbackFocus, ) + SettingsFooterText( + text = "Rewind on Resume skips back when you return to a partly watched title. Still " + + "Watching Prompt sets how many episodes play in a row before Prairie asks whether " + + "you're still watching.", + ) + } + } + if (state.episodeSpoilers.isSupported) { + item { + // Spoiler protection (see EpisodeSpoilers.MIN_CONTRACT_REVISION). Profile-wide, so + // the footer says it follows the profile to other devices. + // Hidden on servers that do not know the keys. + SettingsGroup(title = "Spoilers") { + SettingsToggleRow( + label = "Blur unwatched episode images", + checked = state.episodeSpoilers.hideImages, + onCheckedChange = onHideUnwatchedEpisodeImagesChanged, + ) + SettingsToggleRow( + label = "Hide unwatched episode descriptions", + checked = state.episodeSpoilers.hideOverviews, + onCheckedChange = onHideUnwatchedEpisodeOverviewsChanged, + ) + SettingsFooterText( + text = "Applies to episodes you have not started, on every device " + + "that uses this profile.", + ) + state.episodeSpoilerSaveError?.let { SettingsFooterText(text = it) } + } } } if (seekState != null) { @@ -1219,27 +1426,14 @@ private fun TvPlaybackSettingsPane( ) } } - if (recovery.visible) { - item { - SettingsGroup(title = "Playback recovery") { - SettingsActionRow( - label = if (recovery.busy) "Recovering playback…" else "Retry pending playback stops", - onClick = recovery.retry, - ) - SettingsFooterText(text = recovery.message) - } - } - } item { SettingsGroup(title = "Reset") { SettingsActionRow( - label = "Reset Playback Overrides", - onClick = onResetPlaybackOverrides, + label = UseProfileSettingsCopy.RESET_ALL_ROW, + onClick = { confirmingReset = true }, destructive = true, ) - SettingsFooterText( - text = "Resets playback choices for this Android TV and profile back to the server fallback.", - ) + SettingsFooterText(text = UseProfileSettingsCopy.FOOTER) } } } @@ -1248,53 +1442,94 @@ private fun TvPlaybackSettingsPane( // The picker offers presets; a stored pair no preset covers (set // through the API, or left by a legacy compound value) selects // nothing rather than silently highlighting the wrong entry. - PlaybackPicker.Quality -> TvSettingsPickerSheet( - title = "Quality", - options = QualityPresets.ALL.map { PickerOption(it.id, it.label) }, - selectedId = QualityPresets.presetFor(state.qualityResolution, state.maxBitrateKbps)?.id - ?: "", - onSelect = { id -> - onQualityPresetSelected(id) - activePicker = null - }, - onDismiss = { activePicker = null }, - ) - PlaybackPicker.AudioLanguage -> TvSettingsPickerSheet( - title = "Preferred Audio Language", - options = audioLanguages.map { PickerOption(it.first, it.second) }, - selectedId = state.audioLanguage, - onSelect = { onAudioLanguageChanged(it); activePicker = null }, - onDismiss = { activePicker = null }, - ) - PlaybackPicker.NextUpPrompt -> TvSettingsPickerSheet( - title = "Show Next Up", - options = NextUpPromptOptions.map { PickerOption(it.toString(), nextUpPromptLabel(it)) }, - selectedId = state.nextUpPromptSeconds.toString(), - onSelect = { id -> - id.toIntOrNull()?.let(onNextUpPromptSecondsChanged) - activePicker = null - }, - onDismiss = { activePicker = null }, - ) - // Three short options: a compact popup over the settings list, not the - // full-screen picker the longer lists use. - PlaybackPicker.IntroSkipMode -> TvOptionDialog( - title = stringResource(R.string.settings_intro_skip_title), - options = IntroSkipMode.entries.map { mode -> - TvDialogOption( - key = mode.wireValue, - title = stringResource(introSkipModeLabel(mode)), - selected = mode == state.introSkipMode, - onClick = { - onIntroSkipModeChanged(mode) - activePicker = null - }, - ) - }, - onDismiss = { activePicker = null }, - ) + PlaybackPicker.Quality -> { + val (options, selectedId) = profileLayered( + PlaybackSettingsKeys.PreferredQuality, + QualityPresets.ALL.map { PickerOption(it.id, it.label) }, + QualityPresets.presetFor(state.qualityResolution, state.maxBitrateKbps)?.id ?: "", + ) + TvSettingsPickerSheet( + title = "Quality", + options = options, + selectedId = selectedId, + onSelect = { id -> + if (id == UseProfileSettingId) { + onUseProfileSetting(PlaybackSettingsKeys.PreferredQuality) + } else { + onQualityPresetSelected(id) + } + activePicker = null + }, + onDismiss = { activePicker = null }, + ) + } + PlaybackPicker.AudioLanguage -> { + val (options, selectedId) = profileLayered( + PlaybackSettingsKeys.AudioLanguage, + audioLanguages.map { PickerOption(it.first, it.second) }, + state.audioLanguage, + ) + TvSettingsPickerSheet( + title = "Audio Language", + options = options, + selectedId = selectedId, + onSelect = { id -> + if (id == UseProfileSettingId) { + onUseProfileSetting(PlaybackSettingsKeys.AudioLanguage) + } else { + onAudioLanguageChanged(id) + } + activePicker = null + }, + onDismiss = { activePicker = null }, + ) + } + PlaybackPicker.NextUpPrompt -> { + val (options, selectedId) = profileLayered( + PlaybackSettingsKeys.NextUpPromptSeconds, + NextUpPromptOptions.map { PickerOption(it.toString(), nextUpPromptLabel(it)) }, + state.nextUpPromptSeconds.toString(), + ) + TvSettingsPickerSheet( + title = "Show Next Up", + options = options, + selectedId = selectedId, + onSelect = { id -> + if (id == UseProfileSettingId) { + onUseProfileSetting(PlaybackSettingsKeys.NextUpPromptSeconds) + } else { + id.toIntOrNull()?.let(onNextUpPromptSecondsChanged) + } + activePicker = null + }, + onDismiss = { activePicker = null }, + ) + } + PlaybackPicker.IntroSkipMode -> { + val (options, selectedId) = profileLayered( + PlaybackSettingsKeys.IntroSkipMode, + IntroSkipMode.entries.map { mode -> + PickerOption(mode.wireValue, stringResource(introSkipModeLabel(mode))) + }, + state.introSkipMode.wireValue, + ) + TvSettingsPickerSheet( + title = "Skip Intros", + options = options, + selectedId = selectedId, + onSelect = { id -> + if (id == UseProfileSettingId) { + onUseProfileSetting(PlaybackSettingsKeys.IntroSkipMode) + } else { + IntroSkipMode.entries.firstOrNull { it.wireValue == id }?.let(onIntroSkipModeChanged) + } + activePicker = null + }, + onDismiss = { activePicker = null }, + ) + } PlaybackPicker.ResumeRewind -> TvSettingsPickerSheet( - title = "Resume Skip-Back", + title = "Rewind on Resume", options = ResumeRewindOptions.map { PickerOption(it.toString(), resumeRewindLabel(it)) }, selectedId = state.resumeRewindSeconds.toString(), onSelect = { id -> @@ -1304,7 +1539,7 @@ private fun TvPlaybackSettingsPane( onDismiss = { activePicker = null }, ) PlaybackPicker.PassOutThreshold -> TvSettingsPickerSheet( - title = "Still-Watching Prompt After", + title = "Still Watching Prompt", options = PassOutThresholdOptions.map { PickerOption(it.toString(), passOutThresholdLabel(it)) }, selectedId = state.passOutThreshold.toString(), onSelect = { id -> @@ -1331,6 +1566,37 @@ private fun TvPlaybackSettingsPane( onDismiss = { seekPicker = null }, ) } + + if (confirmingReset) { + TvSettingsConfirmDialog( + title = UseProfileSettingsCopy.CONFIRM_TITLE, + message = UseProfileSettingsCopy.CONFIRM_MESSAGE, + confirmLabel = UseProfileSettingsCopy.CONFIRM_BUTTON, + onConfirm = { + confirmingReset = false + onResetPlaybackOverrides() + }, + onDismiss = { confirmingReset = false }, + ) + } +} + +/** Picker id for the synthetic "Use profile setting" row (never on the wire). */ +private const val UseProfileSettingId = "use_profile_setting" + +/** + * The action under a profile-layered switch while this device holds its own + * value. A switch has no picker to lead with "Use profile setting", so it gets + * a row of its own, as Cards & Posters' "Use Profile Default" does. + */ +@Composable +private fun UseProfileSettingRow(setting: String, onClick: () -> Unit) { + val description = UseProfileSettingsCopy.rowDescription(setting) + SettingsActionRow( + label = UseProfileSettingsCopy.ROW, + onClick = onClick, + modifier = Modifier.semantics { contentDescription = description }, + ) } private fun seekIntervalGroupTitle(media: SeekMedia): String = when (media) { @@ -1456,7 +1722,16 @@ private fun TvSeekIntervalGroup( ) SettingsFooterText(text = "${state.legacyAudiobookSummary}. Uploads them to this profile.") } - SettingsFooterText(text = "Applies to every device on this profile.") + SettingsFooterText( + text = when (media) { + SeekMedia.Video -> + "Used by left and right presses on the remote and the on-screen skip buttons. " + + "Applies to every device signed in to this profile." + SeekMedia.Audiobook -> + "Used by the audiobook player's skip buttons. Applies to every device signed " + + "in to this profile." + }, + ) state.saveErrorFor(media)?.let { SettingsFooterText(text = it) } } } @@ -1480,6 +1755,7 @@ private fun TvSubtitleSettingsPane( onSubtitleFontSizeChanged: (SubtitleFontSizePreset) -> Unit, onSubtitleFontFamilyChanged: (String) -> Unit, onSubtitleFontColorChanged: (String) -> Unit, + onSubtitleTextOpacityChanged: (Int) -> Unit, onSubtitleTextOutlineChanged: (Boolean) -> Unit, onSubtitleTextOutlineColorChanged: (String) -> Unit, onSubtitleBackgroundStyleChanged: (SubtitleBackgroundStylePreset) -> Unit, @@ -1521,12 +1797,6 @@ private fun TvSubtitleSettingsPane( ) { item { SettingsGroup(title = "Profile") { - SettingsValueRow( - label = "Mode", - value = state.subtitleMode.label, - onClick = { activePicker = SubtitlePicker.Mode }, - focusRequester = firstFocusRequester, - ) SettingsValueRow( label = "Language", value = LanguageOptions.label( @@ -1534,17 +1804,13 @@ private fun TvSubtitleSettingsPane( SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE, ), onClick = { activePicker = SubtitlePicker.Language }, + focusRequester = firstFocusRequester, + ) + SettingsValueRow( + label = "Behavior", + value = state.subtitleMode.label, + onClick = { activePicker = SubtitlePicker.Mode }, ) - if (metadataLanguageEnabled) { - SettingsValueRow( - label = "Metadata Language", - value = LanguageOptions.label( - state.metadataLanguage, - SettingKeys.CATALOG_METADATA_LANGUAGE, - ), - onClick = { activePicker = SubtitlePicker.MetadataLanguage }, - ) - } SettingsToggleRow( label = "Show Forced Subtitles", checked = state.showForcedSubtitles, @@ -1556,13 +1822,31 @@ private fun TvSubtitleSettingsPane( ) } } + if (metadataLanguageEnabled) { + item { + // The footer is the settings contract's own description of the key. + SettingsGroup(title = "Metadata") { + SettingsValueRow( + label = "Metadata Language", + value = LanguageOptions.label( + state.metadataLanguage, + SettingKeys.CATALOG_METADATA_LANGUAGE, + ), + onClick = { activePicker = SubtitlePicker.MetadataLanguage }, + ) + SettingsFooterText( + text = "Fallback language Prairie prefers for titles, descriptions, and artwork.", + ) + } + } + } item { SettingsGroup(title = "Appearance") { TvSettingsSubtitlePreview(state.effectiveSubtitleAppearance) SettingsToggleRow( // tvOS parity: appearance follows the OS captioning // settings while this is on. - label = "Use System Caption Style", + label = "Use Device Settings", checked = state.subtitleMatchesDevice, onCheckedChange = onSubtitleMatchesDeviceChanged, ) @@ -1590,6 +1874,14 @@ private fun TvSubtitleSettingsPane( onClick = { activePicker = SubtitlePicker.FontColor }, enabled = state.subtitleUsesDeviceOverride, ) + if (state.subtitleTextOpacitySupported) { + SettingsValueRow( + label = "Text Opacity", + value = "${appearance.textOpacity}%", + onClick = { activePicker = SubtitlePicker.TextOpacity }, + enabled = state.subtitleUsesDeviceOverride, + ) + } SettingsToggleRow( label = "Text Outline", checked = appearance.textOutline, @@ -1648,7 +1940,7 @@ private fun TvSubtitleSettingsPane( when (activePicker) { SubtitlePicker.Mode -> TvSettingsPickerSheet( - title = "Mode", + title = "Behavior", options = SubtitleMode.values().map { PickerOption(it.name, it.label) }, selectedId = state.subtitleMode.name, onSelect = { id -> @@ -1705,6 +1997,19 @@ private fun TvSubtitleSettingsPane( }, onDismiss = { activePicker = null }, ) + SubtitlePicker.TextOpacity -> TvSettingsPickerSheet( + title = "Text Opacity", + options = TvSubtitleAppearanceOptions.percentOptions( + TvSubtitleAppearanceOptions.TEXT_OPACITY_PERCENT_STEPS, + appearance.textOpacity, + ).map { PickerOption(it.toString(), "$it%") }, + selectedId = appearance.textOpacity.toString(), + onSelect = { id -> + id.toIntOrNull()?.let { onSubtitleTextOpacityChanged(it) } + activePicker = null + }, + onDismiss = { activePicker = null }, + ) SubtitlePicker.OutlineColor -> TvSettingsPickerSheet( title = "Outline Color", options = TvSubtitleAppearanceOptions.OUTLINE_COLORS.map { PickerOption(it.first, it.second) }, @@ -1729,7 +2034,10 @@ private fun TvSubtitleSettingsPane( ) SubtitlePicker.BackgroundOpacity -> TvSettingsPickerSheet( title = "Background Opacity", - options = TvSubtitleAppearanceOptions.OPACITY_PERCENT_STEPS.map { PickerOption(it.toString(), "$it%") }, + options = TvSubtitleAppearanceOptions.percentOptions( + TvSubtitleAppearanceOptions.OPACITY_PERCENT_STEPS, + appearance.backgroundOpacity, + ).map { PickerOption(it.toString(), "$it%") }, selectedId = appearance.backgroundOpacity.toString(), onSelect = { id -> id.toIntOrNull()?.let { onSubtitleBackgroundOpacityChanged(it) } @@ -1764,7 +2072,7 @@ private fun TvSubtitleSettingsPane( if (showResetConfirmation) { TvSettingsConfirmDialog( - title = "Reset Custom Appearance?", + title = "Reset custom appearance?", message = "This restores all custom subtitle appearance options to their defaults.", confirmLabel = "Reset", onConfirm = { @@ -1792,12 +2100,14 @@ private fun TvSettingsSubtitlePreview(appearance: SubtitleAppearance) { else -> FontFamily.SansSerif } val fontSize = (safe.fontSize.pointSize * 0.36).sp - val foreground = settingsHexColor(safe.fontColor) + val foreground = settingsHexColor(safe.fontColor).copy( + alpha = TvSubtitleAppearanceOptions.previewOpacityAlpha(safe.textOpacity, floor = 1), + ) val outline = settingsHexColor(safe.textOutlineColor) val showOutline = safe.textOutline || safe.backgroundStyle == SubtitleBackgroundStylePreset.Outline val boxColor = settingsHexColor(safe.backgroundColor).copy( alpha = if (safe.backgroundStyle == SubtitleBackgroundStylePreset.Box) { - safe.backgroundOpacity.coerceIn(0, 100) / 100f + TvSubtitleAppearanceOptions.previewOpacityAlpha(safe.backgroundOpacity, floor = 0) } else { 0f }, @@ -1858,6 +2168,7 @@ private fun TvServerSettingsPane( firstFocusRequester: FocusRequester, onManageServers: () -> Unit, ) { + var showPrivacyPolicy by remember { mutableStateOf(false) } LazyColumn( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(10.dp), @@ -1870,7 +2181,7 @@ private fun TvServerSettingsPane( value = state.serverName.ifBlank { "Not configured" }, ) if (state.serverUrl.isNotBlank() && state.serverName != state.serverUrl) { - SettingsInfoRow(label = "URL", value = state.serverUrl, singleLine = false) + SettingsInfoRow(label = "Address", value = state.serverUrl, singleLine = false) } SettingsActionRow( label = "Manage Servers", @@ -1887,13 +2198,22 @@ private fun TvServerSettingsPane( // Same "1.0.0 (5)" form as the phone About row, so a TV support // report names the build the server's admin Activity page shows. SettingsInfoRow( - label = "Version", + label = "App Version", value = clientVersionLabel(BuildConfig.DISPLAY_VERSION, BuildConfig.BUILD_NUMBER), ) TvAppUpdateStatusRows() + // tvOS About: the policy as a QR code, for a device with a browser. + SettingsActionRow( + label = "Privacy Policy", + onClick = { showPrivacyPolicy = true }, + ) } } } + + if (showPrivacyPolicy) { + TvPrivacyPolicyDialog(onDismiss = { showPrivacyPolicy = false }) + } } /** @@ -1964,6 +2284,7 @@ private enum class SubtitlePicker { FontSize, FontFamily, FontColor, + TextOpacity, OutlineColor, BackgroundStyle, BackgroundOpacity, @@ -1975,7 +2296,8 @@ private enum class SubtitlePicker { // Reusable picker sheet (centered modal vertical option list) // --------------------------------------------------------------------------- -data class PickerOption(val id: String, val label: String) +/** [detail] is an optional second line explaining the choice (tvOS `TVSettingsOption.detail`). */ +data class PickerOption(val id: String, val label: String, val detail: String? = null) /** * Reusable centered modal option picker. Renders a vertical list with a @@ -1996,7 +2318,11 @@ fun TvSettingsPickerSheet( val initialFocus = remember { FocusRequester() } val selectedIndex = options.indexOfFirst { it.id == selectedId }.coerceAtLeast(0) - var pickerHasFocus by remember { mutableStateOf(false) } + // The target row's own focus, not the sheet's: the dialog hands focus to + // its first row as it opens, so "something in the sheet is focused" was + // already true before the request landed, and focus stayed on the first + // row instead of the current choice. + var targetHasFocus by remember { mutableStateOf(false) } val focusTargetIndex = if (options.isEmpty()) -1 else selectedIndex val listState: LazyListState = rememberLazyListState() @@ -2007,7 +2333,7 @@ fun TvSettingsPickerSheet( maxAttempts = TvContentInitialFocusMaxAttempts, awaitAttempt = { withFrameNanos { } }, requestFocus = initialFocus::requestFocus, - isFocused = { pickerHasFocus }, + isFocused = { targetHasFocus }, ) } } @@ -2016,32 +2342,71 @@ fun TvSettingsPickerSheet( onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false), ) { - // tvOS presents pickers as a fullScreenCover over the opaque app - // background with a leading nav title and a centered option column. + // tvOS `TVSettingsPickerSheet`: a card over a dark scrim, with the + // title, a "Choose an option" line and a close hint above a hairline, + // then the option list. Box( modifier = Modifier .fillMaxSize() - .onFocusChanged { pickerHasFocus = it.hasFocus } - .background(Color.Black.copy(alpha = 0.94f)), + .background(Color.Black.copy(alpha = 0.88f)), contentAlignment = Alignment.Center, ) { Column( - horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .width(380.dp) + .heightIn(max = 460.dp) + .tvDialogSurface(), ) { - Text( - text = title, - style = MaterialTheme.typography.displaySmall.copy(fontSize = 19.sp, lineHeight = 23.sp), - fontWeight = FontWeight.SemiBold, - color = Color.White, - modifier = Modifier.padding(bottom = 20.dp), + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 18.dp, end = 14.dp, top = 16.dp, bottom = 12.dp), + verticalAlignment = Alignment.Top, + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = title, + style = MaterialTheme.typography.displaySmall.copy(fontSize = 21.sp, lineHeight = 25.sp), + fontWeight = FontWeight.Bold, + color = Color.White, + ) + Text( + text = "Choose an option", + style = MaterialTheme.typography.bodySmall.copy(fontSize = 14.sp, lineHeight = 18.sp), + color = Color.White.copy(alpha = 0.6f), + ) + } + Row( + modifier = Modifier + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.055f)) + .padding(horizontal = 9.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Undo, + contentDescription = null, + tint = Color.White.copy(alpha = 0.6f), + modifier = Modifier.size(14.dp), + ) + Text( + text = "Back to close", + style = MaterialTheme.typography.bodySmall.copy(fontSize = 14.sp, lineHeight = 18.sp), + color = Color.White.copy(alpha = 0.6f), + ) + } + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(Color.White.copy(alpha = 0.08f)), ) - LazyColumn( state = listState, - modifier = Modifier - .width(420.dp), - contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(5.dp), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), ) { items(options, key = { it.id }) { option -> val isFocusTarget = option.id == (options.getOrNull(focusTargetIndex)?.id) @@ -2050,7 +2415,9 @@ fun TvSettingsPickerSheet( selected = option.id == selectedId, onClick = { onSelect(option.id) }, modifier = if (isFocusTarget) { - Modifier.focusRequester(initialFocus) + Modifier + .focusRequester(initialFocus) + .onFocusChanged { targetHasFocus = it.isFocused } } else { Modifier }, @@ -2070,28 +2437,25 @@ private fun TvSettingsPickerOptionRow( onClick: () -> Unit, modifier: Modifier = Modifier, ) { - val shape = RoundedCornerShape(7.dp) val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() + val foreground = if (isFocused) FocusedContent else Color.White Surface( onClick = onClick, interactionSource = interactionSource, - shape = ClickableSurfaceDefaults.shape(shape = shape), + shape = ClickableSurfaceDefaults.shape(shape = RowShape), colors = ClickableSurfaceDefaults.colors( - containerColor = Color.White.copy(alpha = 0.07f), + // tvOS: the current option keeps the "selected" fill at rest. + containerColor = Color.White.copy(alpha = if (selected) 0.14f else 0.07f), contentColor = Color.White, focusedContainerColor = FocusedContainer, focusedContentColor = FocusedContent, pressedContainerColor = FocusedContainer, pressedContentColor = FocusedContent, ), - border = ClickableSurfaceDefaults.border( - border = Border(BorderStroke(1.dp, Color.White.copy(alpha = 0.09f)), shape = shape), - focusedBorder = Border.None, - pressedBorder = Border.None, - ), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f), + border = invertedRowBorder(), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.0f), modifier = modifier .fillMaxWidth() .semantics { this.selected = selected }, @@ -2099,26 +2463,36 @@ private fun TvSettingsPickerOptionRow( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 9.dp), + .heightIn(min = RowHeight) + .padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - // tvOS TVSettingsPickerOptionRow: the checkmark leads and always - // reserves its slot so option labels stay aligned. - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - tint = if (isFocused) FocusedContent else Color.White, - modifier = Modifier - .size(15.dp) - .alpha(if (selected) 1f else 0f), - ) - Text( - text = option.label, - style = MaterialTheme.typography.headlineSmall.copy(fontSize = 15.sp, lineHeight = 18.sp), - color = if (isFocused) FocusedContent else Color.White, - modifier = Modifier.weight(1f), - ) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = option.label, + style = SettingsRowTextStyle(), + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + color = foreground, + ) + option.detail?.let { detail -> + Text( + text = detail, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 14.sp, lineHeight = 18.sp), + color = foreground.copy(alpha = 0.72f), + ) + } + } + // tvOS TVSettingsPickerOptionRow: a filled check circle trails the + // current option. + if (selected) { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = null, + tint = foreground, + modifier = Modifier.size(16.dp), + ) + } } } } @@ -2127,7 +2501,6 @@ private fun TvSettingsPickerOptionRow( // Confirm dialog // --------------------------------------------------------------------------- -@OptIn(ExperimentalTvMaterial3Api::class) @Composable internal fun TvSettingsConfirmDialog( title: String, @@ -2135,99 +2508,39 @@ internal fun TvSettingsConfirmDialog( confirmLabel: String, onConfirm: () -> Unit, onDismiss: () -> Unit, + destructive: Boolean = true, ) { - BackHandler(onBack = onDismiss) - // Default focus lands on Cancel so a stray OK press never triggers the - // destructive action. - val cancelFocus = remember { FocusRequester() } - var confirmHasFocus by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { - requestFocusUntilObserved( - maxAttempts = TvContentInitialFocusMaxAttempts, - awaitAttempt = { withFrameNanos { } }, - requestFocus = cancelFocus::requestFocus, - isFocused = { confirmHasFocus }, - ) - } - - Dialog( + // Cancel comes first and takes focus, so a stray OK press never runs the + // action. A destructive action reads red at rest and fills red on focus. + TvDialog( + title = title, + message = message, onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.86f)), - contentAlignment = Alignment.Center, - ) { - Column( - modifier = Modifier - .width(320.dp) - .clip(RoundedCornerShape(10.dp)) - .background(MaterialTheme.colorScheme.surface) - .padding(20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium.copy(fontSize = 19.sp, lineHeight = 22.sp), - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = message, - style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp, lineHeight = 17.sp), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.height(4.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - DialogButton( - label = "Cancel", - onClick = onDismiss, - focusRequester = cancelFocus, - ) - DialogButton( - label = confirmLabel, - onClick = onConfirm, - destructive = true, - ) - } - } - } - } + actions = listOf( + TvDialogAction(label = "Cancel", onClick = onDismiss), + TvDialogAction( + label = confirmLabel, + onClick = onConfirm, + style = if (destructive) TvDialogActionStyle.Destructive else TvDialogActionStyle.Primary, + ), + ), + ) } -@OptIn(ExperimentalTvMaterial3Api::class) +/** The Experimental group: features that are not finished yet. */ @Composable -private fun DialogButton( - label: String, - onClick: () -> Unit, - destructive: Boolean = false, - focusRequester: FocusRequester? = null, +private fun TvExperimentalSettingsGroup( + watchPartyExperiment: WatchPartyExperiment = koinInject(), ) { - val shape = RoundedCornerShape(6.dp) - val interactionSource = remember { MutableInteractionSource() } - val isFocused by interactionSource.collectIsFocusedAsState() - Surface( - onClick = onClick, - interactionSource = interactionSource, - shape = ClickableSurfaceDefaults.shape(shape = shape), - colors = ClickableSurfaceDefaults.colors( - containerColor = Color.White.copy(alpha = 0.08f), - contentColor = if (destructive) MaterialTheme.colorScheme.error else Color.White, - focusedContainerColor = FocusedContainer, - focusedContentColor = FocusedContent, - pressedContainerColor = FocusedContainer, - pressedContentColor = FocusedContent, - ), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f), - modifier = (focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier), - ) { - Text( - text = label, - style = MaterialTheme.typography.titleMedium.copy(fontSize = 14.sp, lineHeight = 17.sp), - color = if (isFocused) FocusedContent else if (destructive) MaterialTheme.colorScheme.error else Color.White, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + val watchPartyEnabled by watchPartyExperiment.enabled.collectAsState() + SettingsGroup(title = "Experimental") { + SettingsToggleRow( + label = "Watch Party", + checked = watchPartyEnabled, + onCheckedChange = watchPartyExperiment::setEnabled, + ) + SettingsFooterText( + text = "Try Watch Party before it's finished. Turning it off leaves any party you're in.", ) } } @@ -2237,8 +2550,8 @@ private fun DialogButton( // --------------------------------------------------------------------------- /** - * tvOS `TVSettingsSectionHeader` parity: small mono uppercase section label - * (size 15pt mono semibold, tracking 2) above tightly packed rows. + * tvOS `TVSettingsSectionHeader` parity: a small uppercase semibold label in + * the secondary colour, aligned with the row text, above tightly packed rows. */ @OptIn(ExperimentalTvMaterial3Api::class) @Composable @@ -2249,9 +2562,9 @@ internal fun SettingsGroup( Column(verticalArrangement = Arrangement.spacedBy(SettingsGroupRowSpacing)) { Text( text = title.uppercase(), - style = SettingsMonoHeaderStyle(), + style = SettingsGroupHeaderStyle(), color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 2.dp, top = 8.dp, bottom = 2.dp), + modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 2.dp), ) content() } @@ -2279,22 +2592,22 @@ private fun SettingsNestedGroup( } @Composable -private fun SettingsMonoHeaderStyle() = +private fun SettingsGroupHeaderStyle() = MaterialTheme.typography.labelMedium.copy( - fontFamily = FontFamily.Monospace, fontSize = 14.sp, lineHeight = 18.sp, - letterSpacing = 1.5.sp, + letterSpacing = 1.sp, fontWeight = FontWeight.SemiBold, ) -/** Shared 16sp text for all detail-pane row labels and values. */ +/** Shared text for all detail-pane row labels and values (tvOS 26pt). */ @Composable private fun SettingsRowTextStyle() = - MaterialTheme.typography.bodyLarge.copy(fontSize = 16.sp, lineHeight = 20.sp) + MaterialTheme.typography.bodyLarge.copy(fontSize = 15.sp, lineHeight = 19.sp) -private val RowShape = RoundedCornerShape(10.dp) -private val RowMaxWidth = 520.dp +// tvOS pane rows: 14pt corners, content capped at 1080pt — halved to dp. +private val RowShape = RoundedCornerShape(7.dp) +private val RowMaxWidth = 540.dp /** * Gap between the rows (and the trailing footer) inside one [SettingsGroup]. @@ -2304,29 +2617,47 @@ private val RowMaxWidth = 520.dp * height — see `TvDiagnosticsSettingsPane`. Two copies of the number would * silently drift. */ -internal val SettingsGroupRowSpacing = 6.dp -// 42dp keeps the 16sp row text comfortably centered — audit 2026-07-20. -private val RowHeight = 42.dp +internal val SettingsGroupRowSpacing = 4.dp +// tvOS rows are 66pt tall around 26pt text; 38dp keeps that proportion for +// the 15sp row text. +private val RowHeight = 38.dp -/** The one settings-surface ground color. Shared so no screen re-hardcodes it. */ -internal val SettingsBackground = Color(0xFF17181A) +/** Rail category rows carry a title over a description (tvOS rail rows). */ +private val RailRowHeight = 50.dp + +/** + * The one settings-surface ground color: black, like the Apple apps' + * `prairieBackground`. Shared so no screen re-hardcodes it. + */ +internal val SettingsBackground = Color(0xFF000000) + +/** Graphite tile behind the pane header glyph (Apple `prairieIconTile`). */ +private val PaneHeaderTile = Color(0xFF3A3A3C) + +/** Picker and confirmation cards (Apple `prairieSurfaceElevated`), 30pt corners. */ // tvOS destructive row colors: bright red at rest on black, deeper red on the // focused white platter (TVSettingsRailRowStyle). private val DestructiveRed = Color(0xFFD22F3F) private val DestructiveRedOnPlatter = Color(0xFFB00020) +/** + * tvOS rail profile row: the profile's avatar, its name over the account line, + * and a chevron. Selecting it switches profile. + */ @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun SettingsAccountRow( name: String, subtitle: String, - avatar: String?, + avatar: ProfileAvatarRef, onClick: () -> Unit, focusRequester: FocusRequester? = null, ) { val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() + val foreground = if (isFocused) FocusedContent else Color.White + val avatarImage = rememberProfileAvatarImage(avatar) Surface( onClick = onClick, interactionSource = interactionSource, @@ -2354,33 +2685,43 @@ private fun SettingsAccountRow( ) { Box( modifier = Modifier - .size(30.dp) + .size(34.dp) .clip(CircleShape) - .background( - if (isFocused) FocusedContent.copy(alpha = 0.15f) - else Color.White.copy(alpha = 0.12f), - ), + .background(Color.White.copy(alpha = 0.12f)), contentAlignment = Alignment.Center, ) { - Text( - text = name.take(1).uppercase(), - style = MaterialTheme.typography.titleMedium.copy(fontSize = 14.sp), - color = if (isFocused) FocusedContent else Color.White, - ) + if (avatarImage != null) { + ThumbhashImage( + url = avatarImage.url, + thumbhash = null, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + transparent = true, + cacheKey = avatarImage.cacheKey, + onError = avatarImage.onLoadFailed, + ) + } else { + Text( + text = profileAvatarDisplayText(avatar, name), + style = MaterialTheme.typography.titleMedium.copy(fontSize = 14.sp), + color = foreground, + ) + } } Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) { Text( text = name, style = MaterialTheme.typography.titleMedium.copy(fontSize = 14.sp, lineHeight = 17.sp), fontWeight = FontWeight.SemiBold, - color = if (isFocused) FocusedContent else Color.White, + color = foreground, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( text = subtitle, style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp, lineHeight = 18.sp), - color = (if (isFocused) FocusedContent else Color.White).copy(alpha = 0.75f), + color = foreground.copy(alpha = 0.62f), maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -2388,7 +2729,7 @@ private fun SettingsAccountRow( Icon( imageVector = Icons.Default.ChevronRight, contentDescription = null, - tint = (if (isFocused) FocusedContent else Color.White).copy(alpha = 0.5f), + tint = foreground.copy(alpha = 0.5f), modifier = Modifier.size(14.dp), ) } @@ -2429,7 +2770,7 @@ internal fun SettingsValueRow( Row( modifier = Modifier .fillMaxSize() - .padding(horizontal = 16.dp), + .padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { @@ -2492,7 +2833,7 @@ internal fun SettingsActionRow( Row( modifier = Modifier .fillMaxSize() - .padding(horizontal = 16.dp), + .padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -2552,7 +2893,7 @@ internal fun SettingsToggleRow( Row( modifier = Modifier .fillMaxSize() - .padding(horizontal = 16.dp), + .padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -2585,7 +2926,7 @@ internal fun SettingsInfoRow(label: String, value: String, singleLine: Boolean = .clip(RowShape) .background(Color.White.copy(alpha = 0.07f)) .border(1.dp, Color.White.copy(alpha = 0.09f), RowShape) - .padding(horizontal = 16.dp, vertical = if (singleLine) 0.dp else 10.dp), + .padding(horizontal = 12.dp, vertical = if (singleLine) 0.dp else 10.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -2614,7 +2955,7 @@ internal fun SettingsFooterText(text: String, modifier: Modifier = Modifier) { Text( text = text, style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp, lineHeight = 18.sp), - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), // The modifier goes outermost so a caller measuring this footer sees the // laid-out block, not the text before its width cap and padding apply. modifier = modifier @@ -2670,3 +3011,41 @@ private fun nextUpPromptLabel(seconds: Int): String = when { seconds == 60 -> "1 minute before end" else -> "${seconds / 60} minutes before end" } + +/** + * "Show title art" and "Apply to all devices" (settings revision 16). Hidden + * until the server confirms the key; older servers keep logos on. The + * switches stay disabled until this session's read lands. + */ +@Composable +private fun TvTitleArtSettingsGroup(store: TitleArtStore) { + val state by store.state.collectAsState() + val saveError by store.saveError.collectAsState() + if (!state.isSupported) return + SettingsGroup(title = "Title Pages") { + SettingsToggleRow( + label = "Show title art", + checked = state.showTitleArt, + onCheckedChange = store::setShowTitleArt, + enabled = state.canEdit, + ) + SettingsFooterText(text = "Use logo artwork as the title when available.") + SettingsToggleRow( + label = "Apply to all devices", + checked = state.appliesToAllDevices, + onCheckedChange = store::setAppliesToAllDevices, + enabled = state.canEdit, + ) + SettingsFooterText( + text = if (state.appliesToAllDevices) { + "On: every device on this profile uses this choice. Title art is " + + "${if (state.showTitleArt) "on" else "off"} on every device signed into " + + "this profile. Changing it here changes it everywhere. Turn off " + + "\u201CApply to all devices\u201D to choose for this TV only." + } else { + "Off: only affects this TV. Your other devices keep their own setting." + }, + ) + saveError?.let { SettingsFooterText(text = it) } + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvSettingsViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvSettingsViewModel.kt index c656ae4e3c..0eb62ed247 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvSettingsViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvSettingsViewModel.kt @@ -6,6 +6,9 @@ import androidx.lifecycle.viewModelScope import org.prairieserver.prairie.common.settings.CardPresentationSource import org.prairieserver.prairie.common.settings.CardPresentationStore import org.prairieserver.prairie.common.settings.CardPresentationSupport +import org.prairieserver.prairie.common.settings.EpisodeSpoilerSetting +import org.prairieserver.prairie.common.settings.EpisodeSpoilerState +import org.prairieserver.prairie.common.settings.EpisodeSpoilerStore import org.prairieserver.prairie.common.settings.LibraryPlaybackPrefsStore import org.prairieserver.prairie.common.settings.OverlayPrefsStore import org.prairieserver.prairie.common.settings.PlayerSettingsStore @@ -23,18 +26,22 @@ import org.prairieserver.prairie.model.settings.SubtitleFontSizePreset import org.prairieserver.prairie.model.settings.SubtitlePositionPreset import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.ServerRegistry +import org.prairieserver.prairie.common.ui.components.ProfileAvatarRef +import org.prairieserver.prairie.common.ui.components.avatarRef import org.prairieserver.prairie.network.TokenManager import org.prairieserver.prairie.repository.AuthRepository import org.prairieserver.prairie.repository.ProfileRepository import org.prairieserver.prairie.tv.data.preferences.LegacyTvPrefsMigration +import org.prairieserver.prairie.common.profiles.ProfileLaunchBehavior import org.prairieserver.prairie.tv.data.preferences.SubtitleMode import org.prairieserver.prairie.tv.data.preferences.SubtitleSize +import org.prairieserver.prairie.common.profiles.ProfileLaunchPreferences +import org.prairieserver.prairie.tv.watchnext.WatchNextSeeder import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -64,7 +71,10 @@ class TvSettingsViewModel( private val profileSettings: ProfileSettingsController, private val tvLibraryScopeStore: org.prairieserver.prairie.tv.data.preferences.TvLibraryScopeStore? = null, private val seekIntervalStore: SeekIntervalStore? = null, + private val episodeSpoilerStore: EpisodeSpoilerStore? = null, audiobookSettingsStore: AudiobookSettingsStore? = null, + private val profileLaunchPreferences: ProfileLaunchPreferences? = null, + private val watchNextSeeder: WatchNextSeeder? = null, ) : ViewModel() { /** @@ -87,7 +97,7 @@ class TvSettingsViewModel( val userError: String? = null, // Active profile identity for the tappable account header row. val profileName: String? = null, - val profileAvatar: String? = null, + val profileAvatar: ProfileAvatarRef = ProfileAvatarRef.None, val serverUrl: String = "", val serverName: String = "", // Whether the canonical settings probe succeeded; playback is @@ -115,14 +125,19 @@ class TvSettingsViewModel( val subtitleAppearance: SubtitleAppearance = SubtitleAppearance.DEFAULT, val effectiveSubtitleAppearance: SubtitleAppearance = SubtitleAppearance.DEFAULT, val subtitleUsesDeviceOverride: Boolean = false, + /** False when the server is known to discard subtitle text opacity. */ + val subtitleTextOpacitySupported: Boolean = true, val autoPlayNext: Boolean = true, val introSkipMode: IntroSkipMode = IntroSkipMode.Default, val matchContentFrameRate: Boolean = false, val dolbyVisionEnabled: Boolean = true, val showAudiobooksTab: Boolean = false, + // Profile Selection (tvOS General → PROFILE AT LAUNCH), device-wide. + val profileLaunchBehavior: ProfileLaunchBehavior = ProfileLaunchBehavior.Automatic, val subtitleMatchesDevice: Boolean = false, - val dvProfile7HDR10Fallback: Boolean = true, + val dvProfile7HDR10Fallback: Boolean = false, val forceHdrPassthrough: Boolean = false, + val trueBlackBars: Boolean = false, val autoSkipCredits: Boolean = false, // Seconds to skip back on resume (0 = off); consecutive auto-advances // before the "Still watching?" prompt (0 = off). @@ -131,12 +146,28 @@ class TvSettingsViewModel( // Seconds before the end of an episode to surface the Up-Next prompt // (0 = at the very end). Mirrors tvOS `nextUpPromptSeconds`. val nextUpPromptSeconds: Int = 10, + /** + * Keys this device holds its own value for. A profile-layered control + * whose key is absent shows "Use profile setting" as its choice. + */ + val deviceOverrides: Set<String> = emptySet(), + /** + * Outcome of the last "Use Profile Settings", until the screen has told + * the user: true when the server confirmed every clear, false when it + * couldn't (offline, refused, or interrupted). + */ + val playbackOverridesReset: Boolean? = null, // Cards & Posters (`ui.card_presentation`), mirrored from // CardPresentationStore. Source drives the "Only This Device" toggle // and the "Use Profile Default" action; support gates the whole group. val cardPresentation: CardPresentation = CardPresentation.DEFAULT, val cardPresentationSource: CardPresentationSource = CardPresentationSource.Unknown, val cardPresentationSupport: CardPresentationSupport = CardPresentationSupport.Unknown, + // Spoiler protection for unwatched episodes, mirrored + // from EpisodeSpoilerStore. The group is hidden unless supported. + val episodeSpoilers: EpisodeSpoilerState = EpisodeSpoilerState(), + /** Why the last spoiler switch change wasn't saved. */ + val episodeSpoilerSaveError: String? = null, val navAction: NavAction? = null, ) @@ -148,6 +179,17 @@ class TvSettingsViewModel( loadSettings() observePlayerSettings() observeCardPresentation() + observeEpisodeSpoilers() + observeProfileLaunch() + } + + private fun observeProfileLaunch() { + val preferences = profileLaunchPreferences ?: return + viewModelScope.launch { + preferences.state.collect { launch -> + _uiState.update { it.copy(profileLaunchBehavior = launch.behavior) } + } + } } /** @@ -184,7 +226,7 @@ class TvSettingsViewModel( userLoading = false, userError = null, profileName = profile?.name, - profileAvatar = profile?.avatar, + profileAvatar = profile?.avatarRef() ?: ProfileAvatarRef.None, ) } return@launch @@ -366,6 +408,11 @@ class TvSettingsViewModel( _uiState.update { it.copy(nextUpPromptSeconds = seconds) } } } + viewModelScope.launch { + playerSettingsStore.deviceOverrideKeysFlow.collect { keys -> + _uiState.update { it.copy(deviceOverrides = keys) } + } + } viewModelScope.launch { playerSettingsStore.matchContentFrameRateFlow.collect { value -> _uiState.update { it.copy(matchContentFrameRate = value) } @@ -392,6 +439,11 @@ class TvSettingsViewModel( _uiState.update { it.copy(forceHdrPassthrough = value) } } } + viewModelScope.launch { + playerSettingsStore.trueBlackBarsFlow.collect { value -> + _uiState.update { it.copy(trueBlackBars = value) } + } + } viewModelScope.launch { playerSettingsStore.subtitleMatchesDeviceFlow.collect { value -> _uiState.update { it.copy(subtitleMatchesDevice = value) } @@ -409,6 +461,33 @@ class TvSettingsViewModel( _uiState.update { it.copy(effectiveSubtitleAppearance = appearance) } } } + viewModelScope.launch { + playerSettingsStore.subtitleTextOpacitySupportedFlow.collect { supported -> + _uiState.update { it.copy(subtitleTextOpacitySupported = supported) } + } + } + } + + /** Mirror the spoiler store into UI state and re-read it from the server. */ + private fun observeEpisodeSpoilers() { + val store = episodeSpoilerStore ?: return + viewModelScope.launch { + store.state.collect { spoilers -> _uiState.update { it.copy(episodeSpoilers = spoilers) } } + } + viewModelScope.launch { + store.saveError.collect { error -> _uiState.update { it.copy(episodeSpoilerSaveError = error) } } + } + viewModelScope.launch { store.refresh() } + } + + /** Profile-wide; the store writes it optimistically and rolls back on failure. */ + fun setHideUnwatchedEpisodeImages(enabled: Boolean) { + episodeSpoilerStore?.set(EpisodeSpoilerSetting.Images, enabled) + } + + /** Profile-wide; the store writes it optimistically and rolls back on failure. */ + fun setHideUnwatchedEpisodeOverviews(enabled: Boolean) { + episodeSpoilerStore?.set(EpisodeSpoilerSetting.Overviews, enabled) } /** Mirror the card-presentation store into UI state (single source of truth). */ @@ -572,15 +651,15 @@ class TvSettingsViewModel( } /** - * Per-field appearance setters. Each reads the freshest appearance from the - * store before copying the single changed field, so a concurrent edit (e.g. - * a HUD change while a Settings picker is open) is not clobbered by a stale - * composable-captured snapshot. Mirrors [onSubtitleSizeChanged]. + * Per-field appearance setters. Each applies its change atomically inside + * the store's own write transaction ([PlayerSettingsStore.updateSubtitleAppearance]), + * so a concurrent edit (e.g. a HUD change while a Settings picker is + * open) can't race on a snapshot read before either writes. Mirrors + * [onSubtitleSizeChanged]. */ private fun editAppearance(transform: (SubtitleAppearance) -> SubtitleAppearance) { viewModelScope.launch { - val current = playerSettingsStore.subtitleAppearanceFlow.first() - playerSettingsStore.setSubtitleAppearance(transform(current)) + playerSettingsStore.updateSubtitleAppearance(transform) // The granular subtitle.* fields are client-local — the contract // carries appearance as one object — so a per-field edit only // reaches the server once it is projected into the composite. @@ -594,6 +673,8 @@ class TvSettingsViewModel( fun setSubtitleFontColor(value: String) = editAppearance { it.copy(fontColor = value) } + fun setSubtitleTextOpacity(value: Int) = editAppearance { it.copy(textOpacity = value) } + fun setSubtitleTextOutline(value: Boolean) = editAppearance { it.copy(textOutline = value) } fun setSubtitleTextOutlineColor(value: String) = editAppearance { it.copy(textOutlineColor = value) } @@ -634,6 +715,31 @@ class TvSettingsViewModel( } } + /** + * Profile Selection takes effect the next time Prairie starts or returns; it + * never ejects the current session (prairie-apple design §4.5). Every Time + * empties the launcher's Watch Next row, and leaving it refills the row. + */ + fun onProfileLaunchBehaviorChanged(behavior: ProfileLaunchBehavior) { + val preferences = profileLaunchPreferences ?: return + val previous = preferences.state.value.behavior + if (previous == behavior) return + preferences.setBehavior(behavior) + val seeder = watchNextSeeder ?: return + if (behavior == ProfileLaunchBehavior.EveryTime) { + seeder.clear() + } else if (previous == ProfileLaunchBehavior.EveryTime) { + viewModelScope.launch { + if (tokenManager.getProfileId().isNullOrBlank()) return@launch + // A later choice may have gone back to Every Time (which + // cleared the row) while this read was in flight. + if (preferences.state.value.behavior == ProfileLaunchBehavior.EveryTime) return@launch + seeder.seedNow() + seeder.enqueuePeriodic() + } + } + } + fun onSubtitleMatchesDeviceChanged(value: Boolean) { viewModelScope.launch { playerSettingsStore.setSubtitleMatchesDevice(value) } } @@ -650,6 +756,10 @@ class TvSettingsViewModel( viewModelScope.launch { playerSettingsStore.setForceHdrPassthrough(value) } } + fun onTrueBlackBarsChanged(value: Boolean) { + viewModelScope.launch { playerSettingsStore.setTrueBlackBars(value) } + } + fun onIntroSkipModeChanged(value: IntroSkipMode) { viewModelScope.launch { playerSettingsStore.setIntroSkipMode(value) } } @@ -671,11 +781,24 @@ class TvSettingsViewModel( } /** - * Clear every server-side device override for this device. Mirrors - * iOS tvOS "Reset Playback Overrides" (TVSettingsView.swift:137). + * "Use Profile Settings": clears every setting this device holds its own + * value for. Mirrors iOS tvOS "Reset Playback Overrides" + * (TVSettingsView.swift:137). */ fun resetPlaybackOverrides() { - viewModelScope.launch { playerSettingsStore.resetAllDeviceSettings() } + viewModelScope.launch { + val landed = playerSettingsStore.resetAllDeviceSettings() + _uiState.update { it.copy(playbackOverridesReset = landed) } + } + } + + fun onPlaybackOverridesResetShown() { + _uiState.update { it.copy(playbackOverridesReset = null) } + } + + /** Goes back to the profile's value for one setting's control on this device. */ + fun onUseProfileSetting(key: String) { + viewModelScope.launch { playerSettingsStore.resetDeviceSetting(key) } } /** Lifecycle hook — call from MainTvActivity.onStop. */ @@ -696,6 +819,7 @@ class TvSettingsViewModel( overlayPrefsStore.clear() cardPresentationStore.clear() seekIntervalStore?.clear() + episodeSpoilerStore?.clear() _uiState.update { it.copy(navAction = NavAction.SIGNED_OUT) } } } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsSettingsPane.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsSettingsPane.kt index 1f7d728f0a..1b39b21df7 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsSettingsPane.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsSettingsPane.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -25,11 +24,9 @@ import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.onFocusEvent import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextAlign @@ -44,11 +41,14 @@ import org.prairieserver.prairie.common.diagnostics.DiagnosticsConsentMode import org.prairieserver.prairie.common.diagnostics.DiagnosticsDestinationKind import org.prairieserver.prairie.common.diagnostics.DiagnosticsUiState import org.prairieserver.prairie.common.diagnostics.TimedCaptureStatus +import org.prairieserver.prairie.tv.ui.components.TvDialogDefaults +import org.prairieserver.prairie.tv.ui.components.tvDialogSurface import org.prairieserver.prairie.tv.ui.focus.TvControlState import org.prairieserver.prairie.tv.ui.focus.TvFrameRelocationMaxAttempts import org.prairieserver.prairie.tv.ui.focus.claimFocusOrReport import org.prairieserver.prairie.tv.ui.focus.tvControlSemantics import org.prairieserver.prairie.tv.ui.screens.auth.QrCodePanel +import org.prairieserver.prairie.tv.ui.screens.player.TvPlayerType import org.prairieserver.prairie.tv.ui.screens.settings.PickerOption import org.prairieserver.prairie.tv.ui.screens.settings.SettingsActionRow import org.prairieserver.prairie.tv.ui.screens.settings.SettingsFooterText @@ -386,7 +386,8 @@ internal fun TvDiagnosticsSettingsPane( TvSettingsConfirmDialog( title = "Always send crash reports?", message = "Future eligible reports may upload automatically until you change this setting.", - confirmLabel = "Always Send", + confirmLabel = "Always send", + destructive = false, onConfirm = { confirmAlways = false onSetConsent(DiagnosticsConsentMode.ALWAYS) @@ -408,7 +409,7 @@ internal fun TvDiagnosticsSettingsPane( */ @OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun TvPrivacyPolicyDialog(onDismiss: () -> Unit) { +internal fun TvPrivacyPolicyDialog(onDismiss: () -> Unit) { BackHandler(onBack = onDismiss) val closeFocus = remember { FocusRequester() } LaunchedEffect(Unit) { @@ -424,23 +425,18 @@ private fun TvPrivacyPolicyDialog(onDismiss: () -> Unit) { Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) { Box( - modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.86f)), + modifier = Modifier.fillMaxSize().background(TvDialogDefaults.Scrim), contentAlignment = Alignment.Center, ) { Column( modifier = Modifier - .width(360.dp) - .clip(RoundedCornerShape(10.dp)) - .background(MaterialTheme.colorScheme.surface) - .padding(20.dp), + .width(380.dp) + .tvDialogSurface() + .padding(horizontal = 28.dp, vertical = 26.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp), ) { - Text( - text = "Privacy Policy", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) + Text(text = "Privacy Policy", style = TvPlayerType.DialogTitle) Text( text = "Scan this code with your phone, or type the address below, to read " + "the full policy.", diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchparty/TvJoinCodeDialog.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchparty/TvJoinCodeDialog.kt new file mode 100644 index 0000000000..2174196348 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchparty/TvJoinCodeDialog.kt @@ -0,0 +1,291 @@ +package org.prairieserver.prairie.tv.ui.screens.watchparty + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import androidx.tv.material3.Border +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Glow +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import org.prairieserver.prairie.tv.ui.components.rememberTvDialogInitialFocus +import org.prairieserver.prairie.tv.ui.focus.TvControlState +import org.prairieserver.prairie.tv.ui.focus.tvControlSemantics +import org.prairieserver.prairie.tv.ui.theme.DarkSurfaceElevated +import org.prairieserver.prairie.tv.ui.theme.FocusedContainer +import org.prairieserver.prairie.tv.ui.theme.FocusedContent +import org.prairieserver.prairie.tv.ui.theme.PrairieOnSurface +import org.prairieserver.prairie.watchtogether.WATCH_PARTY_CODE_ALPHABET +import org.prairieserver.prairie.watchtogether.WATCH_PARTY_CODE_LENGTH +import org.prairieserver.prairie.watchtogether.canDismissRoomEntry +import org.prairieserver.prairie.watchtogether.normalizeWatchPartyCode + +/** + * Pure D-pad join-code accumulator. Appends only characters from the room + * code alphabet (uppercasing letters; 0, 1, I and O never appear in a code), + * caps at [LENGTH], and supports backspace/clear. Kept free of Android types + * so it is unit-testable without Robolectric. + */ +data class JoinCodeState(val code: String = "") { + /** A complete, well-formed code, normalized the same way as a typed or pasted one. */ + val normalized: String? get() = normalizeWatchPartyCode(code) + val isComplete: Boolean get() = normalized != null + fun append(c: Char): JoinCodeState { + if (code.length >= LENGTH) return this + val up = c.uppercaseChar() + return if (up in WATCH_PARTY_CODE_ALPHABET) copy(code = code + up) else this + } + fun backspace() = if (code.isEmpty()) this else copy(code = code.dropLast(1)) + fun clear() = JoinCodeState() + companion object { const val LENGTH = WATCH_PARTY_CODE_LENGTH } +} + +/** Keys laid out in the focusable code-alphabet grid, eight per row. */ +private val JoinCodeKeys: List<Char> = WATCH_PARTY_CODE_ALPHABET.toList() +private const val KEYS_PER_ROW = 8 + +/** + * D-pad Watch Party join-by-code dialog on the party's card: one slot per + * code character, a focusable pill-key grid of the code alphabet (each key + * appends into a [JoinCodeState]), Delete (backspace), and Join party, which + * acts once the code is complete and nothing is in flight. [onJoin] receives + * the normalized code. + */ +@Composable +fun TvJoinCodeDialog( + isBusy: Boolean, + error: String?, + onJoin: (String) -> Unit, + onDismiss: () -> Unit, +) { + var state by remember { mutableStateOf(JoinCodeState()) } + val firstKeyFocus = remember { FocusRequester() } + + Popup( + alignment = Alignment.Center, + onDismissRequest = { + if (canDismissRoomEntry(isBusy)) onDismiss() + }, + properties = PopupProperties( + focusable = true, + dismissOnBackPress = canDismissRoomEntry(isBusy), + dismissOnClickOutside = canDismissRoomEntry(isBusy), + clippingEnabled = false, + ), + ) { + // A scrim and the party's card: the hub's text sits right behind the keypad. + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.72f)) + .padding(start = 36.dp, top = 30.dp, end = 36.dp, bottom = 30.dp), + contentAlignment = Alignment.Center, + ) { + val panelShape = RoundedCornerShape(15.dp) + Column( + modifier = Modifier + .width(420.dp) + .background(color = DarkSurfaceElevated, shape = panelShape) + .border(1.dp, PartyChromeBorder, panelShape) + .padding(horizontal = 22.dp, vertical = 18.dp) + .then(rememberTvDialogInitialFocus(firstKeyFocus)), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + TvPartyEyebrow("Join a Watch Party") + Text( + text = "Enter the party code", + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + color = PrairieOnSurface, + ) + + // One slot per character; the next one to fill is outlined. + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + (0 until JoinCodeState.LENGTH).forEach { i -> + val char = state.code.getOrNull(i) + val next = i == state.code.length + Box( + modifier = Modifier + .weight(1f) + .height(40.dp) + .background(Color.White.copy(alpha = 0.06f), RoundedCornerShape(8.dp)) + .border( + width = if (next) 1.5.dp else 1.dp, + color = if (next) PrairieOnSurface.copy(alpha = 0.7f) else PartyChromeBorder, + shape = RoundedCornerShape(8.dp), + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = char?.toString() ?: "", + fontSize = 20.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + color = PrairieOnSurface, + ) + } + } + } + + // Focusable code-alphabet grid. + Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.padding(top = 4.dp)) { + JoinCodeKeys.chunked(KEYS_PER_ROW).forEachIndexed { rowIndex, rowKeys -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + rowKeys.forEachIndexed { colIndex, ch -> + JoinCodeKey( + char = ch, + // Joining is in flight, not a dead end — the + // grid keeps its focus so the ring survives a + // failed join. + controlState = TvControlState.transient(!isBusy), + onClick = { state = state.append(ch) }, + modifier = if (rowIndex == 0 && colIndex == 0) { + Modifier + .weight(1f) + .focusRequester(firstKeyFocus) + } else { + Modifier.weight(1f) + }, + ) + } + // Pad the final partial row so keys keep a consistent width. + repeat(KEYS_PER_ROW - rowKeys.size) { + Box(modifier = Modifier.weight(1f)) + } + } + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End), + ) { + TvPartyButton( + label = "Delete", + kind = TvPartyButtonKind.Secondary, + state = TvControlState.transient(!isBusy && state.code.isNotEmpty()), + onClick = { state = state.backspace() }, + height = 34.dp, + ) + TvPartyButton( + label = if (isBusy) "Joining…" else "Join party", + state = TvControlState.transient(state.isComplete && !isBusy), + onClick = { state.normalized?.let(onJoin) }, + height = 34.dp, + fontSize = 15.sp, + ) + } + + error?.let { message -> TvPartyBanner(message = message, warning = true) } + } + } + } +} + +/** Single focusable key tile in the join-code grid. */ +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun JoinCodeKey( + char: Char, + controlState: TvControlState, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val shape = RoundedCornerShape(100.dp) + val enabled = controlState.actionable + + Surface( + onClick = { controlState.perform(onClick) }, + enabled = controlState.focusable, + interactionSource = interactionSource, + shape = ClickableSurfaceDefaults.shape(shape = shape), + // Dimmed from `actionable`, not from the Surface's disabled slots: the + // key stays focusable while a join is in flight, so it never enters the + // disabled colour path. + colors = ClickableSurfaceDefaults.colors( + containerColor = if (enabled) { + PartyChromeFill + } else { + Color.White.copy(alpha = 0.03f) + }, + contentColor = if (enabled) Color.White else Color.White.copy(alpha = 0.42f), + focusedContainerColor = FocusedContainer, + focusedContentColor = FocusedContent, + pressedContainerColor = FocusedContainer, + pressedContentColor = FocusedContent, + disabledContainerColor = Color.White.copy(alpha = 0.03f), + disabledContentColor = Color.White.copy(alpha = 0.38f), + ), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f), + border = ClickableSurfaceDefaults.border( + focusedBorder = Border( + border = BorderStroke(0.dp, Color.Transparent), + shape = shape, + ), + ), + glow = ClickableSurfaceDefaults.glow( + focusedGlow = Glow( + elevationColor = Color.White.copy(alpha = 0.18f), + elevation = 14.dp, + ), + ), + modifier = modifier + .height(30.dp) + .tvControlSemantics(controlState), + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = char.toString(), + style = MaterialTheme.typography.titleMedium.copy( + fontSize = 18.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.SemiBold, + ), + color = if (isFocused) FocusedContent else Color.White, + ) + } + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchparty/TvWatchPartyComponents.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchparty/TvWatchPartyComponents.kt new file mode 100644 index 0000000000..d46e3b8f9f --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchparty/TvWatchPartyComponents.kt @@ -0,0 +1,131 @@ +package org.prairieserver.prairie.tv.ui.screens.watchparty + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import kotlinx.coroutines.delay +import org.prairieserver.prairie.repository.WatchTogetherConnectionState +import org.prairieserver.prairie.tv.ui.components.TvDialogAction +import org.prairieserver.prairie.tv.ui.components.TvDialogActionStyle +import org.prairieserver.prairie.tv.ui.components.TvDialogCard +import org.prairieserver.prairie.tv.ui.components.TvDialogDefaults + +/** A non-focusable one- or two-line notice (reconnecting, host away, host stopped). */ +@Composable +internal fun TvPartyNotice(title: String, modifier: Modifier = Modifier, detail: String? = null) { + Column( + modifier = modifier + .background(Color.Black.copy(alpha = 0.72f), RoundedCornerShape(14.dp)) + .border(1.dp, Color.White.copy(alpha = 0.14f), RoundedCornerShape(14.dp)) + .padding(horizontal = 18.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium.copy(fontSize = 16.sp, fontWeight = FontWeight.SemiBold), + color = Color.White, + ) + if (detail != null) { + Text( + text = detail, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 14.sp, lineHeight = 18.sp), + color = Color.White.copy(alpha = 0.78f), + ) + } + } +} + +/** + * Two-button confirmation on the party's card. Initial focus goes to Cancel + * unless [focusConfirm], so a stray Select never runs a destructive action. + */ +@Composable +internal fun TvWatchPartyConfirmDialog( + title: String, + message: String?, + confirmLabel: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + cancelLabel: String = "Cancel", + destructive: Boolean = false, + focusConfirm: Boolean = false, +) { + Popup( + alignment = Alignment.Center, + onDismissRequest = onDismiss, + properties = PopupProperties( + focusable = true, + dismissOnBackPress = true, + dismissOnClickOutside = false, + clippingEnabled = false, + ), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(TvDialogDefaults.Scrim), + contentAlignment = Alignment.Center, + ) { + TvDialogCard( + title = title, + message = message, + actions = listOf( + TvDialogAction(label = cancelLabel, onClick = onDismiss), + TvDialogAction( + label = confirmLabel, + onClick = onConfirm, + style = if (destructive) TvDialogActionStyle.Destructive else TvDialogActionStyle.Primary, + ), + ), + initialFocusIndex = if (focusConfirm) 1 else 0, + ) + } + } +} + +/** + * The reconnect notice text, or null. It appears only after the room socket + * has been down for [delayMs], timed from when this screen first saw it down, + * and clears as soon as the socket is writable again. + */ +@Composable +internal fun rememberTvWatchPartyReconnectNotice( + connection: WatchTogetherConnectionState, + inRoom: Boolean, + delayMs: Long = TV_WATCH_PARTY_RECONNECT_NOTICE_DELAY_MS, +): String? { + var shown by remember { mutableStateOf(false) } + val down = inRoom && !connection.writable && connection.disconnectedAtMs != null + LaunchedEffect(down, connection.disconnectedAtMs) { + shown = false + if (down) { + delay(delayMs) + shown = true + } + } + if (!down || !shown) return null + return if (connection.epoch == 0L) "Connecting to the party…" else "Reconnecting to the party…" +} + +internal const val TV_WATCH_PARTY_RECONNECT_NOTICE_DELAY_MS = 2_000L diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchparty/TvWatchPartyDetailEntry.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchparty/TvWatchPartyDetailEntry.kt new file mode 100644 index 0000000000..0815abbdda --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchparty/TvWatchPartyDetailEntry.kt @@ -0,0 +1,371 @@ +package org.prairieserver.prairie.tv.ui.screens.watchparty + +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf +import org.prairieserver.prairie.model.feature.WatchPartyExposure +import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode +import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.repository.WatchTogetherRepository +import org.prairieserver.prairie.viewmodel.WatchPartyHubViewModel +import org.prairieserver.prairie.viewmodel.WatchPartyItem +import org.prairieserver.prairie.viewmodel.WatchPartyLobbyViewModel +import org.prairieserver.prairie.watchtogether.RoomSession +import org.prairieserver.prairie.watchtogether.WatchPartyAvailability +import org.prairieserver.prairie.watchtogether.WatchPartyAvailabilityRepository +import org.prairieserver.prairie.watchtogether.WatchPartyDestination +import org.prairieserver.prairie.watchtogether.watchPartyErrorMessage +import org.prairieserver.prairie.watchtogether.watchPartyHostingOffered + +/** The detail page's Watch Party row in its More menu. */ +internal data class TvWatchPartyDetailOption( + val title: String, + val subtitle: String, + val onSelect: () -> Unit, +) + +/** What the viewer asked the detail page to do with the party. */ +internal sealed interface TvDetailPartyRequest { + val item: WatchPartyItem + + /** Not engaged: host a Host Picks party with this item staged. */ + data class Host(override val item: WatchPartyItem) : TvDetailPartyRequest + + /** Host in a Host Picks lobby: stage this item. */ + data class Stage(val roomId: String, override val item: WatchPartyItem) : TvDetailPartyRequest + + /** Any member: suggest this item. */ + data class Suggest(val roomId: String, override val item: WatchPartyItem) : TvDetailPartyRequest + + /** Host while the party plays: confirm, then switch everyone to this item. */ + data class Select(val roomId: String, override val item: WatchPartyItem) : TvDetailPartyRequest +} + +/** Holds the detail page's pending party request; the dialogs and effects run it. */ +@Stable +internal class TvWatchPartyDetailEntry internal constructor() { + internal var request by mutableStateOf<TvDetailPartyRequest?>(null) + internal var selectConfirmed by mutableStateOf(false) +} + +@Composable +internal fun rememberTvWatchPartyDetailEntry(): TvWatchPartyDetailEntry = remember { TvWatchPartyDetailEntry() } + +/** + * The Watch Party row for a detail page showing [item] (null when the page + * is not a movie, an episode, or a series whose next-up episode is known). + * Not engaged: "Watch Party" hosts with the item staged. Engaged: a Host + * Picks host in the lobby adds it ("Add to party"), a host while playing + * switches everyone to it after a confirmation, and everyone else suggests it. + */ +@Composable +internal fun rememberTvWatchPartyDetailOption( + entry: TvWatchPartyDetailEntry, + item: WatchPartyItem?, + exposure: WatchPartyExposure = koinInject(), + repository: WatchTogetherRepository = koinInject(), + availability: WatchPartyAvailabilityRepository = koinInject(), +): TvWatchPartyDetailOption? { + val enabled by exposure.enabled.collectAsState() + val room by repository.roomSnapshot.collectAsState() + val available by availability.availability.collectAsState() + if (!enabled || item == null) return null + val current = room + if (current == null) { + if (!watchPartyHostingOffered(available)) return null + return TvWatchPartyDetailOption( + title = "Watch Party", + subtitle = "Host a party with this title", + onSelect = { entry.request = TvDetailPartyRequest.Host(item) }, + ) + } + val host = current.selfRole == MemberRole.Host && current.selfCanManageRoom + // As on phone: never offer an operation the server does not advertise. + val stagingSupported = (available as? WatchPartyAvailability.Available)?.features?.stagedSelection == true + val knownMode = current.selectionMode == RoomSelectionMode.HostPick || current.selectionMode == RoomSelectionMode.Vote + return when { + host && current.phase == RoomPhase.Lobby && current.selectionMode == RoomSelectionMode.HostPick && + stagingSupported -> + TvWatchPartyDetailOption( + title = "Add to party", + subtitle = "Make this the party's next title", + onSelect = { entry.request = TvDetailPartyRequest.Stage(current.roomId, item) }, + ) + // The server refuses a direct selection in a voting room; those suggest instead. + host && current.phase == RoomPhase.Playing && current.selectionMode == RoomSelectionMode.HostPick -> + TvWatchPartyDetailOption( + title = "Play for everyone", + subtitle = "Switch the party to this title", + onSelect = { + entry.selectConfirmed = false + entry.request = TvDetailPartyRequest.Select(current.roomId, item) + }, + ) + current.selfRole != MemberRole.Unknown && knownMode && + (current.phase == RoomPhase.Lobby || current.phase == RoomPhase.Playing) -> + TvWatchPartyDetailOption( + title = "Suggest to Party", + subtitle = "Add it to the party's suggestions", + onSelect = { entry.request = TvDetailPartyRequest.Suggest(current.roomId, item) }, + ) + else -> null + } +} + +/** + * Runs the detail page's party request: its confirmation, the room or hub + * operation, and the navigation that follows. [onNavigate] receives the + * party screen to show, or null for the hub. + */ +@Composable +internal fun TvWatchPartyDetailEffects( + entry: TvWatchPartyDetailEntry, + onNavigate: (WatchPartyDestination?) -> Unit, +) { + when (val request = entry.request) { + null -> Unit + is TvDetailPartyRequest.Host -> DetailHostRunner(entry, request, onNavigate) + is TvDetailPartyRequest.Stage -> DetailStageRunner(entry, request, onNavigate) + is TvDetailPartyRequest.Suggest -> DetailSuggestRunner(entry, request) + is TvDetailPartyRequest.Select -> if (!entry.selectConfirmed) { + TvWatchPartyConfirmDialog( + title = "Play this for everyone?", + message = "The party switches to ${request.item.title} now.", + confirmLabel = "Play for everyone", + focusConfirm = true, + onConfirm = { entry.selectConfirmed = true }, + onDismiss = { entry.request = null }, + ) + } else { + DetailSelectRunner(entry, request, onNavigate) + } + } +} + +@Composable +private fun DetailHostRunner( + entry: TvWatchPartyDetailEntry, + request: TvDetailPartyRequest.Host, + onNavigate: (WatchPartyDestination?) -> Unit, +) { + val context = LocalContext.current + val availability: WatchPartyAvailabilityRepository = koinInject() + val hub: WatchPartyHubViewModel = koinViewModel(key = "tv-detail-watch-party-hub") + val state by hub.uiState.collectAsState() + val latestNavigate by rememberUpdatedState(onNavigate) + var started by remember(request) { mutableStateOf(false) } + // Re-check first, as the hub does: the cached answer may predate the + // server turning Watch Party off. `checking` is set before this returns. + var rechecked by remember(request) { mutableStateOf(false) } + LaunchedEffect(request) { + hub.refresh(force = true) + rechecked = true + } + + LaunchedEffect(request, state.availability, rechecked, state.checking) { + if (started || !rechecked || state.checking) return@LaunchedEffect + when (state.availability ?: availability.availability.value) { + null -> Unit // The hub's first probe is still running. + is WatchPartyAvailability.Available -> { + started = true + TvWatchPartyPreviews.put(request.item) + hub.clearError() + hub.hostWithItem(request.item) + } + // Unsupported, not allowed, or a failed probe: the hub explains and offers Retry. + else -> { + entry.request = null + latestNavigate(null) + } + } + } + LaunchedEffect(state.destination) { + val destination = state.destination ?: return@LaunchedEffect + hub.consumeDestination() + entry.request = null + latestNavigate(destination) + } + LaunchedEffect(state.error) { + val error = state.error ?: return@LaunchedEffect + if (!started) return@LaunchedEffect + Toast.makeText(context, error, Toast.LENGTH_LONG).show() + hub.clearError() + // A staging failure after the room exists still lands in the lobby + // (the destination above); anything else ends this request. + if (state.destination == null) entry.request = null + } +} + +@Composable +private fun DetailStageRunner( + entry: TvWatchPartyDetailEntry, + request: TvDetailPartyRequest.Stage, + onNavigate: (WatchPartyDestination?) -> Unit, +) { + val context = LocalContext.current + val repository: WatchTogetherRepository = koinInject() + val lobby: WatchPartyLobbyViewModel = koinViewModel( + key = "tv-detail-watch-party-lobby-${request.roomId}", + parameters = { parametersOf(request.roomId) }, + ) + val latestNavigate by rememberUpdatedState(onNavigate) + LaunchedEffect(request) { + TvWatchPartyPreviews.put(request.item) + lobby.stage(request.item) + val staged = withTimeoutOrNull(REQUEST_TIMEOUT_MS) { + repository.roomSnapshot.first { room -> + room == null || room.roomId != request.roomId || room.selectedContentId == request.item.contentId + } + } + entry.request = null + if (staged != null && staged.roomId == request.roomId && staged.selectedContentId == request.item.contentId) { + Toast.makeText(context, "Added ${request.item.title} to the party.", Toast.LENGTH_SHORT).show() + latestNavigate(WatchPartyDestination.Lobby(request.roomId)) + } + } + LaunchedEffect(lobby) { + lobby.messages.collect { message -> + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + entry.request = null + } + } +} + +@Composable +private fun DetailSuggestRunner(entry: TvWatchPartyDetailEntry, request: TvDetailPartyRequest.Suggest) { + val context = LocalContext.current + val repository: WatchTogetherRepository = koinInject() + val lobby: WatchPartyLobbyViewModel = koinViewModel( + key = "tv-detail-watch-party-lobby-${request.roomId}", + parameters = { parametersOf(request.roomId) }, + ) + LaunchedEffect(request) { + val before = repository.suggestions.value.count { it.contentId == request.item.contentId } + lobby.suggest(request.item) + val added = withTimeoutOrNull(REQUEST_TIMEOUT_MS) { + repository.suggestions.first { list -> list.count { it.contentId == request.item.contentId } > before } + } + entry.request = null + if (added != null) { + Toast.makeText(context, "Suggested ${request.item.title} to the party.", Toast.LENGTH_SHORT).show() + } + } + LaunchedEffect(lobby) { + lobby.messages.collect { message -> + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + entry.request = null + } + } +} + +@Composable +private fun DetailSelectRunner( + entry: TvWatchPartyDetailEntry, + request: TvDetailPartyRequest.Select, + onNavigate: (WatchPartyDestination?) -> Unit, +) { + val context = LocalContext.current + val repository: WatchTogetherRepository = koinInject() + val latestNavigate by rememberUpdatedState(onNavigate) + LaunchedEffect(request) { + val result = repository.setSelection( + SetSelectionRequest( + contentId = request.item.contentId, + fileId = request.item.fileId, + libraryId = request.item.libraryId, + ), + ) + entry.request = null + entry.selectConfirmed = false + if (result is ApiResult.Success) { + latestNavigate(WatchPartyDestination.Player(request.roomId)) + } else { + Toast.makeText( + context, + watchPartyErrorMessage(result, "Couldn't switch the party to ${request.item.title}."), + Toast.LENGTH_LONG, + ).show() + } + } +} + +/** + * D5: while this device is in a Watch Party, starting other playback first + * asks the viewer to leave the party. Leaving is explicit, never hidden. + */ +@Stable +internal class TvWatchPartyPlayGuard internal constructor( + private val engaged: () -> Boolean, + /** Returns once the party is torn down, so no room command reaches the solo player. */ + private val leave: suspend () -> Unit, + private val scope: CoroutineScope, +) { + internal var pending by mutableStateOf<(() -> Unit)?>(null) + private set + + /** Run [play] now, or ask first when engaged. */ + fun requestPlay(play: () -> Unit) { + if (engaged()) pending = play else play() + } + + internal fun confirm() { + val play = pending ?: return + pending = null + scope.launch { + leave() + play() + } + } + + internal fun cancel() { + pending = null + } +} + +@Composable +internal fun rememberTvWatchPartyPlayGuard( + repository: WatchTogetherRepository = koinInject(), + roomSession: RoomSession = koinInject(), +): TvWatchPartyPlayGuard { + val scope = rememberCoroutineScope() + return remember(repository, roomSession, scope) { + TvWatchPartyPlayGuard( + engaged = { repository.roomSnapshot.value != null }, + leave = { roomSession.depart(closeRoom = false).join() }, + scope = scope, + ) + } +} + +@Composable +internal fun TvWatchPartyPlayGuardDialog(guard: TvWatchPartyPlayGuard) { + if (guard.pending == null) return + TvWatchPartyConfirmDialog( + title = "Leave the Watch Party to play this?", + message = "You're in a Watch Party. Leaving it stops the party on this TV.", + confirmLabel = "Leave and play", + onConfirm = guard::confirm, + onDismiss = guard::cancel, + ) +} + +private const val REQUEST_TIMEOUT_MS = 15_000L diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchparty/TvWatchPartyHubScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchparty/TvWatchPartyHubScreen.kt new file mode 100644 index 0000000000..68ed4951de --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchparty/TvWatchPartyHubScreen.kt @@ -0,0 +1,313 @@ +package org.prairieserver.prairie.tv.ui.screens.watchparty + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.tv.material3.Text +import kotlinx.coroutines.delay +import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode +import org.prairieserver.prairie.repository.WatchPartyPendingAction +import org.prairieserver.prairie.tv.ui.focus.TvControlState +import org.prairieserver.prairie.tv.ui.focus.TvRestoreFocusOnModalDismiss +import org.prairieserver.prairie.tv.ui.focus.rememberTvContentInitialFocus +import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved +import org.prairieserver.prairie.tv.ui.theme.PrairieOnSurface +import org.prairieserver.prairie.tv.ui.theme.PrairieSecondaryText +import org.prairieserver.prairie.viewmodel.WatchPartyHubViewModel +import org.prairieserver.prairie.watchtogether.WatchPartyAvailability +import org.prairieserver.prairie.watchtogether.WatchPartyDestination + +private enum class HubFocus { Return, Rejoin, Host, Join, Retry } + +/** + * The Watch Party entry, reached from the profile menu, laid out as the tvOS + * entry: the pitch on the left; Host (start a party, start one where everyone + * votes, Return or Rejoin) and Join (by code, or Check again when this server + * can't host) on the right. Navigation follows the hub ViewModel's + * destination only. + * + * [showEnded] shows why the last party ended (the lobby and player open the + * hub this way when a party ends under them). + */ +@Composable +fun TvWatchPartyHubScreen( + showEnded: Boolean, + onDestination: (WatchPartyDestination) -> Unit, + onBack: () -> Unit, + viewModel: WatchPartyHubViewModel = koinViewModel(), +) { + val state by viewModel.uiState.collectAsState() + var joinOpen by rememberSaveable { mutableStateOf(false) } + val returnFocus = remember { FocusRequester() } + val rejoinFocus = remember { FocusRequester() } + val hostFocus = remember { FocusRequester() } + val joinFocus = remember { FocusRequester() } + val retryFocus = remember { FocusRequester() } + var joinFocused by remember { mutableStateOf(false) } + + LaunchedEffect(state.destination) { + val destination = state.destination ?: return@LaunchedEffect + viewModel.consumeDestination() + joinOpen = false + onDestination(destination) + } + + // The recent party can change while this screen is in the back stack + // (a party ended, another identity signed in), and the server can turn + // Watch Party off at any time. Probe again whenever the hub is shown, as + // Apple does; a failed probe keeps the last known answer. + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) viewModel.refresh(force = true) + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + BackHandler(enabled = !joinOpen) { onBack() } + + val availability = state.availability + val available = availability is WatchPartyAvailability.Available + val busy = state.busy != null + // Still checking is a wait, not a dead end: the buttons stay focusable + // and dimmed. A server that can't host drops them from the focus path. + val entryState = when { + available -> TvControlState.transient(!busy) + availability == null -> TvControlState.transient(false) + else -> TvControlState.structural(false) + } + // Hosting needs staged selection; a server without it can still be joined. + val canHost = (availability as? WatchPartyAvailability.Available)?.features?.stagedSelection == true + val current = state.current + val recent = state.recent.takeIf { available } + val primary = when { + current != null -> HubFocus.Return + recent != null -> HubFocus.Rejoin + canHost || availability == null -> HubFocus.Host + available -> HubFocus.Join + else -> HubFocus.Retry + } + val primaryTarget = when (primary) { + HubFocus.Return -> returnFocus + HubFocus.Rejoin -> rejoinFocus + HubFocus.Host -> hostFocus + HubFocus.Join -> joinFocus + HubFocus.Retry -> retryFocus + } + val contentFocus = rememberTvContentInitialFocus(target = primaryTarget, contentKey = primary) + // The probe and the recent party land a moment apart, so Start can take + // focus just before Rejoin appears. While the viewer still sits on the + // old default, the new default takes over; once they move, it doesn't. + var focusedDefault by remember { mutableStateOf<HubFocus?>(null) } + var previousPrimary by remember { mutableStateOf(primary) } + LaunchedEffect(primary) { + if (focusedDefault != null && focusedDefault == previousPrimary && primary != previousPrimary) { + requestFocusUntilObserved( + maxAttempts = 10, + awaitAttempt = { delay(60) }, + requestFocus = primaryTarget::requestFocus, + isFocused = { focusedDefault == primary }, + ) + } + previousPrimary = primary + } + fun Modifier.defaultTarget(which: HubFocus, requester: FocusRequester): Modifier = this + .focusRequester(requester) + .onFocusChanged { + if (it.isFocused) focusedDefault = which else if (focusedDefault == which) focusedDefault = null + } + + Box(modifier = Modifier.fillMaxSize()) { + TvPartyBackdrop(url = null) + Row( + modifier = Modifier + .fillMaxSize() + .padding(start = 70.dp, end = 70.dp, top = 110.dp) + .then(contentFocus), + ) { + // ---- The pitch and whatever the viewer should know -------------- + Column(modifier = Modifier.width(380.dp), verticalArrangement = Arrangement.spacedBy(20.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + TvPartyEyebrow("Watch Party") + Text( + text = "Watch something\ntogether", + fontSize = TvPartyMetrics.heroTitle, + lineHeight = 54.sp, + fontWeight = FontWeight.Bold, + letterSpacing = (-0.8).sp, + color = PrairieOnSurface, + ) + Text( + text = "Pick a title, share the code, and everyone's playback stays in step.", + fontSize = TvPartyMetrics.body, + lineHeight = 21.sp, + color = PrairieSecondaryText, + ) + } + val ended = state.ended + if (showEnded && ended != null && current == null) { + TvPartyBanner(message = tvWatchPartyEndedMessage(ended.reason)) + } + val error = state.error + when { + error != null && !joinOpen -> TvPartyBanner(message = error, warning = true) + current != null -> Unit + availability == null -> TvPartyBanner(message = "Checking Watch Party support…") + availability is WatchPartyAvailability.Unsupported -> + TvPartyBanner(message = "This server doesn't support Watch Party yet.") + availability == WatchPartyAvailability.NotAllowed -> + TvPartyBanner(message = "Watch Party is not available for this profile on this server.") + availability is WatchPartyAvailability.ProbeFailed -> + TvPartyBanner(message = "Couldn't check whether this server supports Watch Party.", warning = true) + else -> Unit + } + } + Spacer(modifier = Modifier.weight(1f)) + + // ---- Host and Join -------------------------------------------- + Column(modifier = Modifier.width(320.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (current != null) { + TvPartyEyebrow("Your party") + TvPartyButton( + label = "Return to party", + icon = Icons.Filled.PlayArrow, + onClick = viewModel::returnToParty, + state = TvControlState.transient(!busy), + modifier = Modifier.defaultTarget(HubFocus.Return, returnFocus), + ) + Text( + text = "Party code ${tvWatchPartyDisplayCode(current.code)}", + fontSize = TvPartyMetrics.caption, + color = PrairieSecondaryText, + ) + } else { + TvPartyEyebrow("Host") + if (available && !canHost) { + Text( + text = "This server can't host a Watch Party. You can still join one.", + fontSize = TvPartyMetrics.caption, + color = PrairieSecondaryText, + ) + } else { + TvPartyButton( + label = if (state.busy == WatchPartyPendingAction.Create) "Starting your party…" else "Start a party", + icon = Icons.Filled.Add, + onClick = { viewModel.host() }, + state = entryState, + modifier = Modifier.defaultTarget(HubFocus.Host, hostFocus), + ) + TvPartyButton( + label = "Start a party and let everyone vote", + kind = TvPartyButtonKind.Secondary, + onClick = { viewModel.host(RoomSelectionMode.Vote) }, + state = entryState, + ) + } + if (recent != null) { + TvPartyButton( + label = if (state.busy == WatchPartyPendingAction.Join) { + "Joining…" + } else { + "Rejoin ${recent.title ?: "recent party"}" + }, + icon = Icons.Filled.Refresh, + kind = TvPartyButtonKind.Outlined, + onClick = viewModel::rejoinRecent, + state = entryState, + modifier = Modifier.defaultTarget(HubFocus.Rejoin, rejoinFocus), + ) + } + if (state.createRetryable) { + TvPartyButton( + label = "Try again", + kind = TvPartyButtonKind.Secondary, + onClick = viewModel::retryCreate, + state = entryState, + ) + Text( + text = "Finish creating the party you started.", + fontSize = TvPartyMetrics.caption, + color = PrairieSecondaryText, + ) + } + } + } + if (current == null) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + TvPartyEyebrow("Join") + TvPartyButton( + label = "Join with a code", + kind = TvPartyButtonKind.Secondary, + onClick = { + viewModel.clearError() + joinOpen = true + }, + state = entryState, + modifier = Modifier + .defaultTarget(HubFocus.Join, joinFocus) + .onFocusChanged { joinFocused = it.isFocused }, + ) + if (!available) { + TvPartyButton( + label = "Check again", + kind = TvPartyButtonKind.Secondary, + onClick = { viewModel.refresh(force = true) }, + state = TvControlState.transient(availability != null), + modifier = Modifier.defaultTarget(HubFocus.Retry, retryFocus), + ) + } + } + } + } + } + } + + if (joinOpen) { + TvJoinCodeDialog( + isBusy = state.busy == WatchPartyPendingAction.Join, + error = state.error, + onJoin = viewModel::join, + onDismiss = { + viewModel.clearError() + joinOpen = false + }, + ) + } + TvRestoreFocusOnModalDismiss( + visible = joinOpen, + opener = joinFocus, + isOpenerFocused = { joinFocused }, + ) +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchparty/TvWatchPartyLobbyScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchparty/TvWatchPartyLobbyScreen.kt new file mode 100644 index 0000000000..d2509875e7 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchparty/TvWatchPartyLobbyScreen.kt @@ -0,0 +1,517 @@ +package org.prairieserver.prairie.tv.ui.screens.watchparty + +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.tv.material3.Text +import kotlinx.coroutines.delay +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf +import org.prairieserver.prairie.common.ui.components.rememberProfileServerUrl +import org.prairieserver.prairie.model.watchtogether.ItemMemberState +import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.Suggestion +import org.prairieserver.prairie.repository.WatchTogetherRepository +import org.prairieserver.prairie.tv.ui.focus.TvControlState +import org.prairieserver.prairie.tv.ui.focus.TvRestoreFocusOnModalDismiss +import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved +import org.prairieserver.prairie.tv.ui.theme.PrairieOnSurface +import org.prairieserver.prairie.tv.ui.theme.PrairieSecondaryText +import org.prairieserver.prairie.viewmodel.WatchPartyItem +import org.prairieserver.prairie.viewmodel.WatchPartyLobbyViewModel +import org.prairieserver.prairie.watchtogether.WatchPartyDestination +import org.prairieserver.prairie.watchtogether.WatchPartyFeatures +import org.prairieserver.prairie.watchtogether.canRemoveSuggestion +import org.prairieserver.prairie.watchtogether.roomVoteWinner +import org.prairieserver.prairie.watchtogether.watchPartyInviteUrl +import org.prairieserver.prairie.common.cards.LocalEpisodeSpoilerPrefs + +/** + * The Watch Party lobby, laid out as the tvOS lobby ([TvWatchPartyRoomView]): + * the staged film full-bleed, the hero, the ballot or suggestions, seats, and + * the one foregrounded action. Settings, End, and Leave sit behind "···"; the + * code pill and the Invite seat open the invitation page. + * + * Only the room's phase opens the player ([WatchPartyLobbyViewModel.destination]). + * Every control is composed from role and room alone and gated transiently, + * and cards are keyed by suggestion id, so live snapshots and vote reordering + * never move or drop focus. If a focused card disappears (removed, promoted), + * focus goes to the nearest card that still has an action, else the primary + * action. Modals hand focus back to whatever opened them. + */ +@Composable +fun TvWatchPartyLobbyScreen( + roomId: String, + /** Opened because the host stopped playback. */ + hostStopped: Boolean, + onOpenPlayer: (WatchPartyDestination.Player) -> Unit, + onOpenDetail: (contentId: String) -> Unit, + onEnded: () -> Unit, + onLeft: () -> Unit, + onBack: () -> Unit, + viewModel: WatchPartyLobbyViewModel = koinViewModel( + key = "tv-watch-party-lobby-$roomId", + parameters = { parametersOf(roomId) }, + ), + repository: WatchTogetherRepository = koinInject(), +) { + val state by viewModel.state.collectAsState() + val destination by viewModel.destination.collectAsState() + val context = LocalContext.current + val latestOnOpenPlayer by rememberUpdatedState(onOpenPlayer) + val latestOnEnded by rememberUpdatedState(onEnded) + + LaunchedEffect(destination) { + (destination as? WatchPartyDestination.Player)?.let { latestOnOpenPlayer(it) } + } + LaunchedEffect(state.ended) { + val ended = state.ended ?: return@LaunchedEffect + if (ended.roomId == roomId) latestOnEnded() + } + // The membership can also vanish without an ended record (another screen + // left, the identity changed); the hub then offers what is possible. + var hadRoom by remember { mutableStateOf(false) } + val hasRoom = state.room?.roomId == roomId + LaunchedEffect(hasRoom) { + if (hasRoom) { + hadRoom = true + } else if (hadRoom && state.ended == null) { + latestOnEnded() + } + } + LaunchedEffect(viewModel) { + viewModel.messages.collect { message -> + if (message.isNotBlank()) Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + } + + /** "select" stages a pick, "suggest" adds a suggestion; null is closed. */ + var pickerPurpose by rememberSaveable { mutableStateOf<String?>(null) } + var optionsOpen by rememberSaveable { mutableStateOf(false) } + var inviteOpen by rememberSaveable { mutableStateOf(false) } + var confirmEnd by rememberSaveable { mutableStateOf(false) } + var confirmLeave by rememberSaveable { mutableStateOf(false) } + var menuSuggestionId by remember { mutableStateOf<String?>(null) } + val modalOpen = pickerPurpose != null || optionsOpen || inviteOpen || confirmEnd || confirmLeave || + menuSuggestionId != null + + BackHandler(enabled = !modalOpen) { onBack() } + + val room = state.room?.takeIf { it.roomId == roomId } + val eligibility = state.eligibility + val suggestions = state.suggestions + // Role comes from the snapshot, so the layout is right before the + // capability probe (which eligibility also needs) has answered. + val isHost = room?.let { it.selfRole == MemberRole.Host && it.selfCanManageRoom } == true + + val focus = rememberTvPartyFocus() + var lobbyHasFocus by remember { mutableStateOf(false) } + var lastFocusedSuggestion by remember { mutableStateOf<Int?>(null) } + val actionableIds = if (room == null) { + emptyList() + } else { + suggestions.filter { tvPartySuggestionFocusable(room, it) }.map { it.id } + } + val lifecycle = LocalLifecycleOwner.current.lifecycle + val latestActionableIds by rememberUpdatedState(actionableIds) + val latestSuggestionIds by rememberUpdatedState(suggestions.map { it.id }) + val anchorKey = room?.let { tvPartyAnchorKey(it, state.features, suggestions) } ?: PARTY_KEY_BACK + val latestAnchorKey by rememberUpdatedState(anchorKey) + + // Remember which card had focus, so a card that vanishes under focus + // hands it to its neighbour rather than the page. + val focusedKey = focus.focusedKey + LaunchedEffect(focusedKey) { + val key = focusedKey ?: return@LaunchedEffect + lastFocusedSuggestion = if (key.startsWith("s:")) { + latestSuggestionIds.indexOf(key.removePrefix("s:")).takeIf { it >= 0 } + } else { + null + } + } + + // One focus owner for first entry and for recovery: on entry, and + // whenever focus leaves the lobby without a modal taking it (a focused + // suggestion was removed, promoted, or reordered away; a button's action + // swapped it for another), land on the nearest suggestion that still has + // actions, else the anchor action. + LaunchedEffect(lobbyHasFocus, modalOpen, room != null) { + if (lobbyHasFocus || modalOpen) return@LaunchedEffect + delay(LOBBY_FOCUS_RESCUE_DELAY_MS) + if (!lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) return@LaunchedEffect + val target = lastFocusedSuggestion + ?.let { index -> nearestActionable(latestSuggestionIds, latestActionableIds, index) } + ?.let { id -> focus.requester(tvPartySuggestionKey(id)) } + ?: focus.requester(latestAnchorKey) + requestFocusUntilObserved( + maxAttempts = 20, + awaitAttempt = { delay(60) }, + requestFocus = target::requestFocus, + isFocused = { lobbyHasFocus }, + ) + } + + // Entry focus goes to the anchor (the primary action). The anchor can + // change a moment after entry (the capability probe answers, a pick + // lands); while the viewer still sits on the old one, focus follows. + var initialFocusClaimed by rememberSaveable { mutableStateOf(false) } + var previousAnchor by remember { mutableStateOf<String?>(null) } + LaunchedEffect(room != null, anchorKey, modalOpen) { + if (room == null || modalOpen) return@LaunchedEffect + val previous = previousAnchor + previousAnchor = anchorKey + val follow = previous != null && previous != anchorKey && focus.isFocused(previous) + if (initialFocusClaimed && !follow) return@LaunchedEffect + val target = focus.requester(anchorKey) + requestFocusUntilObserved( + maxAttempts = 20, + awaitAttempt = { delay(60) }, + requestFocus = target::requestFocus, + isFocused = { focus.isFocused(latestAnchorKey) }, + ) + initialFocusClaimed = true + } + + // Modals return focus to the control that opened them. + var opener by remember { mutableStateOf<String?>(null) } + fun openModal(open: () -> Unit) { + opener = focus.focusedKey + open() + } + TvRestoreFocusOnModalDismiss( + visible = modalOpen, + opener = opener?.let(focus::requester), + isOpenerFocused = { focus.isFocused(opener) }, + ) + + val serverUrl = rememberProfileServerUrl() + val inviteUrl = room?.takeIf { isHost }?.let { watchPartyInviteUrl(serverUrl, it.invitePath) } + val connection by repository.connectionState.collectAsState() + val reconnect = rememberTvWatchPartyReconnectNotice(connection = connection, inRoom = room != null) + var showStopped by remember { mutableStateOf(hostStopped) } + LaunchedEffect(showStopped) { + if (showStopped) { + delay(HOST_STOPPED_NOTICE_MS) + showStopped = false + } + } + val staged = rememberTvStagedPreview( + contentId = room?.selectedContentId, + fileId = room?.selectedFileId, + libraryId = room?.selectedLibraryId, + suggestions = suggestions, + refreshKey = room?.phase, + ) + val stagedId = room?.selectedContentId?.takeIf { it.isNotBlank() } + val memberState by produceState<ItemMemberState?>(initialValue = null, stagedId) { + value = null + stagedId?.let { value = viewModel.memberState(it) } + } + + Box( + modifier = Modifier + .fillMaxSize() + .onFocusChanged { lobbyHasFocus = it.hasFocus }, + ) { + if (room == null) { + ConnectingRoom(focus = focus, onBack = onBack) + } else { + TvWatchPartyRoomView( + room = room, + suggestions = suggestions, + features = state.features, + eligibility = eligibility, + personalVotesKnown = state.personalVotesKnown, + connection = connection, + staged = staged, + focus = focus, + notice = reconnect ?: "The host stopped playback".takeIf { showStopped && !isHost }, + memberStateLine = memberStateLine(room, memberState), + footer = state.suggestionRetry?.let { draft -> + { + SuggestionRetryRow( + title = draft.title, + onRetry = viewModel::retrySuggestion, + onDismiss = viewModel::dismissSuggestionRetry, + ) + } + }, + actions = TvPartyRoomActions( + onInvite = { openModal { inviteOpen = true } }, + onOptions = { openModal { optionsOpen = true } }, + onChooseTitle = { openModal { pickerPurpose = PICK_SELECT } }, + onSuggest = { openModal { pickerPurpose = PICK_SUGGEST } }, + onStart = { + if (room.selectionMode == RoomSelectionMode.Vote) { + roomVoteWinner(suggestions)?.let(viewModel::promote) + } else { + viewModel.start() + } + }, + onReady = viewModel::setLobbyReady, + onVote = viewModel::vote, + onQueue = { suggestion -> queue(viewModel, suggestion) }, + onSuggestionMenu = { suggestion -> + if (suggestionMenuRows(room, suggestion, state.features).isNotEmpty()) { + openModal { menuSuggestionId = suggestion.id } + } + }, + ), + ) + } + } + + val purpose = pickerPurpose + if (purpose != null && room != null) { + // Host-pick hosts stage their pick; everyone else suggests. + val stages = purpose == PICK_SELECT && state.features?.stagedSelection == true + TvWatchPartyPicker( + stages = stages, + members = room.members, + memberState = if (state.features?.memberState == true) viewModel::memberState else null, + onPick = { item -> + pickerPurpose = null + TvWatchPartyPreviews.put(item) + if (stages) viewModel.stage(item) else viewModel.suggest(item) + }, + onOpenSeries = { contentId -> + pickerPurpose = null + onOpenDetail(contentId) + }, + onDismiss = { pickerPurpose = null }, + ) + } + + if (optionsOpen && room != null) { + TvWatchPartyOptionsOverlay( + room = room, + features = state.features, + eligibility = eligibility, + onSetMode = viewModel::setMode, + onSetPolicy = viewModel::setGuestPolicy, + // The lobby is never playing; the player's panel owns this row. + onStopPlayback = { optionsOpen = false }, + onEnd = { + optionsOpen = false + confirmEnd = true + }, + onLeave = { + optionsOpen = false + if (isHost) { + confirmLeave = true + } else { + viewModel.leave() + onLeft() + } + }, + onDismiss = { optionsOpen = false }, + ) + } + + if (inviteOpen && room != null) { + TvWatchPartyInvitePage( + code = room.code, + inviteUrl = inviteUrl, + backdropUrl = staged?.backdropUrl, + backdropThumbhash = staged?.backdropThumbhash, + backdropHidden = staged?.spoilers?.hidesBackdrop(LocalEpisodeSpoilerPrefs.current, staged.backdropUrl) == true, + onDismiss = { inviteOpen = false }, + ) + } + + val menuSuggestion = suggestions.firstOrNull { it.id == menuSuggestionId } + LaunchedEffect(menuSuggestionId, menuSuggestion == null) { + // The suggestion went away (removed, promoted) while its menu was open. + if (menuSuggestionId != null && menuSuggestion == null) menuSuggestionId = null + } + if (menuSuggestion != null && room != null) { + TvPartyMenuOverlay( + title = menuSuggestion.title, + onDismiss = { menuSuggestionId = null }, + width = 380.dp, + ) { + suggestionMenuRows(room, menuSuggestion, state.features).forEach { row -> + TvPartyOptionRow( + title = row.title, + destructive = row == SuggestionMenuRow.Remove, + state = TvControlState.transient(!eligibility.busy), + onClick = { + menuSuggestionId = null + when (row) { + SuggestionMenuRow.StartThisOne -> viewModel.promote(menuSuggestion) + SuggestionMenuRow.Queue -> queue(viewModel, menuSuggestion) + SuggestionMenuRow.Remove -> viewModel.remove(menuSuggestion) + } + }, + ) + } + } + } + + if (confirmEnd) { + TvWatchPartyConfirmDialog( + title = "End this party for everyone?", + message = "Everyone leaves the party.", + confirmLabel = "End party", + destructive = true, + onConfirm = { + confirmEnd = false + viewModel.endForEveryone() + onLeft() + }, + onDismiss = { confirmEnd = false }, + ) + } + if (confirmLeave) { + TvWatchPartyConfirmDialog( + title = "Leave the party?", + message = TV_WATCH_PARTY_HOST_LEAVE_NOTE, + confirmLabel = "Leave party", + destructive = true, + onConfirm = { + confirmLeave = false + viewModel.leave() + onLeft() + }, + onDismiss = { confirmLeave = false }, + ) + } +} + +private const val PICK_SELECT = "select" +private const val PICK_SUGGEST = "suggest" +internal const val PARTY_KEY_BACK = "back" + +private fun queue(viewModel: WatchPartyLobbyViewModel, suggestion: Suggestion) { + TvWatchPartyPreviews.put( + WatchPartyItem( + contentId = suggestion.contentId, + contentType = suggestion.contentType, + title = suggestion.title, + subtitle = suggestion.subtitle.ifBlank { null }, + posterUrl = suggestion.posterUrl.ifBlank { null }, + ), + ) + viewModel.queue(suggestion) +} + +private enum class SuggestionMenuRow(val title: String) { + StartThisOne("Start this one"), + Queue("Queue"), + Remove("Remove suggestion"), +} + +/** A suggestion card's context actions for this member. */ +private fun suggestionMenuRows(room: RoomSnapshot, suggestion: Suggestion, features: WatchPartyFeatures?): List<SuggestionMenuRow> { + val manages = room.selfRole == MemberRole.Host && room.selfCanManageRoom + return buildList { + if (manages && room.selectionMode == RoomSelectionMode.Vote && features?.voteHostOverride == true) { + add(SuggestionMenuRow.StartThisOne) + } + // Queueing stages the pick, which only servers with staged selection accept. + if (manages && room.selectionMode == RoomSelectionMode.HostPick && features?.stagedSelection == true && + suggestion.contentId != room.selectedContentId + ) { + add(SuggestionMenuRow.Queue) + } + if (canRemoveSuggestion(room, suggestion)) add(SuggestionMenuRow.Remove) + } +} + +/** The actionable suggestion nearest [index] in the current list, preferring the one now in its place. */ +private fun nearestActionable(ids: List<String>, actionable: List<String>, index: Int): String? { + if (actionable.isEmpty()) return null + val set = actionable.toSet() + for (offset in 0..ids.size) { + ids.getOrNull(index + offset)?.takeIf { it in set }?.let { return it } + ids.getOrNull(index - offset)?.takeIf { it in set }?.let { return it } + } + return null +} + +/** "Seen by Ana · Part way: Ben" for the staged item, from member state (omitted members are unknown, not unplayed). */ +private fun memberStateLine(room: RoomSnapshot?, state: ItemMemberState?): String? { + if (room == null || state == null) return null + fun nameOf(userId: String, profileId: String): String? = room.members + .firstOrNull { it.userId == userId && it.profileId == profileId } + ?.let(::tvWatchPartyMemberName) + val seen = state.members.filter { it.state == "watched" }.mapNotNull { nameOf(it.userId, it.profileId) } + val partWay = state.members + .filter { it.state != "watched" && (it.positionSeconds ?: 0.0) > 0.0 } + .mapNotNull { nameOf(it.userId, it.profileId) } + val parts = buildList { + if (seen.isNotEmpty()) add("Seen by ${tvWatchPartyNameList(seen)}") + if (partWay.isNotEmpty()) add("Part way: ${tvWatchPartyNameList(partWay)}") + } + return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ") +} + +/** Before the first snapshot: say so, and keep a focus owner so Back and Select work. */ +@Composable +private fun ConnectingRoom(focus: TvPartyFocus, onBack: () -> Unit) { + Box(modifier = Modifier.fillMaxSize()) { + TvPartyBackdrop(url = null) + Column( + modifier = Modifier + .align(Alignment.CenterStart) + .padding(horizontal = TvPartyMetrics.pageInsetX), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + TvPartyEyebrow("Watch Party") + Text(text = "Connecting to the party…", fontSize = 30.sp, fontWeight = FontWeight.Bold, color = PrairieOnSurface) + TvPartyButton( + label = "Back", + kind = TvPartyButtonKind.Secondary, + onClick = onBack, + modifier = Modifier.partyFocus(focus, PARTY_KEY_BACK), + ) + } + } +} + +@Composable +private fun SuggestionRetryRow(title: String, onRetry: () -> Unit, onDismiss: () -> Unit) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Couldn't confirm your suggestion of $title.", + fontSize = TvPartyMetrics.caption, + color = PrairieSecondaryText, + modifier = Modifier.weight(1f, fill = false), + ) + TvPartyButton(label = "Try again", kind = TvPartyButtonKind.Secondary, onClick = onRetry, height = 32.dp, fontSize = 14.sp) + TvPartyButton(label = "Dismiss", kind = TvPartyButtonKind.Secondary, onClick = onDismiss, height = 32.dp, fontSize = 14.sp) + } +} + +private const val LOBBY_FOCUS_RESCUE_DELAY_MS = 150L +private const val HOST_STOPPED_NOTICE_MS = 6_000L diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchparty/TvWatchPartyPicker.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchparty/TvWatchPartyPicker.kt new file mode 100644 index 0000000000..06400d236e --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchparty/TvWatchPartyPicker.kt @@ -0,0 +1,605 @@ +package org.prairieserver.prairie.tv.ui.screens.watchparty + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +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.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import androidx.tv.material3.Border +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Icon +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import kotlinx.coroutines.delay +import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.common.ui.components.DefaultArtworkKind +import org.prairieserver.prairie.model.catalog.BrowseItem +import org.prairieserver.prairie.model.section.SectionItem +import org.prairieserver.prairie.model.watchtogether.ItemMemberState +import org.prairieserver.prairie.model.watchtogether.MemberWatchState +import org.prairieserver.prairie.model.watchtogether.PickerEntry +import org.prairieserver.prairie.model.watchtogether.RoomMember +import org.prairieserver.prairie.tv.ui.components.TvMediaRow +import org.prairieserver.prairie.tv.ui.components.TvPoster +import org.prairieserver.prairie.tv.ui.components.TvTextInputDialog +import org.prairieserver.prairie.tv.ui.components.rememberTvDialogInitialFocus +import org.prairieserver.prairie.tv.ui.focus.TvRestoreFocusOnModalDismiss +import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved +import org.prairieserver.prairie.tv.ui.focus.tvModalFocusBoundary +import org.prairieserver.prairie.tv.ui.theme.DarkBackground +import org.prairieserver.prairie.tv.ui.theme.FocusedContainer +import org.prairieserver.prairie.tv.ui.theme.FocusedContent +import org.prairieserver.prairie.tv.ui.theme.PrairieOnSurface +import org.prairieserver.prairie.tv.ui.theme.PrairieSecondaryText +import org.prairieserver.prairie.tv.ui.theme.TvSkyline +import org.prairieserver.prairie.viewmodel.WatchPartyItem +import org.prairieserver.prairie.viewmodel.WatchPartyPickerViewModel +import org.prairieserver.prairie.viewmodel.WatchPartySpoilers +import org.prairieserver.prairie.common.cards.LocalEpisodeSpoilerPrefs + +/** One Home-style shelf of the picker. */ +private data class TvPickerShelf( + val key: String, + val title: String, + val entries: List<Pair<BrowseItem, PickerEntry?>>, + val showProgress: Boolean = false, +) { + val items: List<SectionItem> = entries.map { (item, entry) -> item.toSectionItem(entry) } + + /** The items carry the room's progress; spoiler protection uses this profile's own. */ + val unwatchedById: Map<String, Boolean> = + entries.associate { (item, _) -> item.contentId to WatchPartySpoilers.of(item).unwatchedEpisode } +} + +/** What the confirmation page shows, and the item it stages or suggests. */ +private data class TvPickerChoice( + val item: WatchPartyItem, + val title: String, + val subtitle: String?, + val posterUrl: String?, + val posterThumbhash: String?, + val backdropUrl: String?, + val backdropThumbhash: String?, + val overview: String?, + val facts: List<String>, + val spoilers: WatchPartySpoilers = item.spoilers, +) + +/** + * Choose or suggest a title, as the tvOS picker does it: a full-screen page of + * Home-style shelves (Continue Together, On Your Watchlists, or search + * results) under a slim strip with the page name, Search, and Close. Picking a + * movie or episode opens a confirmation page (artwork, overview, "Who's seen + * it") whose primary action stages or suggests it. A series with a party + * next-up episode confirms that episode; any other series opens its normal + * page, where the party action applies. + * + * Focus: the first card of the first shelf, else Search. If the focused card + * disappears (a newer search), focus returns to Search instead of dropping. + */ +@Composable +internal fun TvWatchPartyPicker( + /** Host Picks host: the pick is staged. Otherwise it is suggested. */ + stages: Boolean, + members: List<RoomMember>, + /** Who in the room has watched a title; null when the server can't say. */ + memberState: (suspend (String) -> ItemMemberState?)?, + onPick: (WatchPartyItem) -> Unit, + onOpenSeries: (contentId: String) -> Unit, + onDismiss: () -> Unit, + viewModel: WatchPartyPickerViewModel = koinViewModel(key = "tv-watch-party-picker"), +) { + val state by viewModel.state.collectAsState() + var searchOpen by remember { mutableStateOf(false) } + var choice by remember { mutableStateOf<TvPickerChoice?>(null) } + var choiceShelf by remember { mutableStateOf<String?>(null) } + var searchFocused by remember { mutableStateOf(false) } + var pickerHasFocus by remember { mutableStateOf(false) } + val searchFocus = remember { FocusRequester() } + val firstCardFocus = remember { FocusRequester() } + val shelfFocus = remember { mutableStateMapOf<String, Boolean>() } + val shelfRequesters = remember { mutableMapOf<String, FocusRequester>() } + + LaunchedEffect(viewModel) { viewModel.loadRows() } + + val searching = state.query.isNotBlank() + val shelves: List<TvPickerShelf> = if (searching) { + listOf(TvPickerShelf("results", "Results for “${state.query.trim()}”", state.results.map { it to null })) + } else { + listOf( + TvPickerShelf( + "together", + "Continue Together", + state.rows?.continueTogether.orEmpty().map { it.item to it }, + showProgress = true, + ), + TvPickerShelf("watchlist", "On Your Watchlists", state.rows?.watchlistUnion.orEmpty().map { it.item to it }), + ) + }.filter { it.entries.isNotEmpty() } + val shelfKeys = shelves.joinToString { it.key } + + val open: (TvPickerShelf, String) -> Unit = { shelf, contentId -> + val (item, entry) = shelf.entries.first { it.first.contentId == contentId } + val nextUp = entry?.nextUp + when { + item.type == "series" && nextUp != null -> { + choiceShelf = shelf.key + choice = TvPickerChoice( + item = WatchPartyItem( + contentId = nextUp.contentId, + contentType = "episode", + title = nextUp.title.ifBlank { "Episode ${nextUp.episodeNumber}" }, + subtitle = "${item.title} · S${nextUp.seasonNumber}E${nextUp.episodeNumber}", + posterUrl = item.posterUrl, + // Series artwork; the episode's own watch state isn't known here. + spoilers = WatchPartySpoilers(unwatchedEpisode = true, posterIsEpisodeStill = false, backdropIsEpisodeStill = false), + ), + title = item.title, + subtitle = "S${nextUp.seasonNumber} · E${nextUp.episodeNumber}" + + nextUp.title.takeIf { it.isNotBlank() }?.let { " · $it" }.orEmpty(), + posterUrl = item.posterUrl, + posterThumbhash = item.posterThumbhash, + backdropUrl = item.backdropUrl, + backdropThumbhash = item.backdropThumbhash, + overview = null, + facts = emptyList(), + ) + } + item.type == "series" -> onOpenSeries(item.contentId) + else -> { + choiceShelf = shelf.key + choice = TvPickerChoice( + item = WatchPartyItem( + contentId = item.contentId, + contentType = item.type, + title = item.title, + subtitle = item.year.takeIf { it > 0 }?.toString(), + posterUrl = item.posterUrl, + spoilers = WatchPartySpoilers.of(item), + ), + title = item.title, + subtitle = null, + posterUrl = item.posterUrl, + posterThumbhash = item.posterThumbhash, + backdropUrl = item.backdropUrl, + backdropThumbhash = item.backdropThumbhash, + overview = item.overview?.takeIf { it.isNotBlank() }, + facts = listOfNotNull( + item.year.takeIf { it > 0 }?.toString(), + item.runtime?.takeIf { it > 0 }?.let(::tvWatchPartyRuntime), + item.contentRating?.takeIf { it.isNotBlank() }, + item.genres.firstOrNull(), + ), + ) + } + } + } + + // A card that vanished under focus (a newer search, reloaded rows) would + // otherwise leave the picker with no focus owner. + LaunchedEffect(pickerHasFocus, searchOpen, choice != null) { + if (pickerHasFocus || searchOpen || choice != null) return@LaunchedEffect + delay(PICKER_FOCUS_RESCUE_DELAY_MS) + requestFocusUntilObserved( + maxAttempts = 20, + awaitAttempt = { delay(60) }, + requestFocus = searchFocus::requestFocus, + isFocused = { pickerHasFocus }, + ) + } + // Shelves arrive after the page opens: the first card claims focus from + // Search, as Home's first row does, unless the viewer already moved on. + LaunchedEffect(shelfKeys) { + if (shelves.isEmpty() || !searchFocused || searching) return@LaunchedEffect + requestFocusUntilObserved( + maxAttempts = 10, + awaitAttempt = { delay(60) }, + requestFocus = firstCardFocus::requestFocus, + isFocused = { shelfFocus[shelves.first().key] == true }, + ) + } + + Popup( + alignment = Alignment.Center, + onDismissRequest = onDismiss, + properties = PopupProperties( + focusable = true, + dismissOnBackPress = true, + dismissOnClickOutside = false, + clippingEnabled = false, + ), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .background(DarkBackground) + .onFocusChanged { pickerHasFocus = it.hasFocus } + .tvModalFocusBoundary() + .then(rememberTvDialogInitialFocus(if (shelves.isEmpty()) searchFocus else firstCardFocus)), + ) { + // ---- Strip: page name, search, close ---------------------------- + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = TvSkyline.safeAreaX, end = TvSkyline.safeAreaX, top = TvSkyline.barTopInset) + .height(TvSkyline.barHeight), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TvSkyline.barTrailingSpacing), + ) { + Text( + text = if (stages) "Choose a title" else "Suggest a title", + fontSize = TvSkyline.tabLabelSize, + fontWeight = FontWeight.SemiBold, + color = Color.White, + ) + Spacer(modifier = Modifier.weight(1f)) + if (searching) { + TvPartyButton( + label = "Clear search", + kind = TvPartyButtonKind.Secondary, + onClick = { viewModel.onQuery("") }, + height = TvSkyline.barIconSize, + fontSize = 14.sp, + ) + } + ChromeIcon( + icon = Icons.Filled.Search, + label = "Search", + onClick = { searchOpen = true }, + modifier = Modifier + .focusRequester(searchFocus) + .onFocusChanged { searchFocused = it.isFocused }, + ) + ChromeIcon(icon = Icons.Filled.Close, label = "Close", onClick = onDismiss) + } + + // ---- Shelves ----------------------------------------------------- + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(top = 12.dp, bottom = 24.dp), + ) { + val status = when { + searching && state.error != null && shelves.isEmpty() -> state.error + searching && state.searching && shelves.isEmpty() -> "Searching…" + searching && shelves.isEmpty() -> "No titles match “${state.query.trim()}”." + !searching && state.rowsLoading && shelves.isEmpty() -> "Loading…" + else -> null + } + if (status != null) { + PickerStatus(status) + } else if (!searching && shelves.isEmpty()) { + Column( + modifier = Modifier.padding(horizontal = TvSkyline.safeAreaX, vertical = 40.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(text = "Nothing to pick from yet", fontSize = 22.sp, fontWeight = FontWeight.SemiBold, color = PrairieOnSurface) + Text( + text = "Search for a title, or add something to a watchlist.", + fontSize = TvPartyMetrics.body, + color = PrairieSecondaryText, + ) + state.error?.let { message -> + TvPartyBanner(message = message, warning = true) + TvPartyButton( + label = "Try again", + kind = TvPartyButtonKind.Secondary, + onClick = viewModel::loadRows, + ) + } + } + } + shelves.forEachIndexed { index, shelf -> + TvMediaRow( + title = shelf.title, + items = shelf.items, + onItemClick = { contentId -> open(shelf, contentId) }, + showProgress = shelf.showProgress, + unwatchedById = shelf.unwatchedById, + startPadding = TvSkyline.safeAreaX, + endPadding = TvSkyline.safeAreaX, + rowTopPadding = 10.dp, + rowBottomPadding = 16.dp, + firstItemFocusRequester = if (index == 0) firstCardFocus else null, + rowContainerFocusRequester = shelfRequesters.getOrPut(shelf.key) { FocusRequester() }, + onRowFocusChanged = { shelfFocus[shelf.key] = it }, + ) + } + } + } + } + + if (searchOpen) { + TvTextInputDialog( + title = "Search titles", + label = "Title", + confirmLabel = "Search", + initialValue = state.query, + allowBlank = true, + onConfirm = { text -> + viewModel.onQuery(text) + searchOpen = false + }, + onDismiss = { searchOpen = false }, + ) + } + TvRestoreFocusOnModalDismiss( + visible = searchOpen, + opener = searchFocus, + isOpenerFocused = { searchFocused }, + ) + + choice?.let { current -> + TvPickerConfirmation( + choice = current, + stages = stages, + members = members, + memberState = memberState, + onConfirm = { + choice = null + onPick(current.item) + }, + onDismiss = { choice = null }, + ) + } + // Back from the confirmation returns to the shelf it came from; the row's + // focus restorer puts focus back on the card. + TvRestoreFocusOnModalDismiss( + visible = choice != null, + opener = choiceShelf?.let { shelfRequesters[it] }, + isOpenerFocused = { choiceShelf?.let { shelfFocus[it] } == true }, + ) +} + +/** + * The confirmation page: the title's artwork and facts, what everyone in the + * room has seen, and one action. Back returns to the shelves. + */ +@Composable +private fun TvPickerConfirmation( + choice: TvPickerChoice, + stages: Boolean, + members: List<RoomMember>, + memberState: (suspend (String) -> ItemMemberState?)?, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + val confirmFocus = remember { FocusRequester() } + val spoilerPrefs = LocalEpisodeSpoilerPrefs.current + val contentId = choice.item.contentId + val seen by produceState<MemberStateLoad>(initialValue = MemberStateLoad.Loading, contentId) { + value = MemberStateLoad.Loading + value = memberState?.invoke(contentId)?.let(MemberStateLoad::Loaded) ?: MemberStateLoad.Unavailable + } + Popup( + alignment = Alignment.Center, + onDismissRequest = onDismiss, + properties = PopupProperties( + focusable = true, + dismissOnBackPress = true, + dismissOnClickOutside = false, + clippingEnabled = false, + ), + ) { + Box(modifier = Modifier.fillMaxSize()) { + TvPartyBackdrop( + url = choice.backdropUrl ?: choice.posterUrl, + thumbhash = if (choice.backdropUrl != null) choice.backdropThumbhash else choice.posterThumbhash, + isPoster = choice.backdropUrl == null, + hidden = choice.spoilers.hidesBackdrop(spoilerPrefs, choice.backdropUrl), + ) + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = TvPartyMetrics.pageInsetX, vertical = 44.dp) + .tvModalFocusBoundary() + .then(rememberTvDialogInitialFocus(confirmFocus)), + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(16.dp)) { + TvPoster( + imageUrl = choice.posterUrl, + contentDescription = null, + modifier = Modifier.size(width = 110.dp, height = 165.dp), + defaultArtwork = DefaultArtworkKind.forItemType(choice.item.contentType), + hidden = choice.spoilers.hidesPoster(spoilerPrefs), + ) + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + TvPartyEyebrow(if (stages) "Watch together" else "Suggest to the party") + Text( + text = choice.title, + fontSize = 44.sp, + lineHeight = 48.sp, + fontWeight = FontWeight.Bold, + letterSpacing = (-0.5).sp, + color = PrairieOnSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + val facts = listOfNotNull(choice.subtitle) + choice.facts + if (facts.isNotEmpty()) { + Text(text = facts.joinToString(" · "), fontSize = TvPartyMetrics.body, color = PrairieSecondaryText) + } + } + } + choice.overview?.takeUnless { choice.spoilers.hidesOverview(spoilerPrefs) }?.let { overview -> + Text( + text = overview, + fontSize = TvPartyMetrics.body, + lineHeight = 21.sp, + color = PrairieSecondaryText, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 560.dp), + ) + } + TvPartyButton( + label = if (stages) "Choose for the party" else "Add suggestion", + icon = if (stages) Icons.Filled.Check else Icons.Filled.Add, + onClick = onConfirm, + modifier = Modifier.focusRequester(confirmFocus), + ) + when (val load = seen) { + MemberStateLoad.Unavailable -> Unit + MemberStateLoad.Loading -> if (memberState != null) { + WhoSeenSection { + Text(text = "Checking viewing history…", fontSize = TvPartyMetrics.caption, color = PrairieSecondaryText) + } + } + is MemberStateLoad.Loaded -> WhoSeenSection { + members.forEach { member -> + val watch = load.state.members.firstOrNull { + it.userId == member.userId && it.profileId == member.profileId + } + Row(modifier = Modifier.fillMaxWidth()) { + Text( + text = tvWatchPartyMemberName(member), + fontSize = TvPartyMetrics.body, + color = PrairieOnSurface, + modifier = Modifier.weight(1f), + ) + Text(text = tvWatchPartyHistoryLabel(watch), fontSize = TvPartyMetrics.body, color = PrairieSecondaryText) + } + } + } + } + } + } + } +} + +private sealed interface MemberStateLoad { + data object Loading : MemberStateLoad + data object Unavailable : MemberStateLoad + data class Loaded(val state: ItemMemberState) : MemberStateLoad +} + +@Composable +private fun WhoSeenSection(content: @Composable () -> Unit) { + Column(modifier = Modifier.width(420.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + TvPartyEyebrow("Who's seen it") + content() + } +} + +/** "Watched", "In progress · Watchlist", "No viewing history". */ +internal fun tvWatchPartyHistoryLabel(state: MemberWatchState?): String { + state ?: return "No viewing history" + val label = when { + state.state in setOf("watched", "completed", "played") -> "Watched" + state.state in setOf("in_progress", "inprogress") || (state.positionSeconds ?: 0.0) > 0.0 -> "In progress" + else -> "Not watched" + } + return if (state.onWatchlist) "$label · Watchlist" else label +} + +/** Capsule icon in the strip, the same geometry as the app's top bar. */ +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun ChromeIcon(icon: ImageVector, label: String, onClick: () -> Unit, modifier: Modifier = Modifier) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val shape = RoundedCornerShape(100.dp) + Surface( + onClick = onClick, + interactionSource = interactionSource, + shape = ClickableSurfaceDefaults.shape(shape = shape), + colors = ClickableSurfaceDefaults.colors( + containerColor = Color.Transparent, + focusedContainerColor = FocusedContainer, + pressedContainerColor = FocusedContainer, + ), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f), + border = ClickableSurfaceDefaults.border( + focusedBorder = Border(BorderStroke(0.dp, Color.Transparent), shape = shape), + ), + modifier = modifier.size(TvSkyline.barIconSize), + ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Icon( + imageVector = icon, + contentDescription = label, + tint = if (isFocused) FocusedContent else Color.White.copy(alpha = 0.62f), + modifier = Modifier.size(15.dp), + ) + } + } +} + +@Composable +private fun PickerStatus(text: String) { + Text( + text = text, + fontSize = TvPartyMetrics.body, + color = PrairieSecondaryText, + modifier = Modifier.padding(horizontal = TvSkyline.safeAreaX, vertical = 24.dp), + ) +} + +private fun BrowseItem.toSectionItem(entry: PickerEntry?): SectionItem = SectionItem( + contentId = contentId, + type = type, + title = title, + year = year, + genres = genres, + contentRating = contentRating, + runtime = runtime, + overview = overview, + posterUrl = posterUrl, + posterThumbhash = posterThumbhash, + backdropUrl = backdropUrl, + backdropThumbhash = backdropThumbhash, + posterIsEpisodeStill = posterIsEpisodeStill, + backdropIsEpisodeStill = backdropIsEpisodeStill, + userState = userState, + overlaySummary = overlaySummary, + positionSeconds = entry?.members?.mapNotNull { it.positionSeconds }?.maxOrNull(), + durationSeconds = entry?.members?.mapNotNull { it.durationSeconds }?.maxOrNull(), +) + +private const val PICKER_FOCUS_RESCUE_DELAY_MS = 150L diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchparty/TvWatchPartyRoom.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchparty/TvWatchPartyRoom.kt new file mode 100644 index 0000000000..a21447ab7f --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchparty/TvWatchPartyRoom.kt @@ -0,0 +1,977 @@ +package org.prairieserver.prairie.tv.ui.screens.watchparty + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import androidx.tv.material3.Border +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Glow +import androidx.tv.material3.Icon +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import org.prairieserver.prairie.common.ui.components.DefaultArtworkKind +import org.prairieserver.prairie.model.watchtogether.GuestControlPolicy +import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.Suggestion +import org.prairieserver.prairie.repository.WatchTogetherConnectionState +import org.prairieserver.prairie.tv.ui.components.TvPoster +import org.prairieserver.prairie.tv.ui.components.rememberTvDialogInitialFocus +import org.prairieserver.prairie.tv.ui.focus.TvControlState +import org.prairieserver.prairie.tv.ui.focus.tvControlSemantics +import org.prairieserver.prairie.tv.ui.focus.tvModalFocusBoundary +import org.prairieserver.prairie.tv.ui.screens.auth.QrCodePanel +import org.prairieserver.prairie.tv.ui.theme.PrairieOnSurface +import org.prairieserver.prairie.tv.ui.theme.PrairieSecondaryText +import org.prairieserver.prairie.watchtogether.WatchPartyEligibility +import org.prairieserver.prairie.watchtogether.WatchPartyFeatures +import org.prairieserver.prairie.watchtogether.WatchPartyLobbyHero +import org.prairieserver.prairie.watchtogether.WatchPartyPrimaryAction +import org.prairieserver.prairie.watchtogether.WatchPartySecondaryAction +import org.prairieserver.prairie.watchtogether.canRemoveSuggestion +import org.prairieserver.prairie.watchtogether.roomVoteWinner +import org.prairieserver.prairie.watchtogether.watchPartyConnectionLabel +import org.prairieserver.prairie.watchtogether.watchPartyLobbyBanner +import org.prairieserver.prairie.watchtogether.watchPartyLobbyHero +import org.prairieserver.prairie.watchtogether.watchPartyLobbyHint +import org.prairieserver.prairie.watchtogether.watchPartyPrimaryAction +import org.prairieserver.prairie.watchtogether.watchPartySecondaryAction +import org.prairieserver.prairie.common.cards.LocalEpisodeSpoilerPrefs +import org.prairieserver.prairie.viewmodel.WatchPartySpoilers + +/** What a room screen can do. The lobby and the in-player panel each supply their own. */ +internal class TvPartyRoomActions( + val onInvite: () -> Unit, + val onOptions: () -> Unit, + val onChooseTitle: () -> Unit = {}, + val onSuggest: () -> Unit = {}, + /** Host-pick: start the staged title. Voting: start the leader. */ + val onStart: () -> Unit = {}, + val onReady: (Boolean) -> Unit = {}, + val onReturnToPlayback: () -> Unit = {}, + val onVote: (Suggestion) -> Unit = {}, + val onQueue: (Suggestion) -> Unit = {}, + /** "Start this one", "Remove suggestion": long-press, Menu, or Select on a card with no other action. */ + val onSuggestionMenu: (Suggestion) -> Unit = {}, +) + +internal const val PARTY_KEY_PRIMARY = "primary" +internal const val PARTY_KEY_SECONDARY = "secondary" +internal const val PARTY_KEY_OPTIONS = "options" +internal const val PARTY_KEY_CODE = "code" +internal const val PARTY_KEY_INVITE_SEAT = "invite-seat" +internal const val PARTY_KEY_SUGGEST_CARD = "suggest-card" + +/** The focus key of a suggestion card. */ +internal fun tvPartySuggestionKey(id: String) = "s:$id" + +/** The control that owns focus on entry and when focus is lost: the primary action, else the next best. */ +internal fun tvPartyAnchorKey(room: RoomSnapshot, features: WatchPartyFeatures?, suggestions: List<Suggestion>): String { + val primary = watchPartyPrimaryAction(room, features, suggestions) + return when { + primary != WatchPartyPrimaryAction.None -> PARTY_KEY_PRIMARY + watchPartySecondaryAction(room, primary) != null -> PARTY_KEY_SECONDARY + else -> PARTY_KEY_OPTIONS + } +} + +/** Whether a suggestion card is shown and has something to do for this member. */ +internal fun tvPartySuggestionFocusable(room: RoomSnapshot, suggestion: Suggestion): Boolean = when { + room.phase != RoomPhase.Lobby -> false + room.selectionMode == RoomSelectionMode.Vote -> true + room.selectionMode == RoomSelectionMode.HostPick -> + room.isManagedBySelf || canRemoveSuggestion(room, suggestion) + else -> false +} + +private val RoomSnapshot.isManagedBySelf: Boolean + get() = selfRole == MemberRole.Host && selfCanManageRoom + +/** + * The room, laid out as the tvOS lobby: the staged film fills the screen, the + * hero names it, members sit as seats along the bottom, and the one action + * that matters now is the white pill. Voting rooms put the ballot where the + * overview would be. Play/pause on the remote starts when Start is showing. + * + * Which buttons exist comes from the shared lobby policy, never from a + * request in flight: busy gating dims a control but keeps it (and focus) put. + */ +@Composable +internal fun TvWatchPartyRoomView( + room: RoomSnapshot, + suggestions: List<Suggestion>, + features: WatchPartyFeatures?, + eligibility: WatchPartyEligibility, + personalVotesKnown: Boolean, + connection: WatchTogetherConnectionState, + staged: TvStagedPreview?, + focus: TvPartyFocus, + actions: TvPartyRoomActions, + modifier: Modifier = Modifier, + /** A neutral notice (reconnecting, the host stopped playback) when no warning outranks it. */ + notice: String? = null, + /** "Seen by Ana" for the staged title. */ + memberStateLine: String? = null, + /** Extra content under the banner (the suggestion retry row). */ + footer: (@Composable () -> Unit)? = null, +) { + val connected = connection.writable + val reconnecting = !connected && connection.epoch != 0L + val (tone, connectionText) = watchPartyConnectionLabel(room, connected, reconnecting) + val primary = watchPartyPrimaryAction(room, features, suggestions) + val secondary = watchPartySecondaryAction(room, primary) + val hero = watchPartyLobbyHero(room, suggestions) + val hint = watchPartyLobbyHint(room, suggestions) + val voting = room.selectionMode == RoomSelectionMode.Vote && room.phase == RoomPhase.Lobby + val winner = if (room.selectionMode == RoomSelectionMode.Vote) roomVoteWinner(suggestions) else null + val warning = watchPartyLobbyBanner(room, selectionUnavailable = false, reconnecting = false) + val startState = TvControlState.transient( + connected && if (room.selectionMode == RoomSelectionMode.Vote) eligibility.canPromote else eligibility.canStart, + ) + + // The staged film's backdrop, else its poster blurred, else (voting) the leader's poster. + val leader = if (voting) (winner ?: suggestions.firstOrNull())?.takeIf { it.posterUrl.isNotBlank() } else null + val backdropUrl = staged?.backdropUrl ?: staged?.posterUrl ?: leader?.posterUrl + val backdropIsPoster = staged?.backdropUrl == null + val spoilerPrefs = LocalEpisodeSpoilerPrefs.current + val backdropHidden = if (staged != null && (staged.backdropUrl != null || staged.posterUrl != null)) { + staged.spoilers.hidesBackdrop(spoilerPrefs, staged.backdropUrl) + } else { + leader?.let { WatchPartySpoilers.of(it).hidesPoster(spoilerPrefs) } ?: false + } + + Box( + modifier = modifier + .fillMaxSize() + .onPreviewKeyEvent { event -> + // Same predicate as the Start button, so the remote cannot start what the button refuses. + val playKey = event.key == Key.MediaPlayPause || event.key == Key.MediaPlay + if (playKey && event.type == KeyEventType.KeyDown && primary is WatchPartyPrimaryAction.Start) { + startState.perform(actions.onStart) + true + } else { + false + } + }, + ) { + TvPartyBackdrop( + url = backdropUrl, + thumbhash = if (backdropIsPoster) staged?.posterThumbhash else staged?.backdropThumbhash, + isPoster = backdropIsPoster, + hidden = backdropHidden, + ) + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = TvPartyMetrics.pageInsetX), + ) { + // ---- Header ------------------------------------------------------ + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = if (voting) 30.dp else TvPartyMetrics.headerTop), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = "Watch Party", fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = PrairieOnSurface) + Spacer(modifier = Modifier.width(10.dp)) + TvPartyConnectionDot(tone = tone, text = connectionText) + Spacer(modifier = Modifier.weight(1f)) + TvPartyCodePill( + code = room.code, + onClick = actions.onInvite, + modifier = Modifier.partyFocus(focus, PARTY_KEY_CODE), + ) + Spacer(modifier = Modifier.width(11.dp)) + TvPartyOptionsButton( + onClick = actions.onOptions, + modifier = Modifier.partyFocus(focus, PARTY_KEY_OPTIONS), + ) + } + + // ---- Hero -------------------------------------------------------- + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .clipToBounds(), + ) { + if (voting) { + Column( + modifier = Modifier.padding(top = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + HeroText(room = room, hero = hero, staged = staged, titleSize = 34.sp, memberStateLine = null) + TvPartyBallot( + room = room, + suggestions = suggestions, + leaderId = winner?.id, + personalVotesKnown = personalVotesKnown, + voteState = TvControlState.transient(eligibility.canVote && connected), + suggestState = TvControlState.transient(eligibility.canSuggest), + focus = focus, + actions = actions, + ) + RoomBanner(warning = warning, notice = notice, footer = footer) + } + } else { + // Suggestions take the empty side of the screen so the hero + // keeps its height and the seats stay put. + Row( + modifier = Modifier.padding(top = 30.dp), + horizontalArrangement = Arrangement.spacedBy(20.dp), + ) { + Column( + modifier = Modifier.width(380.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + HeroText( + room = room, + hero = hero, + staged = staged, + titleSize = TvPartyMetrics.heroTitle, + memberStateLine = memberStateLine, + ) + staged?.overview?.takeIf { + !room.selectedContentId.isNullOrBlank() && !staged.spoilers.hidesOverview(spoilerPrefs) + }?.let { overview -> + Text( + text = overview, + fontSize = TvPartyMetrics.overview, + lineHeight = 20.sp, + color = PrairieSecondaryText, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + RoomBanner(warning = warning, notice = notice, footer = footer) + } + if (room.phase == RoomPhase.Lobby && room.selectionMode == RoomSelectionMode.HostPick && + suggestions.isNotEmpty() + ) { + Spacer(modifier = Modifier.weight(1f)) + TvPartyHostPickSuggestions( + room = room, + suggestions = suggestions, + queueState = TvControlState.transient(eligibility.canQueue && connected), + focus = focus, + actions = actions, + modifier = Modifier.widthIn(max = 410.dp), + ) + } + } + } + } + + // ---- Seats and actions ----------------------------------------- + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp, bottom = if (voting) 26.dp else 36.dp), + verticalAlignment = Alignment.Bottom, + ) { + TvPartySeatsRow( + members = room.members, + phase = room.phase, + onInvite = actions.onInvite, + inviteModifier = Modifier.partyFocus(focus, PARTY_KEY_INVITE_SEAT), + seatSize = if (voting) 40.dp else TvPartyMetrics.seat, + ) + Spacer(modifier = Modifier.weight(1f).width(20.dp)) + Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(10.dp)) { + TvPartyButtonRow { + SecondaryButton( + secondary = secondary, + primary = primary, + eligibility = eligibility, + actions = actions, + modifier = Modifier.partyFocus(focus, PARTY_KEY_SECONDARY), + ) + PrimaryButton( + primary = primary, + hasSelection = !room.selectedContentId.isNullOrBlank(), + startState = startState, + eligibility = eligibility, + connected = connected, + actions = actions, + modifier = Modifier.partyFocus(focus, PARTY_KEY_PRIMARY), + ) + } + if (hint != null) { + Text( + text = hint, + fontSize = TvPartyMetrics.caption, + color = PrairieSecondaryText.copy(alpha = 0.7f), + textAlign = TextAlign.End, + modifier = Modifier.widthIn(max = 360.dp), + ) + } + } + } + } + } +} + +/** Staged titles longer than this step the hero type down a size. */ +private const val LONG_TITLE_CHARS = 16 + +@Composable +private fun RoomBanner(warning: String?, notice: String?, footer: (@Composable () -> Unit)?) { + when { + warning != null -> TvPartyBanner(message = warning, warning = true) + notice != null -> TvPartyBanner(message = notice) + } + footer?.invoke() +} + +/** Eyebrow, title, facts. In a voting lobby the question is the title. */ +@Composable +private fun HeroText( + room: RoomSnapshot, + hero: WatchPartyLobbyHero, + staged: TvStagedPreview?, + titleSize: TextUnit, + memberStateLine: String?, +) { + val selected = !room.selectedContentId.isNullOrBlank() + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + TvPartyEyebrow(hero.eyebrow) + val title = hero.title ?: staged?.title ?: "Loading title…" + val size = if (!hero.isVoteQuestion && hero.title == null && title.length > LONG_TITLE_CHARS) titleSize * 0.8f else titleSize + Text( + text = title, + fontSize = size, + lineHeight = size * 1.06f, + fontWeight = FontWeight.Bold, + letterSpacing = (-0.5).sp, + color = if (staged == null && selected && hero.title == null) PrairieSecondaryText else PrairieOnSurface, + maxLines = if (hero.isVoteQuestion) 1 else 2, + overflow = TextOverflow.Ellipsis, + ) + if (selected && !hero.isVoteQuestion) { + if (staged != null) FactsLine(staged) + val chose = listOfNotNull(hero.supporting, memberStateLine).joinToString(" · ") + if (chose.isNotBlank()) { + Text( + text = chose, + fontSize = TvPartyMetrics.caption, + color = PrairieSecondaryText.copy(alpha = 0.7f), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } else { + hero.supporting?.let { + Text(text = it, fontSize = TvPartyMetrics.body, lineHeight = 21.sp, color = PrairieSecondaryText) + } + } + } +} + +/** "S1:E2 · 2021 · 2h 35m [4K] [HDR]". */ +@Composable +private fun FactsLine(staged: TvStagedPreview) { + val parts = listOfNotNull(staged.subtitle) + staged.facts + if (parts.isEmpty() && staged.chips.isEmpty()) return + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + parts.forEachIndexed { index, part -> + if (index > 0) Text(text = "·", fontSize = 14.sp, color = PrairieSecondaryText.copy(alpha = 0.5f)) + Text(text = part, fontSize = 14.sp, color = PrairieSecondaryText, maxLines = 1) + } + staged.chips.forEach { chip -> + Text( + text = chip, + fontSize = TvPartyMetrics.caption, + fontWeight = FontWeight.SemiBold, + color = PrairieSecondaryText, + modifier = Modifier + .border(1.dp, PrairieSecondaryText.copy(alpha = 0.5f), RoundedCornerShape(3.dp)) + .padding(horizontal = 4.dp, vertical = 1.dp), + ) + } + } +} + +@Composable +private fun PrimaryButton( + primary: WatchPartyPrimaryAction, + hasSelection: Boolean, + startState: TvControlState, + eligibility: WatchPartyEligibility, + connected: Boolean, + actions: TvPartyRoomActions, + modifier: Modifier, +) { + when (primary) { + WatchPartyPrimaryAction.ChooseTitle -> TvPartyButton( + label = if (hasSelection) "Change title" else "Choose a title", + onClick = actions.onChooseTitle, + state = TvControlState.transient(eligibility.canStage), + modifier = modifier, + ) + is WatchPartyPrimaryAction.Start -> TvPartyButton( + label = primary.title?.let { "Start $it" } ?: "Start for everyone", + icon = Icons.Filled.PlayArrow, + onClick = actions.onStart, + state = startState, + modifier = modifier.widthIn(max = 380.dp), + ) + WatchPartyPrimaryAction.WaitingForVotes -> TvPartyButton( + label = "Suggest a title", + icon = Icons.Filled.Add, + onClick = actions.onSuggest, + state = TvControlState.transient(eligibility.canSuggest), + modifier = modifier, + ) + is WatchPartyPrimaryAction.Ready -> TvPartyButton( + label = if (primary.isReady) "You're ready" else "I'm ready", + icon = if (primary.isReady) Icons.Filled.Check else Icons.Filled.CheckCircle, + kind = if (primary.isReady) TvPartyButtonKind.Outlined else TvPartyButtonKind.Primary, + trailing = if (primary.isReady) "select to undo" else null, + onClick = { actions.onReady(!primary.isReady) }, + state = TvControlState.transient(eligibility.canLobbyReady && connected), + modifier = modifier, + ) + WatchPartyPrimaryAction.ReturnToPlayback -> TvPartyButton( + label = "Return to playback", + icon = Icons.Filled.PlayArrow, + onClick = actions.onReturnToPlayback, + modifier = modifier, + ) + WatchPartyPrimaryAction.None -> Unit + } +} + +@Composable +private fun SecondaryButton( + secondary: WatchPartySecondaryAction?, + primary: WatchPartyPrimaryAction, + eligibility: WatchPartyEligibility, + actions: TvPartyRoomActions, + modifier: Modifier, +) { + when (secondary) { + WatchPartySecondaryAction.ChangeTitle -> TvPartyButton( + label = "Change title", + kind = TvPartyButtonKind.Secondary, + onClick = actions.onChooseTitle, + state = TvControlState.transient(eligibility.canStage), + modifier = modifier, + ) + WatchPartySecondaryAction.Suggest -> TvPartyButton( + label = if (primary is WatchPartyPrimaryAction.Start) "Suggest" else "Suggest a title", + icon = Icons.Filled.Add, + kind = TvPartyButtonKind.Secondary, + onClick = actions.onSuggest, + state = TvControlState.transient(eligibility.canSuggest), + modifier = modifier, + ) + null -> Unit + } +} + +// ---- Ballot (voting) --------------------------------------------------------- + +/** Candidates with their vote count as a badge; the leader's badge inverts. Suggest is the dashed card at the end. */ +@Composable +private fun TvPartyBallot( + room: RoomSnapshot, + suggestions: List<Suggestion>, + leaderId: String?, + personalVotesKnown: Boolean, + voteState: TvControlState, + suggestState: TvControlState, + focus: TvPartyFocus, + actions: TvPartyRoomActions, +) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(14.dp), + contentPadding = PaddingValues(vertical = 8.dp), + modifier = Modifier.padding(top = 2.dp), + ) { + items(suggestions, key = { it.id }) { suggestion -> + TvPartySuggestionCard( + suggestion = suggestion, + posterWidth = TvPartyMetrics.ballotPosterWidth, + titleSize = 15.sp, + // Personal votes are unknown until an HTTP read confirms them. + voted = personalVotesKnown && suggestion.votedByMe, + badge = { VoteBadge(count = suggestion.voteCount, isLeader = suggestion.id == leaderId) }, + state = voteState, + focusable = tvPartySuggestionFocusable(room, suggestion), + onClick = { actions.onVote(suggestion) }, + onMenu = { actions.onSuggestionMenu(suggestion) }, + modifier = Modifier.partyFocus(focus, tvPartySuggestionKey(suggestion.id)), + ) + } + item(key = PARTY_KEY_SUGGEST_CARD) { + TvPartySuggestCard( + posterWidth = TvPartyMetrics.ballotPosterWidth, + state = suggestState, + onClick = actions.onSuggest, + modifier = Modifier.partyFocus(focus, PARTY_KEY_SUGGEST_CARD), + ) + } + } +} + +@Composable +private fun VoteBadge(count: Int, isLeader: Boolean) { + val shape = RoundedCornerShape(100.dp) + Row( + modifier = Modifier + .background(if (isLeader) PrairieOnSurface else Color.Black.copy(alpha = 0.7f), shape) + .border(1.dp, if (isLeader) Color.Transparent else PartyChromeBorder, shape) + .padding(horizontal = 7.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + ) { + val color = if (isLeader) Color.Black else PrairieOnSurface + Text(text = "$count", fontSize = 16.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, color = color) + Text( + text = if (count == 1) "VOTE" else "VOTES", + fontSize = TvPartyMetrics.caption, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.8.sp, + color = color.copy(alpha = 0.6f), + ) + } +} + +// ---- Suggestions (host-pick) ----------------------------------------------- + +/** + * Guests' suggestions in a host-pick lobby. The host still decides, so Select + * queues a suggestion as the pick rather than starting it; a guest's own card + * stays focusable so they can remove it. Other guests' cards only display. + */ +@Composable +private fun TvPartyHostPickSuggestions( + room: RoomSnapshot, + suggestions: List<Suggestion>, + queueState: TvControlState, + focus: TvPartyFocus, + actions: TvPartyRoomActions, + modifier: Modifier = Modifier, +) { + val manages = room.isManagedBySelf + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + TvPartyEyebrow("Suggestions · ${suggestions.size}") + Spacer(modifier = Modifier.weight(1f).width(12.dp)) + Text( + text = if (manages) "Queue one to put it up next." else "The host decides what plays.", + fontSize = TvPartyMetrics.caption, + color = PrairieSecondaryText, + maxLines = 1, + ) + } + LazyRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(vertical = 8.dp), + ) { + items(suggestions, key = { it.id }) { suggestion -> + val queued = suggestion.contentId == room.selectedContentId + TvPartySuggestionCard( + suggestion = suggestion, + posterWidth = 76.dp, + titleSize = 14.sp, + voted = false, + badge = if (queued) { + { UpNextBadge() } + } else { + null + }, + state = if (manages && !queued) queueState else TvControlState.structural(true), + focusable = tvPartySuggestionFocusable(room, suggestion), + onClick = { + if (manages && !queued) actions.onQueue(suggestion) else actions.onSuggestionMenu(suggestion) + }, + onMenu = { actions.onSuggestionMenu(suggestion) }, + showSubtitle = false, + modifier = Modifier.partyFocus(focus, tvPartySuggestionKey(suggestion.id)), + ) + } + } + } +} + +@Composable +private fun UpNextBadge() { + Text( + text = "UP NEXT", + fontSize = TvPartyMetrics.caption, + fontWeight = FontWeight.Bold, + letterSpacing = 0.8.sp, + color = Color.Black, + modifier = Modifier + .background(PrairieOnSurface, RoundedCornerShape(100.dp)) + .padding(horizontal = 5.dp, vertical = 1.dp), + ) +} + +/** + * A poster card for a suggestion. Select runs [onClick]; long-press or the + * remote's Menu key runs [onMenu]. Focus lifts and outlines the poster. + */ +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun TvPartySuggestionCard( + suggestion: Suggestion, + posterWidth: Dp, + titleSize: TextUnit, + voted: Boolean, + badge: (@Composable () -> Unit)?, + state: TvControlState, + focusable: Boolean, + onClick: () -> Unit, + onMenu: () -> Unit, + modifier: Modifier = Modifier, + showSubtitle: Boolean = true, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val shape = RoundedCornerShape(6.dp) + val controlState = if (focusable) state else TvControlState.structural(false) + Column(modifier = Modifier.width(posterWidth), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Surface( + onClick = { controlState.perform(onClick) }, + onLongClick = if (focusable) onMenu else null, + enabled = controlState.focusable, + interactionSource = interactionSource, + shape = ClickableSurfaceDefaults.shape(shape = shape), + colors = ClickableSurfaceDefaults.colors( + containerColor = Color.Transparent, + focusedContainerColor = Color.Transparent, + pressedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + ), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f), + border = ClickableSurfaceDefaults.border( + border = Border(BorderStroke(1.dp, PartyChromeBorder), shape = shape), + focusedBorder = Border(BorderStroke(3.dp, PrairieOnSurface), shape = shape), + ), + glow = ClickableSurfaceDefaults.glow( + focusedGlow = Glow(elevationColor = Color.White.copy(alpha = 0.22f), elevation = 16.dp), + ), + modifier = modifier + .onPreviewKeyEvent { event -> + if (focusable && event.key == Key.Menu && event.type == KeyEventType.KeyUp) { + onMenu() + true + } else { + event.key == Key.Menu && focusable + } + } + .tvControlSemantics(controlState), + ) { + Box(modifier = Modifier.size(width = posterWidth, height = posterWidth * 1.5f)) { + TvPoster( + imageUrl = suggestion.posterUrl.ifBlank { null }, + contentDescription = suggestion.title, + cornerRadius = 6.dp, + modifier = Modifier.fillMaxSize(), + defaultArtwork = DefaultArtworkKind.forItemType(suggestion.contentType), + hidden = WatchPartySpoilers.of(suggestion).hidesPoster(LocalEpisodeSpoilerPrefs.current), + ) + if (badge != null) { + Box(modifier = Modifier.align(Alignment.TopStart).padding(6.dp)) { badge() } + } + if (voted) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(6.dp) + .size(22.dp) + .background(PrairieOnSurface, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Filled.Check, contentDescription = "Your vote", tint = Color.Black, modifier = Modifier.size(14.dp)) + } + } + } + } + Column(verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text( + text = suggestion.title, + fontSize = titleSize, + fontWeight = FontWeight.SemiBold, + color = if (isFocused) PrairieOnSurface else PrairieOnSurface.copy(alpha = 0.92f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (showSubtitle) { + val line = suggestion.note.takeIf { it.isNotBlank() }?.let { "“$it”" } ?: suggestion.subtitle.ifBlank { " " } + Text(text = line, fontSize = TvPartyMetrics.caption, color = PrairieSecondaryText, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } +} + +/** The dashed "+" card at the end of the ballot. */ +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun TvPartySuggestCard( + posterWidth: Dp, + state: TvControlState, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val shape = RoundedCornerShape(6.dp) + Column(modifier = Modifier.width(posterWidth), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Surface( + onClick = { state.perform(onClick) }, + enabled = state.focusable, + interactionSource = interactionSource, + shape = ClickableSurfaceDefaults.shape(shape = shape), + colors = ClickableSurfaceDefaults.colors( + containerColor = Color.Transparent, + focusedContainerColor = Color.White.copy(alpha = 0.14f), + pressedContainerColor = Color.White.copy(alpha = 0.14f), + ), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f), + border = ClickableSurfaceDefaults.border( + border = Border(BorderStroke(0.dp, Color.Transparent), shape = shape), + focusedBorder = Border(BorderStroke(2.dp, PrairieOnSurface), shape = shape), + ), + modifier = modifier.tvControlSemantics(state), + ) { + Box( + modifier = Modifier.size(width = posterWidth, height = posterWidth * 1.5f), + contentAlignment = Alignment.Center, + ) { + if (!isFocused) { + Canvas(modifier = Modifier.fillMaxSize()) { + val stroke = 1.dp.toPx() + drawRoundRect( + color = PrairieSecondaryText.copy(alpha = 0.5f), + topLeft = Offset(stroke / 2, stroke / 2), + size = Size(size.width - stroke, size.height - stroke), + cornerRadius = CornerRadius(6.dp.toPx()), + style = Stroke(width = stroke, pathEffect = PathEffect.dashPathEffect(floatArrayOf(5.dp.toPx(), 4.dp.toPx()))), + ) + } + } + Icon( + imageVector = Icons.Filled.Add, + contentDescription = "Suggest a title", + tint = if (isFocused) PrairieOnSurface else PrairieSecondaryText, + modifier = Modifier.size(34.dp), + ) + } + } + Column(verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text(text = "Suggest a title", fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = PrairieSecondaryText, maxLines = 1) + Text(text = "Anyone can add one", fontSize = TvPartyMetrics.caption, color = PrairieSecondaryText.copy(alpha = 0.7f), maxLines = 1) + } + } +} + +// ---- Options ----------------------------------------------------------------- + +/** + * Party options behind the "···" button, so settings never share a row with + * Start. Hosts get the mode, the playback policy, Return everyone to lobby + * while playing, End, and Leave; guests get Leave. Close has focus on open, + * and Back closes. Rows stay focusable while a change is in flight. + */ +@Composable +internal fun TvWatchPartyOptionsOverlay( + room: RoomSnapshot, + features: WatchPartyFeatures?, + eligibility: WatchPartyEligibility, + onSetMode: (RoomSelectionMode) -> Unit, + onSetPolicy: (GuestControlPolicy) -> Unit, + onStopPlayback: () -> Unit, + onEnd: () -> Unit, + onLeave: () -> Unit, + onDismiss: () -> Unit, +) { + TvPartyMenuOverlay(title = "Party options", onDismiss = onDismiss) { + if (room.isManagedBySelf) { + if (room.phase == RoomPhase.Lobby && features?.selectionModeSwitch == true) { + val vote = room.selectionMode == RoomSelectionMode.Vote + TvPartyOptionRow( + title = "Who chooses", + value = if (vote) "Everyone votes" else "Host chooses", + state = TvControlState.transient(eligibility.canSwitchMode), + onClick = { onSetMode(if (vote) RoomSelectionMode.HostPick else RoomSelectionMode.Vote) }, + ) + } + val guestsPlay = room.guestControlPolicy == GuestControlPolicy.GuestPlayPause + TvPartyOptionRow( + title = "Playback controls", + value = if (guestsPlay) "Guests can play and pause" else "Host only", + state = TvControlState.transient(eligibility.canSetPolicy), + onClick = { onSetPolicy(if (guestsPlay) GuestControlPolicy.HostOnly else GuestControlPolicy.GuestPlayPause) }, + ) + if (room.phase == RoomPhase.Playing && features?.stopPlayback == true) { + TvPartyOptionRow( + title = "Return everyone to lobby", + state = TvControlState.transient(eligibility.canStop), + onClick = onStopPlayback, + ) + } + TvPartyOptionRow( + title = "End party for everyone", + destructive = true, + state = TvControlState.transient(eligibility.canEnd), + onClick = onEnd, + ) + } + TvPartyOptionRow(title = "Leave party", destructive = true, onClick = onLeave) + } +} + +// ---- Invitation ---------------------------------------------------------------- + +/** + * The invitation: a large code, and for the host a QR of the invitation link + * for a phone. Nothing scrolls and Done is the only control, so the page + * always has a focus owner. Back dismisses. + */ +@Composable +internal fun TvWatchPartyInvitePage( + code: String, + inviteUrl: String?, + backdropUrl: String?, + backdropThumbhash: String?, + /** Spoiler protection hides the staged title's backdrop. */ + backdropHidden: Boolean, + onDismiss: () -> Unit, +) { + val doneFocus = remember { FocusRequester() } + Popup( + alignment = Alignment.Center, + onDismissRequest = onDismiss, + properties = PopupProperties( + focusable = true, + dismissOnBackPress = true, + dismissOnClickOutside = false, + clippingEnabled = false, + ), + ) { + Box(modifier = Modifier.fillMaxSize()) { + TvPartyBackdrop(url = backdropUrl, thumbhash = backdropThumbhash, hidden = backdropHidden) + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 60.dp) + .tvModalFocusBoundary() + .then(rememberTvDialogInitialFocus(doneFocus)), + horizontalArrangement = Arrangement.spacedBy(48.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.widthIn(max = 450.dp), + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + TvPartyEyebrow("Invite friends") + Text( + text = "Join with this code", + fontSize = 40.sp, + lineHeight = 46.sp, + fontWeight = FontWeight.Bold, + color = PrairieOnSurface, + ) + Text( + text = "Anyone with a profile on this Prairie server can enter it from Watch Party.", + fontSize = TvPartyMetrics.body, + lineHeight = 21.sp, + color = PrairieSecondaryText, + ) + } + Text( + text = code, + fontSize = 40.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + letterSpacing = 8.sp, + color = PrairieOnSurface, + maxLines = 1, + ) + if (inviteUrl != null) { + Text( + text = "Or scan to open the invitation on a phone.", + fontSize = TvPartyMetrics.caption, + color = PrairieSecondaryText, + ) + } + TvPartyButton( + label = "Done", + kind = TvPartyButtonKind.Secondary, + onClick = onDismiss, + modifier = Modifier.focusRequester(doneFocus), + ) + } + if (inviteUrl != null) { + Box( + modifier = Modifier + .background(Color.White, RoundedCornerShape(8.dp)) + .padding(15.dp), + ) { + QrCodePanel(content = inviteUrl, size = 250.dp) + } + } + } + } + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchparty/TvWatchPartyStagedItem.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchparty/TvWatchPartyStagedItem.kt new file mode 100644 index 0000000000..081c21a7b3 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchparty/TvWatchPartyStagedItem.kt @@ -0,0 +1,152 @@ +package org.prairieserver.prairie.tv.ui.screens.watchparty + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import org.koin.compose.koinInject +import org.prairieserver.prairie.model.catalog.ItemDetail +import org.prairieserver.prairie.model.watchtogether.Suggestion +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.repository.CatalogRepository +import org.prairieserver.prairie.viewmodel.WatchPartyItem +import org.prairieserver.prairie.viewmodel.WatchPartySpoilers +import java.util.concurrent.ConcurrentHashMap + +/** + * Title previews handed from the detail page or the picker to the lobby, so + * the staged card can lay out once instead of waiting for a detail fetch. + * Process memory only; nothing here is a secret or survives the process. + */ +internal object TvWatchPartyPreviews { + private const val CAPACITY = 32 + private val items = ConcurrentHashMap<String, WatchPartyItem>() + + fun put(item: WatchPartyItem) { + if (items.size >= CAPACITY) items.clear() + items[item.contentId] = item + } + + fun get(contentId: String): WatchPartyItem? = items[contentId] +} + +/** What the room shows for the staged item. */ +internal data class TvStagedPreview( + val title: String, + /** An episode's series and number; null for a movie. */ + val subtitle: String?, + val posterUrl: String?, + val posterThumbhash: String? = null, + val backdropUrl: String? = null, + val backdropThumbhash: String? = null, + val overview: String? = null, + /** "2021", "2h 35m". */ + val facts: List<String> = emptyList(), + /** The chosen version's quality: "4K", "HDR". */ + val chips: List<String> = emptyList(), + val spoilers: WatchPartySpoilers = WatchPartySpoilers.NONE, +) + +/** + * The staged item's hero content: a handed-over preview or a suggestion first, + * then the catalog detail (cached, else fetched) for the full title, artwork, + * overview, and the selected version's quality. + */ +@Composable +internal fun rememberTvStagedPreview( + contentId: String?, + fileId: Int?, + libraryId: Int?, + suggestions: List<Suggestion>, + /** Re-reads the detail when it changes, such as the room's phase after playback. */ + refreshKey: Any? = null, + catalog: CatalogRepository = koinInject(), +): TvStagedPreview? { + val detail by produceState<ItemDetail?>(initialValue = null, contentId, libraryId, refreshKey) { + val id = contentId?.takeIf { it.isNotBlank() } + val shown = value?.takeIf { it.contentId == id } + value = shown + id ?: return@produceState + if (shown == null) { + value = catalog.getCachedItemDetail(id, libraryId) + ?: (catalog.getItemDetail(id, libraryId) as? ApiResult.Success)?.data + } else { + // Same title, new phase: after the party plays it, a fresh read + // carries the watch state spoiler protection needs. + (catalog.getItemDetail(id, libraryId) as? ApiResult.Success)?.data?.let { value = it } + } + } + val id = contentId?.takeIf { it.isNotBlank() } ?: return null + val loaded = detail?.takeIf { it.contentId == id } + if (loaded != null) { + val version = loaded.versions.firstOrNull { it.fileId == fileId } ?: loaded.versions.singleOrNull() + return TvStagedPreview( + title = loaded.title, + subtitle = tvDetailSubtitle(loaded), + posterUrl = loaded.posterUrl, + posterThumbhash = loaded.posterThumbhash, + backdropUrl = loaded.backdropUrl, + backdropThumbhash = loaded.backdropThumbhash, + overview = loaded.overview?.takeIf { it.isNotBlank() }, + facts = listOfNotNull( + loaded.year.takeIf { it > 0 && loaded.type != "episode" }?.toString(), + loaded.runtime.takeIf { it > 0 }?.let(::tvWatchPartyRuntime), + ), + chips = listOfNotNull( + version?.resolution?.let(::tvWatchPartyResolutionChip), + "HDR".takeIf { version?.hdr == true }, + ), + spoilers = WatchPartySpoilers.of(loaded), + ) + } + TvWatchPartyPreviews.get(id)?.let { item -> + return TvStagedPreview(item.title, item.subtitle, item.posterUrl, spoilers = item.spoilers) + } + suggestions.firstOrNull { it.contentId == id }?.let { suggestion -> + return TvStagedPreview( + title = suggestion.title, + subtitle = suggestion.subtitle.ifBlank { null }, + posterUrl = suggestion.posterUrl.ifBlank { null }, + spoilers = WatchPartySpoilers.of(suggestion), + ) + } + return TvStagedPreview(title = "Loading title…", subtitle = null, posterUrl = null) +} + +private fun tvDetailSubtitle(detail: ItemDetail): String? = when (detail.type) { + "episode" -> listOfNotNull( + detail.seriesTitle?.takeIf { it.isNotBlank() }, + detail.seasonNumber?.let { season -> detail.episodeNumber?.let { "S$season:E$it" } }, + ).joinToString(" · ").ifBlank { null } + else -> null +} + +/** "2h 35m", "48m". */ +internal fun tvWatchPartyRuntime(minutes: Int): String = + if (minutes >= 60) "${minutes / 60}h ${minutes % 60}m" else "${minutes}m" + +private fun tvWatchPartyResolutionChip(resolution: String): String? = when (resolution.trim().lowercase()) { + "" -> null + "2160p", "4k", "uhd" -> "4K" + else -> resolution.trim().uppercase() +} + +/** A detail page's item as the party sees it. */ +internal fun tvWatchPartyItem( + contentId: String, + contentType: String, + title: String, + subtitle: String?, + posterUrl: String?, + fileId: Int?, + libraryId: Int?, + spoilers: WatchPartySpoilers = WatchPartySpoilers.forType(contentType), +): WatchPartyItem = WatchPartyItem( + contentId = contentId, + contentType = contentType, + title = title, + subtitle = subtitle?.takeIf { it.isNotBlank() }, + posterUrl = posterUrl?.takeIf { it.isNotBlank() }, + fileId = fileId, + libraryId = libraryId, + spoilers = spoilers, +) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchparty/TvWatchPartyStyle.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchparty/TvWatchPartyStyle.kt new file mode 100644 index 0000000000..b6cfc40331 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchparty/TvWatchPartyStyle.kt @@ -0,0 +1,840 @@ +package org.prairieserver.prairie.tv.ui.screens.watchparty + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.MoreHoriz +import androidx.compose.material.icons.filled.QrCode2 +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.scale +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import androidx.tv.material3.Border +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Glow +import androidx.tv.material3.Icon +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import org.prairieserver.prairie.model.watchtogether.RoomMember +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.tv.ui.components.rememberTvDialogInitialFocus +import org.prairieserver.prairie.tv.ui.focus.TvControlState +import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved +import org.prairieserver.prairie.tv.ui.focus.tvControlSemantics +import org.prairieserver.prairie.tv.ui.focus.tvModalFocusBoundary +import org.prairieserver.prairie.tv.ui.theme.DarkBackground +import org.prairieserver.prairie.tv.ui.theme.DarkSurfaceElevated +import org.prairieserver.prairie.tv.ui.theme.FocusedContainer +import org.prairieserver.prairie.tv.ui.theme.FocusedContent +import org.prairieserver.prairie.tv.ui.theme.PrairieOnSurface +import org.prairieserver.prairie.tv.ui.theme.PrairieSecondaryText +import org.prairieserver.prairie.watchtogether.WatchPartyConnectionTone +import org.prairieserver.prairie.watchtogether.watchPartyAvatarIndex +import org.prairieserver.prairie.watchtogether.WatchPartySeatState +import org.prairieserver.prairie.watchtogether.watchPartyPresenceSummary +import org.prairieserver.prairie.watchtogether.watchPartySeatOrder +import org.prairieserver.prairie.watchtogether.watchPartySeatState +import org.prairieserver.prairie.watchtogether.watchPartySeatStatus +import org.prairieserver.prairie.common.ui.components.SpoilerImage + +/* + * The Watch Party look, ported from the tvOS clients (`WatchPartyComponents` + * in prairie-apple). tvOS metrics are pixels at 1920×1080; this app lays out a + * 960×540dp canvas and scales text by 0.86, so lengths are px / 2 and text is + * px / 2 / 0.86, never below the TV app's 14sp reading floor (eyebrows, + * captions, and badges land on the floor rather than at tvOS's smaller size). + */ + +internal object TvPartyMetrics { + /** tvOS page inset (88) plus the system's horizontal safe area (80). */ + val pageInsetX = 84.dp + val headerTop = 58.dp + val seat = 64.dp + val seatGap = 18.dp + val heroTitle = 50.sp + val body = 15.sp + val overview = 15.sp + val caption = 14.sp + val eyebrow = 14.sp + val code = 17.sp + val ballotPosterWidth = 104.dp +} + +/** The TV app's smallest readable text size. */ +private const val MIN_SP = 14f + +internal val PartyAmber = Color(0xFFF5A524) +internal val PartyAmberText = Color(0xFFF5C563) +internal val PartyEmerald = Color(0xFF34D399) +internal val PartyRose = Color(0xFFFB7185) +internal val PartyDestructive = Color(0xFFF2667D) +internal val PartyDestructiveFocused = Color(0xFFC8243B) +internal val PartyChromeFill = Color.White.copy(alpha = 0.10f) +internal val PartyChromeBorder = Color.White.copy(alpha = 0.14f) + +/** + * Focus bookkeeping for a room screen: one requester per named control, and + * which one holds focus now. Modals record [focusedKey] when they open and + * hand focus back to it when they close. + */ +@Stable +internal class TvPartyFocus { + private val requesters = mutableMapOf<String, FocusRequester>() + var focusedKey by mutableStateOf<String?>(null) + private set + + fun requester(key: String): FocusRequester = requesters.getOrPut(key) { FocusRequester() } + + fun isFocused(key: String?): Boolean = key != null && focusedKey == key + + fun onFocus(key: String, focused: Boolean) { + if (focused) focusedKey = key else if (focusedKey == key) focusedKey = null + } +} + +@Composable +internal fun rememberTvPartyFocus(): TvPartyFocus = remember { TvPartyFocus() } + +/** Attach [key]'s requester and report its focus to [focus]. */ +internal fun Modifier.partyFocus(focus: TvPartyFocus, key: String): Modifier = this + .focusRequester(focus.requester(key)) + .onFocusChanged { focus.onFocus(key, it.isFocused) } + +// ---- Type ------------------------------------------------------------------ + +@Composable +internal fun TvPartyEyebrow(text: String, modifier: Modifier = Modifier, color: Color = PrairieSecondaryText) { + Text( + text = text.uppercase(), + fontSize = TvPartyMetrics.eyebrow, + fontWeight = FontWeight.SemiBold, + letterSpacing = 1.4.sp, + color = color, + maxLines = 1, + modifier = modifier, + ) +} + +// ---- Backdrop -------------------------------------------------------------- + +/** + * Full-bleed artwork under a room screen: the film is the room. A poster + * (all some titles have) is blurred and dimmed so it tints rather than shows. + */ +@Composable +internal fun TvPartyBackdrop( + url: String?, + thumbhash: String? = null, + isPoster: Boolean = false, + modifier: Modifier = Modifier, + /** Spoiler protection hides this artwork: an unwatched episode's still. */ + hidden: Boolean = false, +) { + Box(modifier = modifier.fillMaxSize().background(DarkBackground)) { + if (!url.isNullOrBlank() || !thumbhash.isNullOrBlank()) { + SpoilerImage( + url = url, + thumbhash = thumbhash, + hidden = hidden, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .then(if (isPoster) Modifier.blur(14.dp).alpha(0.7f) else Modifier), + ) + } + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.horizontalGradient( + 0f to Color.Black.copy(alpha = 0.92f), + 0.34f to Color.Black.copy(alpha = 0.78f), + 0.62f to Color.Black.copy(alpha = 0.25f), + 1f to Color.Black.copy(alpha = 0.05f), + ), + ), + ) + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + 0f to Color.Black.copy(alpha = 0.35f), + 0.3f to Color.Transparent, + 0.7f to Color.Black.copy(alpha = 0.4f), + 1f to Color.Black.copy(alpha = 0.95f), + ), + ), + ) + } +} + +// ---- Buttons --------------------------------------------------------------- + +internal enum class TvPartyButtonKind { Primary, Secondary, Outlined } + +/** + * The room's pill button: white primary, chrome secondary, outlined. Focus + * always inverts to the white capsule, as the rest of the TV app does. A + * [state] gated by work in flight stays focusable and dims. + */ +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +internal fun TvPartyButton( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + kind: TvPartyButtonKind = TvPartyButtonKind.Primary, + icon: ImageVector? = null, + state: TvControlState = TvControlState.structural(true), + trailing: String? = null, + fontSize: TextUnit = if (kind == TvPartyButtonKind.Primary) 17.sp else 15.sp, + height: Dp = 40.dp, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val shape = RoundedCornerShape(100.dp) + val (restFill, restContent, restBorder) = when (kind) { + TvPartyButtonKind.Primary -> Triple(PrairieOnSurface, DarkBackground, Color.Transparent) + TvPartyButtonKind.Secondary -> Triple(PartyChromeFill, PrairieOnSurface, PartyChromeBorder) + TvPartyButtonKind.Outlined -> Triple(Color.Transparent, PrairieOnSurface, PrairieOnSurface.copy(alpha = 0.8f)) + } + val content = if (isFocused) FocusedContent else restContent + Surface( + onClick = { state.perform(onClick) }, + enabled = state.focusable, + interactionSource = interactionSource, + shape = ClickableSurfaceDefaults.shape(shape = shape), + colors = ClickableSurfaceDefaults.colors( + containerColor = restFill, + contentColor = restContent, + focusedContainerColor = FocusedContainer, + focusedContentColor = FocusedContent, + pressedContainerColor = FocusedContainer, + pressedContentColor = FocusedContent, + disabledContainerColor = restFill, + disabledContentColor = restContent, + ), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f), + border = ClickableSurfaceDefaults.border( + border = Border(BorderStroke(1.dp, restBorder), shape = shape), + focusedBorder = Border(BorderStroke(0.dp, Color.Transparent), shape = shape), + ), + glow = ClickableSurfaceDefaults.glow( + focusedGlow = Glow(elevationColor = Color.White.copy(alpha = 0.22f), elevation = 16.dp), + ), + modifier = modifier + .alpha(if (state.actionable) 1f else 0.45f) + .tvControlSemantics(state), + ) { + Row( + modifier = Modifier + .height(height) + .padding(horizontal = 22.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + if (icon != null) { + Icon(imageVector = icon, contentDescription = null, tint = content, modifier = Modifier.size(20.dp)) + Spacer(modifier = Modifier.width(10.dp)) + } + Text( + text = label, + fontSize = fontSize, + fontWeight = FontWeight.SemiBold, + color = content, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (trailing != null) { + Spacer(modifier = Modifier.width(8.dp)) + Text(text = trailing, fontSize = TvPartyMetrics.caption, color = content.copy(alpha = 0.6f), maxLines = 1) + } + } + } +} + +/** The room code is the invitation: "JOIN WITH <CODE> [qr]". Opens the invitation page. */ +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +internal fun TvPartyCodePill(code: String, onClick: () -> Unit, modifier: Modifier = Modifier) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val shape = RoundedCornerShape(100.dp) + Surface( + onClick = onClick, + interactionSource = interactionSource, + shape = ClickableSurfaceDefaults.shape(shape = shape), + colors = ClickableSurfaceDefaults.colors( + containerColor = PartyChromeFill, + contentColor = PrairieOnSurface, + focusedContainerColor = FocusedContainer, + focusedContentColor = FocusedContent, + pressedContainerColor = FocusedContainer, + pressedContentColor = FocusedContent, + ), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f), + border = ClickableSurfaceDefaults.border( + border = Border(BorderStroke(1.dp, PartyChromeBorder), shape = shape), + focusedBorder = Border(BorderStroke(0.dp, Color.Transparent), shape = shape), + ), + glow = ClickableSurfaceDefaults.glow( + focusedGlow = Glow(elevationColor = Color.White.copy(alpha = 0.22f), elevation = 16.dp), + ), + modifier = modifier, + ) { + Row( + modifier = Modifier + .height(34.dp) + .padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(9.dp), + ) { + Text( + text = "JOIN WITH", + fontSize = TvPartyMetrics.caption, + fontWeight = FontWeight.SemiBold, + letterSpacing = 1.4.sp, + color = if (isFocused) Color.Black.copy(alpha = 0.5f) else PrairieSecondaryText, + ) + Text( + text = code, + fontSize = TvPartyMetrics.code, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + letterSpacing = 3.sp, + color = if (isFocused) FocusedContent else PrairieOnSurface, + ) + Icon( + imageVector = Icons.Filled.QrCode2, + contentDescription = "Invite friends", + tint = if (isFocused) Color.Black.copy(alpha = 0.6f) else PrairieSecondaryText, + modifier = Modifier.size(16.dp), + ) + } + } +} + +/** The "···" Options button beside the code pill. */ +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +internal fun TvPartyOptionsButton(onClick: () -> Unit, modifier: Modifier = Modifier) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val shape = RoundedCornerShape(100.dp) + Surface( + onClick = onClick, + interactionSource = interactionSource, + shape = ClickableSurfaceDefaults.shape(shape = shape), + colors = ClickableSurfaceDefaults.colors( + containerColor = PartyChromeFill, + contentColor = PrairieOnSurface, + focusedContainerColor = FocusedContainer, + focusedContentColor = FocusedContent, + pressedContainerColor = FocusedContainer, + pressedContentColor = FocusedContent, + ), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f), + border = ClickableSurfaceDefaults.border( + border = Border(BorderStroke(1.dp, PartyChromeBorder), shape = shape), + focusedBorder = Border(BorderStroke(0.dp, Color.Transparent), shape = shape), + ), + glow = ClickableSurfaceDefaults.glow( + focusedGlow = Glow(elevationColor = Color.White.copy(alpha = 0.22f), elevation = 16.dp), + ), + modifier = modifier, + ) { + Box(modifier = Modifier.size(width = 68.dp, height = 38.dp), contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Filled.MoreHoriz, + contentDescription = "Party options", + tint = if (isFocused) FocusedContent else PrairieOnSurface, + modifier = Modifier.size(22.dp), + ) + } + } +} + +// ---- Status ---------------------------------------------------------------- + +@Composable +internal fun TvPartyConnectionDot(tone: WatchPartyConnectionTone, text: String, modifier: Modifier = Modifier) { + val color = when (tone) { + WatchPartyConnectionTone.Good -> PartyEmerald + WatchPartyConnectionTone.Warning -> PartyAmber + WatchPartyConnectionTone.Neutral -> PrairieSecondaryText + WatchPartyConnectionTone.Bad -> PartyRose + } + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + Box(modifier = Modifier.size(6.dp).background(color, CircleShape)) + Text(text = text, fontSize = TvPartyMetrics.caption, color = PrairieSecondaryText, maxLines = 1) + } +} + +/** One banner for whatever the member should know. Warnings are amber. */ +@Composable +internal fun TvPartyBanner(message: String, modifier: Modifier = Modifier, warning: Boolean = false) { + val shape = RoundedCornerShape(8.dp) + Row( + modifier = modifier + .background(if (warning) PartyAmber.copy(alpha = 0.12f) else PartyChromeFill, shape) + .border(1.dp, if (warning) PartyAmber.copy(alpha = 0.3f) else PartyChromeBorder, shape) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + modifier = Modifier + .size(7.dp) + .background(if (warning) PartyAmber else PrairieSecondaryText, CircleShape), + ) + Text( + text = message, + fontSize = TvPartyMetrics.caption, + lineHeight = 17.sp, + color = if (warning) PartyAmberText else PrairieOnSurface, + ) + } +} + +// ---- Seats ----------------------------------------------------------------- + +private val AvatarPalette = listOf( + Color(0xFF5A4A8A) to Color(0xFF2B2450), + Color(0xFF8A5A3A) to Color(0xFF4A2C1C), + Color(0xFF3A7A6A) to Color(0xFF1C3F36), + Color(0xFF7A3A5A) to Color(0xFF3F1C30), + Color(0xFF4A6A8A) to Color(0xFF213546), + Color(0xFF7A6A2A) to Color(0xFF3F3616), +) + +/** The member's avatar colours, chosen as Apple chooses them so a member keeps one colour everywhere. */ +internal fun tvPartyAvatarBrush(member: RoomMember): Brush { + val (start, end) = AvatarPalette[watchPartyAvatarIndex(member, AvatarPalette.size)] + return Brush.linearGradient(listOf(start, end)) +} + +/** + * One member as a seat: an avatar disc whose ring says where they are. Dashed + * grey is here but not ready, solid white with a tick is ready or watching, + * dotted and dimmed is reconnecting. + */ +@Composable +internal fun TvPartySeat(member: RoomMember, state: WatchPartySeatState, size: Dp = TvPartyMetrics.seat) { + val ring = size * 1.14f + val ringColor = when (state) { + WatchPartySeatState.Ready, WatchPartySeatState.Watching -> PrairieOnSurface + WatchPartySeatState.Away -> PrairieOnSurface.copy(alpha = 0.25f) + else -> PrairieOnSurface.copy(alpha = 0.38f) + } + val done = state == WatchPartySeatState.Ready || state == WatchPartySeatState.Watching + val nameSize = (size.value * 0.19f / 0.86f).coerceAtLeast(MIN_SP).sp + val detailSize = (size.value * 0.16f / 0.86f).coerceAtLeast(MIN_SP).sp + Column( + modifier = Modifier.width(seatColumnWidth(size)), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(size * 0.08f), + ) { + Box( + modifier = Modifier + .padding(top = size * 0.12f) + .size(ring), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .size(size) + .alpha(if (state == WatchPartySeatState.Away) 0.45f else 1f) + .background(tvPartyAvatarBrush(member), CircleShape), + contentAlignment = Alignment.Center, + ) { + Text( + text = tvWatchPartyMemberName(member).take(1).uppercase(), + fontSize = (size.value * 0.36f / 0.86f).sp, + fontWeight = FontWeight.SemiBold, + color = PrairieOnSurface, + ) + } + Canvas(modifier = Modifier.fillMaxSize()) { + val width = size.toPx() * 0.035f + val effect = when (state) { + WatchPartySeatState.Ready, WatchPartySeatState.Watching -> null + WatchPartySeatState.Away -> PathEffect.dashPathEffect(floatArrayOf(0.1f, width * 3f)) + else -> PathEffect.dashPathEffect(floatArrayOf(width * 3f, width * 2.2f)) + } + drawCircle( + color = ringColor, + radius = this.size.minDimension / 2f - width / 2f, + style = Stroke( + width = width, + pathEffect = effect, + cap = if (state == WatchPartySeatState.Away) StrokeCap.Round else StrokeCap.Butt, + ), + ) + } + if (member.isHost) { + Text( + text = "HOST", + fontSize = MIN_SP.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.sp, + color = PrairieOnSurface, + modifier = Modifier + .align(Alignment.TopCenter) + // Text keeps a 14sp floor, so on a small seat the tag is wider than the disc. + .wrapContentSize(unbounded = true) + .offset(y = -size * 0.16f) + .background(Color.Black.copy(alpha = 0.72f), RoundedCornerShape(100.dp)) + .border(1.dp, PartyChromeBorder, RoundedCornerShape(100.dp)) + .padding(horizontal = 6.dp, vertical = 1.dp), + ) + } + if (done) { + // Sits on the ring at 4:30, cutting into it. + val badge = size * 0.34f + val offset = ring / 2 * 0.7071f + Box( + modifier = Modifier + .offset(x = offset, y = offset) + .size(badge) + .background(Color.Black, CircleShape) + .padding(size * 0.03f) + .background(PrairieOnSurface, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + tint = Color.Black, + modifier = Modifier.size(size * 0.2f), + ) + } + } + } + Text( + text = tvWatchPartyMemberName(member), + fontSize = nameSize, + fontWeight = FontWeight.Medium, + color = PrairieOnSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + ) + // "you · not ready": the role rides with the state so long names keep their width. + listOfNotNull("you".takeIf { member.isSelf }, watchPartySeatStatus(state)) + .takeIf { it.isNotEmpty() } + ?.let { detail -> + Text(text = detail.joinToString(" · "), fontSize = detailSize, color = PrairieSecondaryText, maxLines = 1) + } + } +} + +/** Seat columns never narrow past what a 14sp name needs, even when the discs shrink. */ +private fun seatColumnWidth(size: Dp): Dp = maxOf(size * 1.5f, 88.dp) + +/** The dashed "+" seat that opens the invitation. */ +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +internal fun TvPartyInviteSeat(onClick: () -> Unit, modifier: Modifier = Modifier, size: Dp = TvPartyMetrics.seat) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + Column( + modifier = Modifier.width(seatColumnWidth(size)), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(size * 0.08f), + ) { + Surface( + onClick = onClick, + interactionSource = interactionSource, + shape = ClickableSurfaceDefaults.shape(shape = CircleShape), + colors = ClickableSurfaceDefaults.colors( + containerColor = Color.Transparent, + contentColor = PrairieSecondaryText, + focusedContainerColor = FocusedContainer, + focusedContentColor = FocusedContent, + pressedContainerColor = FocusedContainer, + pressedContentColor = FocusedContent, + ), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f), + border = ClickableSurfaceDefaults.border( + border = Border(BorderStroke(size * 0.035f, Color.White.copy(alpha = 0.22f)), shape = CircleShape), + focusedBorder = Border(BorderStroke(0.dp, Color.Transparent), shape = CircleShape), + ), + glow = ClickableSurfaceDefaults.glow( + focusedGlow = Glow(elevationColor = Color.White.copy(alpha = 0.22f), elevation = 16.dp), + ), + modifier = modifier + .padding(top = size * 0.12f) + .size(size * 1.14f), + ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = "Invite friends", + tint = if (isFocused) FocusedContent else PrairieSecondaryText, + modifier = Modifier.size(size * 0.42f), + ) + } + } + Text( + text = "Invite", + fontSize = (size.value * 0.19f / 0.86f).coerceAtLeast(MIN_SP).sp, + fontWeight = FontWeight.Medium, + color = PrairieSecondaryText, + ) + } +} + +/** "HERE NOW · 2 of 3 ready", then the seats and the invite seat. */ +@Composable +internal fun TvPartySeatsRow( + members: List<RoomMember>, + phase: RoomPhase, + onInvite: () -> Unit, + inviteModifier: Modifier = Modifier, + seatSize: Dp = TvPartyMetrics.seat, +) { + Column(verticalArrangement = Arrangement.spacedBy(seatSize * 0.2f)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + TvPartyEyebrow("Here now") + Text( + text = watchPartyPresenceSummary(members, phase), + fontSize = TvPartyMetrics.caption, + color = PrairieSecondaryText.copy(alpha = 0.7f), + maxLines = 1, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(TvPartyMetrics.seatGap), verticalAlignment = Alignment.Top) { + watchPartySeatOrder(members).forEach { member -> + TvPartySeat(member = member, state = watchPartySeatState(member, phase), size = seatSize) + } + TvPartyInviteSeat(onClick = onInvite, modifier = inviteModifier, size = seatSize) + } + } +} + +// ---- Menus ----------------------------------------------------------------- + +/** + * A settings-pane row: title left, current value right. Destructive rows + * read red. Rows gated by work in flight stay focusable. + */ +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +internal fun TvPartyOptionRow( + title: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + value: String? = null, + destructive: Boolean = false, + state: TvControlState = TvControlState.structural(true), +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val shape = RoundedCornerShape(10.dp) + val titleColor = when { + isFocused && destructive -> PartyDestructiveFocused + isFocused -> FocusedContent + destructive -> PartyDestructive + else -> PrairieOnSurface + } + Surface( + onClick = { state.perform(onClick) }, + enabled = state.focusable, + interactionSource = interactionSource, + shape = ClickableSurfaceDefaults.shape(shape = shape), + colors = ClickableSurfaceDefaults.colors( + containerColor = Color.White.copy(alpha = 0.04f), + contentColor = PrairieOnSurface, + focusedContainerColor = FocusedContainer, + focusedContentColor = FocusedContent, + pressedContainerColor = FocusedContainer, + pressedContentColor = FocusedContent, + disabledContainerColor = Color.White.copy(alpha = 0.04f), + disabledContentColor = PrairieOnSurface, + ), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.02f), + border = ClickableSurfaceDefaults.border( + border = Border(BorderStroke(1.dp, Color.White.copy(alpha = 0.10f)), shape = shape), + focusedBorder = Border(BorderStroke(0.dp, Color.Transparent), shape = shape), + ), + glow = ClickableSurfaceDefaults.glow( + focusedGlow = Glow(elevationColor = Color.White.copy(alpha = 0.18f), elevation = 12.dp), + ), + modifier = modifier + .fillMaxWidth() + .heightIn(min = 34.dp) + .alpha(if (state.actionable) 1f else 0.5f) + .tvControlSemantics(state), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + color = titleColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (value != null) { + Text( + text = value, + fontSize = 14.sp, + color = (if (isFocused) FocusedContent else PrairieOnSurface).copy(alpha = 0.7f), + maxLines = 1, + ) + } + } + } +} + +/** + * A dimmed-backdrop card for a short list of rows ("Party options", a + * suggestion's actions), ending in Close. Close has focus on open, so a stray + * Select never runs a destructive row, and Back dismisses. + */ +@Composable +internal fun TvPartyMenuOverlay( + title: String, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + width: Dp = 450.dp, + content: @Composable () -> Unit, +) { + val closeFocus = remember { FocusRequester() } + var closeFocused by remember { mutableStateOf(false) } + // The focusable popup hands focus to its first row on its own, and the + // shared initial-focus helper then stands down, so move focus to Close + // once the rows exist. + LaunchedEffect(Unit) { + requestFocusUntilObserved( + maxAttempts = MENU_FOCUS_ATTEMPTS, + awaitAttempt = { withFrameNanos { } }, + requestFocus = closeFocus::requestFocus, + isFocused = { closeFocused }, + ) + } + Popup( + alignment = Alignment.Center, + onDismissRequest = onDismiss, + properties = PopupProperties( + focusable = true, + dismissOnBackPress = true, + dismissOnClickOutside = false, + clippingEnabled = false, + ), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.62f)), + contentAlignment = Alignment.Center, + ) { + val shape = RoundedCornerShape(15.dp) + Column( + modifier = modifier + .width(width) + .background(DarkSurfaceElevated, shape) + .border(1.dp, PartyChromeBorder, shape) + .padding(24.dp) + .tvModalFocusBoundary() + .then(rememberTvDialogInitialFocus(closeFocus)), + verticalArrangement = Arrangement.spacedBy(9.dp), + ) { + Text( + text = title, + fontSize = 22.sp, + fontWeight = FontWeight.SemiBold, + color = PrairieOnSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(bottom = 2.dp), + ) + content() + TvPartyOptionRow( + title = "Close", + onClick = onDismiss, + modifier = Modifier + .focusRequester(closeFocus) + .onFocusChanged { closeFocused = it.isFocused }, + ) + } + } + } +} + +/** Frames to keep asking for Close's initial focus. */ +private const val MENU_FOCUS_ATTEMPTS = 6 + +/** A row of the room's buttons laid out right to left from the trailing edge. */ +@Composable +internal fun TvPartyButtonRow(modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(9.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically, + content = content, + ) +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchparty/TvWatchPartyText.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchparty/TvWatchPartyText.kt new file mode 100644 index 0000000000..de674abe67 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchparty/TvWatchPartyText.kt @@ -0,0 +1,75 @@ +package org.prairieserver.prairie.tv.ui.screens.watchparty + +import org.prairieserver.prairie.model.watchtogether.RoomMember +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.RoomPlaybackState +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.repository.WatchPartyEndReason + +/** Plain-language reason this device's Watch Party ended. */ +internal fun tvWatchPartyEndedMessage(reason: String): String = when (reason) { + WatchPartyEndReason.Replaced -> "This profile joined the Watch Party on another device." + WatchPartyEndReason.ConnectionLost -> "Lost connection to the party." + else -> "The Watch Party has ended." +} + +/** A member's name for display; the server may omit it. */ +internal fun tvWatchPartyMemberName(member: RoomMember): String = + member.displayName.trim().ifBlank { "Someone" } + +/** "Ana", "Ana and Ben", "Ana, Ben, and Cy", "Ana, Ben, and 3 others". */ +internal fun tvWatchPartyNameList(names: List<String>): String = when (names.size) { + 0 -> "" + 1 -> names[0] + 2 -> "${names[0]} and ${names[1]}" + 3 -> "${names[0]}, ${names[1]}, and ${names[2]}" + else -> "${names[0]}, ${names[1]}, and ${names.size - 2} others" +} + +/** + * The other members the room is waiting for, or null when the room is not + * waiting. An empty list means the room waits only for this viewer. + */ +internal fun tvWatchPartyWaitingFor(room: RoomSnapshot?): List<String>? { + if (room == null || room.phase != RoomPhase.Playing || room.playbackState != RoomPlaybackState.Waiting) { + return null + } + return room.members.filter { it.isSyncing && !it.isSelf }.map(::tvWatchPartyMemberName) +} + +/** + * Members the room left behind between two snapshots of the same selection: + * the room was waiting while they synced, and it now plays again while they + * are still not ready. This viewer is never listed; it gets the catching-up + * notice instead. Members who left the room are not listed either. + */ +internal fun tvWatchPartyLeftBehind(previous: RoomSnapshot?, next: RoomSnapshot?): List<String> { + if (previous == null || next == null) return emptyList() + if (previous.roomId != next.roomId || previous.selectionRevision != next.selectionRevision) return emptyList() + if (previous.phase != RoomPhase.Playing || previous.playbackState != RoomPlaybackState.Waiting) return emptyList() + if (next.phase != RoomPhase.Playing || next.playbackState != RoomPlaybackState.Playing) return emptyList() + return previous.members + .filter { it.isSyncing && !it.isSelf } + .mapNotNull { waitedFor -> + next.members.firstOrNull { it.isSameMember(waitedFor) } + ?.takeIf { !it.isReady } + ?.let(::tvWatchPartyMemberName) + } +} + +private fun RoomMember.isSameMember(other: RoomMember): Boolean = + if (userId.isNotBlank() || other.userId.isNotBlank()) { + userId == other.userId && profileId == other.profileId + } else { + displayName == other.displayName + } + +/** A room code split in two for reading aloud and typing: "ABCD 2345". */ +internal fun tvWatchPartyDisplayCode(code: String): String = + if (code.length == 8) "${code.substring(0, 4)} ${code.substring(4)}" else code + +internal const val TV_WATCH_PARTY_HOST_LEAVE_NOTE = + "The party ends two minutes after you leave unless you rejoin." + +internal const val TV_WATCH_PARTY_HOST_AWAY_NOTE = + "The party ends in two minutes unless the host returns." diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvJoinCodeDialog.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvJoinCodeDialog.kt deleted file mode 100644 index 7f2e98561c..0000000000 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvJoinCodeDialog.kt +++ /dev/null @@ -1,276 +0,0 @@ -package org.prairieserver.prairie.tv.ui.screens.watchtogether - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.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.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupProperties -import androidx.tv.material3.Border -import androidx.tv.material3.ClickableSurfaceDefaults -import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.Glow -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Surface -import androidx.tv.material3.Text -import org.prairieserver.prairie.tv.ui.components.rememberTvDialogInitialFocus -import org.prairieserver.prairie.tv.ui.focus.TvControlState -import org.prairieserver.prairie.tv.ui.focus.tvControlSemantics -import org.prairieserver.prairie.tv.ui.screens.player.TvDialogActionRow -import org.prairieserver.prairie.tv.ui.theme.DarkBackground -import org.prairieserver.prairie.tv.ui.theme.FocusedContainer -import org.prairieserver.prairie.tv.ui.theme.FocusedContent -import org.prairieserver.prairie.watchtogether.canDismissRoomEntry - -/** - * Pure 8-char D-pad join-code accumulator. Appends only A–Z / 0–9 (uppercasing - * letters), caps at [LENGTH], and supports backspace/clear. Kept free of any - * Android types so it is unit-testable without Robolectric. - */ -data class JoinCodeState(val code: String = "") { - val isComplete: Boolean get() = code.length == LENGTH - fun append(c: Char): JoinCodeState { - if (code.length >= LENGTH) return this - val up = c.uppercaseChar() - return if (up in 'A'..'Z' || up in '0'..'9') copy(code = code + up) else this - } - fun backspace() = if (code.isEmpty()) this else copy(code = code.dropLast(1)) - fun clear() = JoinCodeState() - companion object { const val LENGTH = 8 } -} - -/** Keys laid out in the focusable A–Z / 0–9 grid, six per row. */ -private val JoinCodeKeys: List<Char> = ('A'..'Z') + ('0'..'9') -private const val KEYS_PER_ROW = 6 - -/** - * D-pad join-by-code dialog. Panel + row idiom mirrors - * [org.prairieserver.prairie.tv.ui.screens.player.TvSubtitleSearchDialog]. Shows a large - * mono display of the running code (placeholder dashes for unfilled slots), a - * focusable A–Z/0–9 key grid (each key appends into a [JoinCodeState]), a Delete - * row (backspace), and a "Join" [TvDialogActionRow] enabled when the code is - * complete and not busy. - */ -@Composable -fun TvJoinCodeDialog( - isBusy: Boolean, - error: String?, - onJoin: (String) -> Unit, - onDismiss: () -> Unit, -) { - var state by remember { mutableStateOf(JoinCodeState()) } - val firstKeyFocus = remember { FocusRequester() } - - Popup( - alignment = Alignment.Center, - onDismissRequest = { - if (canDismissRoomEntry(isBusy)) onDismiss() - }, - properties = PopupProperties( - focusable = true, - dismissOnBackPress = canDismissRoomEntry(isBusy), - dismissOnClickOutside = canDismissRoomEntry(isBusy), - clippingEnabled = false, - ), - ) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(start = 36.dp, top = 40.dp, end = 36.dp, bottom = 30.dp), - contentAlignment = Alignment.Center, - ) { - val panelShape = RoundedCornerShape(14.dp) - Column( - modifier = Modifier - .width(280.dp) - .background(color = DarkBackground.copy(alpha = 0.68f), shape = panelShape) - .border(0.6.dp, Color.White.copy(alpha = 0.20f), panelShape) - .padding(horizontal = 14.dp, vertical = 14.dp) - .then(rememberTvDialogInitialFocus(firstKeyFocus)), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = "JOIN BY CODE", - style = MaterialTheme.typography.labelMedium.copy( - fontSize = 16.sp, - letterSpacing = 1.1.sp, - fontWeight = FontWeight.Bold, - ), - color = Color.White.copy(alpha = 0.58f), - modifier = Modifier.padding(horizontal = 8.dp), - ) - - // Large mono display: filled chars + dashes for empty slots. - val display = (0 until JoinCodeState.LENGTH).joinToString(" ") { i -> - state.code.getOrNull(i)?.toString() ?: "–" - } - Text( - text = display, - style = MaterialTheme.typography.headlineMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = 16.sp, - letterSpacing = 2.sp, - fontWeight = FontWeight.Bold, - ), - color = Color.White, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp), - ) - - // Focusable A–Z / 0–9 grid. - JoinCodeKeys.chunked(KEYS_PER_ROW).forEachIndexed { rowIndex, rowKeys -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - rowKeys.forEachIndexed { colIndex, ch -> - JoinCodeKey( - char = ch, - // Joining is in flight, not a dead end — the - // grid keeps its focus so the ring survives a - // failed join. - controlState = TvControlState.transient(!isBusy), - onClick = { state = state.append(ch) }, - modifier = if (rowIndex == 0 && colIndex == 0) { - Modifier - .weight(1f) - .focusRequester(firstKeyFocus) - } else { - Modifier.weight(1f) - }, - ) - } - // Pad the final partial row so keys keep a consistent width. - repeat(KEYS_PER_ROW - rowKeys.size) { - Box(modifier = Modifier.weight(1f)) - } - } - } - - TvDialogActionRow( - title = "Delete", - enabled = !isBusy && state.code.isNotEmpty(), - onClick = { state = state.backspace() }, - ) - - TvDialogActionRow( - title = if (isBusy) "Joining…" else "Join", - enabled = state.isComplete && !isBusy, - onClick = { onJoin(state.code) }, - ) - - error?.let { message -> - Text( - text = message, - style = MaterialTheme.typography.bodySmall, - color = Color(0xFFEF4444), - modifier = Modifier.padding(horizontal = 8.dp), - ) - } - } - } - } -} - -/** Single focusable key tile in the join-code grid. */ -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun JoinCodeKey( - char: Char, - controlState: TvControlState, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val interactionSource = remember { MutableInteractionSource() } - val isFocused by interactionSource.collectIsFocusedAsState() - val shape = RoundedCornerShape(12.dp) - val enabled = controlState.actionable - - Surface( - onClick = { controlState.perform(onClick) }, - enabled = controlState.focusable, - interactionSource = interactionSource, - shape = ClickableSurfaceDefaults.shape(shape = shape), - // Dimmed from `actionable`, not from the Surface's disabled slots: the - // key stays focusable while a join is in flight, so it never enters the - // disabled colour path. - colors = ClickableSurfaceDefaults.colors( - containerColor = if (enabled) { - Color.White.copy(alpha = 0.06f) - } else { - Color.White.copy(alpha = 0.03f) - }, - contentColor = if (enabled) Color.White else Color.White.copy(alpha = 0.42f), - focusedContainerColor = FocusedContainer, - focusedContentColor = FocusedContent, - pressedContainerColor = FocusedContainer, - pressedContentColor = FocusedContent, - disabledContainerColor = Color.White.copy(alpha = 0.03f), - disabledContentColor = Color.White.copy(alpha = 0.38f), - ), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f), - border = ClickableSurfaceDefaults.border( - focusedBorder = Border( - border = BorderStroke(2.dp, DarkBackground.copy(alpha = 0.82f)), - shape = shape, - ), - ), - glow = ClickableSurfaceDefaults.glow( - focusedGlow = Glow( - elevationColor = Color.White.copy(alpha = 0.18f), - elevation = 14.dp, - ), - ), - modifier = modifier - .height(24.dp) - .then( - if (isFocused) { - Modifier.border(2.dp, Color.White.copy(alpha = 0.98f), shape) - } else { - Modifier - }, - ) - .tvControlSemantics(controlState), - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Text( - text = char.toString(), - style = MaterialTheme.typography.titleMedium.copy( - fontSize = 18.sp, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.SemiBold, - ), - color = if (isFocused) FocusedContent else Color.White, - ) - } - } -} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvSuggestToRoomViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvSuggestToRoomViewModel.kt deleted file mode 100644 index 219f21a689..0000000000 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvSuggestToRoomViewModel.kt +++ /dev/null @@ -1,61 +0,0 @@ -package org.prairieserver.prairie.tv.ui.screens.watchtogether - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import org.prairieserver.prairie.model.watchtogether.AddSuggestionRequest -import org.prairieserver.prairie.model.watchtogether.RoomSnapshot -import org.prairieserver.prairie.network.ApiResult -import org.prairieserver.prairie.network.errorMessage -import org.prairieserver.prairie.repository.WatchTogetherRepository - -class TvSuggestToRoomViewModel( - private val repository: WatchTogetherRepository, -) : ViewModel() { - data class UiState( - val isBusy: Boolean = false, - val notice: String? = null, - val error: String? = null, - ) - - private val _uiState = MutableStateFlow(UiState()) - val uiState: StateFlow<UiState> = _uiState.asStateFlow() - val room: StateFlow<RoomSnapshot?> = repository.roomSnapshot - - fun suggest( - contentId: String, - contentType: String, - title: String, - subtitle: String?, - posterUrl: String?, - ) { - if (_uiState.value.isBusy) return - _uiState.update { it.copy(isBusy = true, error = null, notice = null) } - viewModelScope.launch { - val result = repository.addSuggestion( - AddSuggestionRequest( - contentId = contentId, - contentType = contentType, - title = title, - subtitle = subtitle, - posterUrl = posterUrl, - ), - ) - when (result) { - is ApiResult.Success -> - _uiState.update { it.copy(isBusy = false, notice = "Suggested to the room") } - is ApiResult.Error, is ApiResult.NetworkError -> - _uiState.update { - it.copy(isBusy = false, error = result.errorMessage("Could not suggest that")) - } - } - } - } - - fun consumeNotice() = _uiState.update { it.copy(notice = null) } - fun clearError() = _uiState.update { it.copy(error = null) } -} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherDestination.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherDestination.kt deleted file mode 100644 index 788a4181b5..0000000000 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherDestination.kt +++ /dev/null @@ -1,20 +0,0 @@ -package org.prairieserver.prairie.tv.ui.screens.watchtogether - -import org.prairieserver.prairie.model.watchtogether.RoomSnapshot -import org.prairieserver.prairie.tv.ui.navigation.TvRoute -import org.prairieserver.prairie.watchtogether.WatchTogetherEntryTarget -import org.prairieserver.prairie.watchtogether.watchTogetherEntryTarget - -fun tvWatchTogetherDestination(room: RoomSnapshot): String = - when (watchTogetherEntryTarget(room)) { - WatchTogetherEntryTarget.Lobby -> - TvRoute.WatchTogetherLobby(room.roomId).route - WatchTogetherEntryTarget.Player -> - TvRoute.Player( - contentId = requireNotNull(room.selectedContentId), - fileId = room.selectedFileId, - roomId = room.roomId, - resumePositionSeconds = room.anchorPositionSeconds - .takeIf { it.isFinite() && it > 0.0 }, - ).route - } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherEntryDialog.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherEntryDialog.kt deleted file mode 100644 index 3f4b84cd43..0000000000 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherEntryDialog.kt +++ /dev/null @@ -1,117 +0,0 @@ -package org.prairieserver.prairie.tv.ui.screens.watchtogether - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupProperties -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import org.prairieserver.prairie.tv.ui.components.rememberTvDialogInitialFocus -import org.prairieserver.prairie.tv.ui.screens.player.TvDialogActionRow -import org.prairieserver.prairie.tv.ui.theme.DarkBackground -import org.prairieserver.prairie.watchtogether.canDismissRoomEntry - -/** - * D-pad Watch Together entry dialog. Panel + row idiom mirrors - * [org.prairieserver.prairie.tv.ui.screens.player.TvSubtitleSearchDialog] (Popup, dark - * panel, [TvDialogActionRow] rows). "Host a room" (auto-focused) creates a room - * with the current title pre-selected; "Join by code" opens the [TvJoinCodeDialog]. - */ -@Composable -fun TvWatchTogetherEntryDialog( - isBusy: Boolean, - error: String?, - onHost: () -> Unit, - onHostVote: () -> Unit, - onJoin: () -> Unit, - onDismiss: () -> Unit, -) { - val hostFocus = remember { FocusRequester() } - - Popup( - alignment = Alignment.Center, - onDismissRequest = { - if (canDismissRoomEntry(isBusy)) onDismiss() - }, - properties = PopupProperties( - focusable = true, - dismissOnBackPress = canDismissRoomEntry(isBusy), - dismissOnClickOutside = canDismissRoomEntry(isBusy), - clippingEnabled = false, - ), - ) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(start = 36.dp, top = 50.dp, end = 36.dp, bottom = 42.dp), - contentAlignment = Alignment.Center, - ) { - val panelShape = RoundedCornerShape(14.dp) - Column( - modifier = Modifier - .width(340.dp) - .background(color = DarkBackground.copy(alpha = 0.68f), shape = panelShape) - .border(0.6.dp, Color.White.copy(alpha = 0.20f), panelShape) - .padding(horizontal = 14.dp, vertical = 14.dp) - .then(rememberTvDialogInitialFocus(hostFocus)), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - Text( - text = "WATCH TOGETHER", - style = MaterialTheme.typography.labelMedium.copy( - fontSize = 16.sp, - letterSpacing = 1.1.sp, - fontWeight = FontWeight.Bold, - ), - color = Color.White.copy(alpha = 0.58f), - modifier = Modifier.padding(horizontal = 8.dp), - ) - - TvDialogActionRow( - title = if (isBusy) "Working…" else "Host a room", - enabled = !isBusy, - onClick = onHost, - modifier = Modifier.focusRequester(hostFocus), - ) - - TvDialogActionRow( - title = if (isBusy) "Working…" else "Host a vote room", - enabled = !isBusy, - onClick = onHostVote, - ) - - TvDialogActionRow( - title = "Join by code", - enabled = !isBusy, - onClick = onJoin, - ) - - error?.let { message -> - Text( - text = message, - style = MaterialTheme.typography.bodySmall, - color = Color(0xFFEF4444), - modifier = Modifier.padding(horizontal = 8.dp), - ) - } - } - } - } -} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyScreen.kt deleted file mode 100644 index 9e420a9027..0000000000 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyScreen.kt +++ /dev/null @@ -1,567 +0,0 @@ -package org.prairieserver.prairie.tv.ui.screens.watchtogether - -import android.widget.Toast -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Star -import androidx.compose.material.icons.outlined.StarBorder -import androidx.compose.runtime.Composable -import androidx.compose.runtime.key -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontFamily -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.tv.material3.Border -import androidx.tv.material3.ClickableSurfaceDefaults -import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.Glow -import androidx.tv.material3.Icon -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Surface -import androidx.tv.material3.Text -import coil3.compose.AsyncImage -import org.prairieserver.prairie.model.watchtogether.GuestControlPolicy -import org.prairieserver.prairie.model.watchtogether.MemberRole -import org.prairieserver.prairie.model.watchtogether.RoomPhase -import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode -import org.prairieserver.prairie.model.watchtogether.RoomSnapshot -import org.prairieserver.prairie.model.watchtogether.Suggestion -import org.prairieserver.prairie.tv.ui.navigation.TvRoute -import org.prairieserver.prairie.tv.ui.screens.auth.QrCodePanel -import org.prairieserver.prairie.tv.ui.screens.player.TvDialogActionRow -import org.prairieserver.prairie.tv.ui.screens.player.TvDialogCyclerRow -import org.prairieserver.prairie.tv.ui.theme.DarkBackground -import org.prairieserver.prairie.tv.ui.theme.FocusedContainer -import org.prairieserver.prairie.tv.ui.theme.FocusedContent -import org.prairieserver.prairie.tv.ui.theme.PrairieBlue -import org.prairieserver.prairie.watchtogether.isVoteRoom -import org.prairieserver.prairie.watchtogether.roomVoteWinner -import org.koin.compose.viewmodel.koinViewModel -import org.koin.core.parameter.parametersOf - -/** - * Pure: should the lobby hand off to the synced player? Only once the room is - * actually [RoomPhase.Playing] AND a (non-blank) selection has landed — mirrors - * the phone lobby's `lobbyPlayerDestinationOrNull` semantics (enum compare + - * isNullOrBlank, NOT a wire-string compare). - */ -fun shouldEnterSyncedPlayer(s: RoomSnapshot?): Boolean { - if (s == null || s.phase != RoomPhase.Playing || s.selectedContentId.isNullOrBlank()) return false - if (s.selfRole == MemberRole.Host && s.memberCount <= 1) return false - return true -} - -/** - * Watch Together lobby (TV). Shows member count / role / selection mode, the - * host block (invite code shown LARGE + selection-mode/policy cyclers + per-row - * promote/remove, gated on the server's per-recipient management capability), - * and the suggestion list (focusable rows; Select toggles vote/unvote). Auto- - * navigates everyone into the synced player once a selection lands and the room - * starts playing. - * - * Host pre-selection comes from item-detail (the entry dialog) and there is no - * reusable TV title-picker, so this screen is vote/promote/pick-only: there is - * no "add a suggestion" affordance on TV (it would require building a picker). - * - * [onNavigateToPlayer] is called with a fully-built player route (computed from - * the snapshot, mirroring the phone lobby); [onBack] backs out of the lobby. - */ -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -fun TvWatchTogetherLobbyScreen( - roomId: String, - onNavigateToPlayer: (route: String) -> Unit, - onBack: () -> Unit, - viewModel: TvWatchTogetherLobbyViewModel = koinViewModel( - key = "wt-lobby-$roomId", - parameters = { parametersOf(roomId) }, - ), -) { - val room by viewModel.room.collectAsState() - val suggestions by viewModel.suggestions.collectAsState() - val closedReason by viewModel.roomClosedReason.collectAsState() - val context = LocalContext.current - - // Role drives only the cosmetic header label; mutating controls gate on - // the server's per-recipient management capability so a demoted/ - // grace-period host (selfRole still "host" but management revoked) doesn't - // see dead buttons. - val snapshot = room - val isHostLabel = snapshot?.selfRole == MemberRole.Host - val canManage = snapshot?.selfCanManageRoom == true - val isVoteRoom = snapshot.isVoteRoom() - - // Auto-hand-off into the synced player once the room is playing + selected. - LaunchedEffect( - room?.phase, - room?.selectedContentId, - room?.selectedFileId, - room?.memberCount, - room?.selfRole, - ) { - val snapshot = room ?: return@LaunchedEffect - if (shouldEnterSyncedPlayer(snapshot)) { - onNavigateToPlayer( - TvRoute.Player( - contentId = snapshot.selectedContentId!!, - fileId = snapshot.selectedFileId, - roomId = snapshot.roomId, - ).route, - ) - } - } - - // Server already closed the room (host left / explicit close) — just tear - // down locally and back out; do NOT re-issue closeRoom. - LaunchedEffect(closedReason) { - if (closedReason != null) { - viewModel.leave() - onBack() - } - } - - LaunchedEffect(viewModel) { - viewModel.errors.collect { message -> - if (message.isNotBlank()) { - Toast.makeText(context, message, Toast.LENGTH_SHORT).show() - } - } - } - - // Browsing away retains the process-scoped room connection. Leaving is an - // explicit action below; hosts close the room for everyone via "Close room". - BackHandler(enabled = true) { - onBack() - } - - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - ) { - Row( - modifier = Modifier - .fillMaxSize() - .padding(start = 56.dp, end = 56.dp, top = 40.dp, bottom = 48.dp), - horizontalArrangement = Arrangement.spacedBy(48.dp), - ) { - Column( - modifier = Modifier.weight(0.42f), - verticalArrangement = Arrangement.spacedBy(20.dp), - ) { - // --- Header -------------------------------------------------------- - Text( - text = "WATCH TOGETHER", - style = MaterialTheme.typography.headlineMedium.copy( - fontWeight = FontWeight.Bold, - letterSpacing = 4.sp, - ), - color = Color.White, - ) - if (snapshot != null) { - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "${snapshot.memberCount} in room · ${if (isHostLabel) "Host" else "Guest"}", - style = MaterialTheme.typography.titleMedium, - color = Color.White.copy(alpha = 0.86f), - ) - Text( - text = "Mode: ${selectionModeLabel(snapshot.selectionMode)}", - style = MaterialTheme.typography.titleMedium, - color = Color.White.copy(alpha = 0.66f), - ) - if (!snapshot.hostConnected) { - Text( - text = "Host disconnected", - style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.SemiBold, - ), - color = Color(0xFFFFB4A9), - ) - } - } - } - - if (snapshot == null) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text( - text = "Connecting…", - style = MaterialTheme.typography.titleLarge, - color = Color.White.copy(alpha = 0.7f), - ) - } - } else { - // --- Host block (gated on manage capability) ------------------- - if (canManage) { - Text( - text = "JOIN CODE", - style = MaterialTheme.typography.labelLarge.copy(letterSpacing = 2.sp), - color = Color.White.copy(alpha = 0.56f), - ) - Text( - text = snapshot.code, - style = MaterialTheme.typography.displaySmall.copy( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - letterSpacing = 12.sp, - ), - color = Color.White, - ) - // Scannable invite: a guest can point their phone camera at - // the QR to read the join code instead of typing it. (TVs - // have no share targets, so a QR is the parity equivalent of - // the phone's share-code action.) Nested Column so the parent - // 20dp spacing doesn't balloon around the QR + caption. - if (snapshot.code.isNotBlank()) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - QrCodePanel(content = snapshot.code, size = 140.dp) - Text( - text = "Scan to get the code", - style = MaterialTheme.typography.labelMedium, - color = Color.White.copy(alpha = 0.5f), - ) - } - } - } - } - } - - Column( - modifier = Modifier - .weight(0.58f) - .verticalScroll(rememberScrollState()) - .padding(bottom = 48.dp), - verticalArrangement = Arrangement.spacedBy(20.dp), - ) { - if (snapshot != null) { - TvDialogActionRow( - title = "Browse titles", - onClick = onBack, - ) - TvDialogActionRow( - title = "Leave room", - onClick = { - viewModel.leave() - onBack() - }, - ) - if (canManage) { - // Selection mode is fixed at room creation — shown read-only. - TvDialogCyclerRow( - title = "Selection mode", - value = selectionModeLabel(snapshot.selectionMode), - onPrevious = {}, - onNext = {}, - enabled = false, - ) - TvDialogCyclerRow( - title = "Guest controls", - value = policyLabel(snapshot.guestControlPolicy), - onPrevious = { viewModel.updatePolicy(nextPolicy(snapshot.guestControlPolicy).wire) }, - onNext = { viewModel.updatePolicy(nextPolicy(snapshot.guestControlPolicy).wire) }, - ) - // Explicit host close — mirrors mobile's "Close" action. - // Stays on screen; the room_closed broadcast then drives the - // back-out via the closedReason effect above. - CloseRoomButton(onClick = viewModel::closeRoom) - } - - val winner = roomVoteWinner(suggestions) - if (isVoteRoom && canManage) { - TvDialogActionRow( - title = winner?.let { "Start winner: ${it.title}" } - ?: "Start winner — no votes yet", - enabled = winner != null, - onClick = { winner?.let { viewModel.promote(it.id) } }, - ) - } - - Text( - text = "SUGGESTIONS", - style = MaterialTheme.typography.labelLarge.copy(letterSpacing = 2.sp), - color = Color.White.copy(alpha = 0.56f), - ) - if (suggestions.isEmpty()) { - Text( - text = "No suggestions yet.", - style = MaterialTheme.typography.bodyLarge, - color = Color.White.copy(alpha = 0.5f), - ) - } else { - Column( - verticalArrangement = Arrangement.spacedBy(10.dp), - modifier = Modifier.fillMaxWidth(), - ) { - suggestions.forEach { s -> - key(s.id) { - SuggestionRow( - suggestion = s, - canManage = canManage, - isWinning = isVoteRoom && winner?.id == s.id, - onVote = { - if (s.votedByMe) viewModel.unvote(s.id) else viewModel.vote(s.id) - }, - onPromote = { viewModel.promote(s.id) }, - onRemove = { viewModel.removeSuggestion(s.id) }, - ) - } - } - } - } - } - } - } - } -} - -private fun selectionModeLabel(mode: RoomSelectionMode): String = when (mode) { - RoomSelectionMode.HostPick -> "Host pick" - RoomSelectionMode.Vote -> "Vote" - RoomSelectionMode.Unknown -> "—" -} - -private fun policyLabel(policy: GuestControlPolicy): String = when (policy) { - GuestControlPolicy.HostOnly -> "Host only" - GuestControlPolicy.GuestPlayPause -> "Guests play/pause" - GuestControlPolicy.Unknown -> "—" -} - -/** Cycle between the two real policies; Unknown maps forward to HostOnly. */ -private fun nextPolicy(policy: GuestControlPolicy): GuestControlPolicy = when (policy) { - GuestControlPolicy.HostOnly -> GuestControlPolicy.GuestPlayPause - GuestControlPolicy.GuestPlayPause -> GuestControlPolicy.HostOnly - GuestControlPolicy.Unknown -> GuestControlPolicy.HostOnly -} - -/** - * Focusable suggestion row — reuses the `Surface` + [MutableInteractionSource] - * focus idiom from `TvSubtitleResultRow`. Select toggles vote/unvote; a host - * (manage-capable) row exposes Promote + Remove sub-actions. - */ -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun SuggestionRow( - suggestion: Suggestion, - canManage: Boolean, - isWinning: Boolean, - onVote: () -> Unit, - onPromote: () -> Unit, - onRemove: () -> Unit, -) { - val interactionSource = remember { MutableInteractionSource() } - val isFocused by interactionSource.collectIsFocusedAsState() - val shape = RoundedCornerShape(16.dp) - - Surface( - onClick = onVote, - interactionSource = interactionSource, - shape = ClickableSurfaceDefaults.shape(shape = shape), - colors = ClickableSurfaceDefaults.colors( - containerColor = Color.White.copy(alpha = 0.04f), - contentColor = Color.White, - focusedContainerColor = FocusedContainer, - focusedContentColor = FocusedContent, - pressedContainerColor = FocusedContainer, - pressedContentColor = FocusedContent, - ), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.02f), - border = ClickableSurfaceDefaults.border( - focusedBorder = Border( - border = BorderStroke(2.dp, DarkBackground.copy(alpha = 0.82f)), - shape = shape, - ), - ), - glow = ClickableSurfaceDefaults.glow( - focusedGlow = Glow( - elevationColor = Color.White.copy(alpha = 0.18f), - elevation = 16.dp, - ), - ), - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 64.dp) - .then( - if (isFocused) { - Modifier.border(2.dp, Color.White.copy(alpha = 0.98f), shape) - } else { - Modifier - }, - ), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 10.dp), - horizontalArrangement = Arrangement.spacedBy(14.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (suggestion.posterUrl.isNotBlank()) { - AsyncImage( - model = suggestion.posterUrl, - contentDescription = null, - modifier = Modifier - .size(width = 36.dp, height = 54.dp), - ) - } - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(3.dp), - ) { - Text( - text = suggestion.title, - style = MaterialTheme.typography.titleMedium.copy( - fontSize = 17.sp, - fontWeight = FontWeight.SemiBold, - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = if (isFocused) FocusedContent else Color.White, - ) - if (suggestion.subtitle.isNotBlank()) { - Text( - text = suggestion.subtitle, - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = if (isFocused) { - FocusedContent.copy(alpha = 0.7f) - } else { - Color.White.copy(alpha = 0.56f) - }, - ) - } - } - // Vote glyph: filled when votedByMe, outline otherwise. - Icon( - imageVector = if (suggestion.votedByMe) Icons.Filled.Star else Icons.Outlined.StarBorder, - contentDescription = if (suggestion.votedByMe) "Voted" else "Vote", - tint = if (isFocused) FocusedContent else Color.White, - ) - Spacer(Modifier.width(4.dp)) - Text( - text = suggestion.voteCount.toString(), - style = MaterialTheme.typography.titleMedium, - color = if (isFocused) FocusedContent else Color.White, - ) - if (isWinning) { - Spacer(Modifier.width(8.dp)) - Text( - text = "WINNING", - style = MaterialTheme.typography.labelMedium.copy( - fontWeight = FontWeight.Bold, - letterSpacing = 1.sp, - ), - color = if (isFocused) FocusedContent else PrairieBlue, - ) - } - if (canManage) { - Spacer(Modifier.width(8.dp)) - RowAction(text = "Pick", onClick = onPromote) - RowAction(text = "Remove", onClick = onRemove) - } - } - } -} - -/** Compact focusable sub-action button used for host Promote/Remove. */ -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun RowAction(text: String, onClick: () -> Unit) { - val interactionSource = remember { MutableInteractionSource() } - val isFocused by interactionSource.collectIsFocusedAsState() - val shape = RoundedCornerShape(10.dp) - Surface( - onClick = onClick, - interactionSource = interactionSource, - shape = ClickableSurfaceDefaults.shape(shape = shape), - colors = ClickableSurfaceDefaults.colors( - containerColor = Color.White.copy(alpha = 0.10f), - contentColor = Color.White, - focusedContainerColor = FocusedContainer, - focusedContentColor = FocusedContent, - pressedContainerColor = FocusedContainer, - pressedContentColor = FocusedContent, - ), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f), - modifier = Modifier.then( - if (isFocused) Modifier.border(2.dp, Color.White.copy(alpha = 0.98f), shape) else Modifier, - ), - ) { - Text( - text = text, - style = MaterialTheme.typography.labelLarge, - color = if (isFocused) FocusedContent else Color.White, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - ) - } -} - -/** - * Host-only "Close room for everyone" action — the TV equivalent of mobile's - * "Close" button. Clicking closes the room server-side while staying on the - * lobby; the resulting room_closed broadcast drives the back-out reactively. - */ -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun CloseRoomButton(onClick: () -> Unit) { - val interactionSource = remember { MutableInteractionSource() } - val isFocused by interactionSource.collectIsFocusedAsState() - val shape = RoundedCornerShape(12.dp) - Surface( - onClick = onClick, - interactionSource = interactionSource, - shape = ClickableSurfaceDefaults.shape(shape = shape), - colors = ClickableSurfaceDefaults.colors( - containerColor = Color(0xFF7A2620).copy(alpha = 0.42f), - contentColor = Color(0xFFFFB4A9), - focusedContainerColor = Color(0xFFB3261E), - focusedContentColor = Color.White, - pressedContainerColor = Color(0xFFB3261E), - pressedContentColor = Color.White, - ), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.02f), - modifier = Modifier.then( - if (isFocused) Modifier.border(2.dp, Color.White.copy(alpha = 0.98f), shape) else Modifier, - ), - ) { - Text( - text = "Close room for everyone", - style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.SemiBold), - modifier = Modifier.padding(horizontal = 18.dp, vertical = 12.dp), - ) - } -} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyViewModel.kt deleted file mode 100644 index b211ed207a..0000000000 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyViewModel.kt +++ /dev/null @@ -1,101 +0,0 @@ -package org.prairieserver.prairie.tv.ui.screens.watchtogether - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import org.prairieserver.prairie.model.watchtogether.PromoteSuggestionRequest -import org.prairieserver.prairie.model.watchtogether.RoomSnapshot -import org.prairieserver.prairie.model.watchtogether.Suggestion -import org.prairieserver.prairie.model.watchtogether.UpdatePolicyRequest -import org.prairieserver.prairie.network.ApiResult -import org.prairieserver.prairie.network.errorMessage -import org.prairieserver.prairie.repository.WatchTogetherRepository -import org.prairieserver.prairie.watchtogether.RoomSession -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch - -/** - * Backs [TvWatchTogetherLobbyScreen]. Binds the per-room websocket on enter so - * snapshots/suggestions flow into the state flows, and exposes vote / unvote / - * host-promote / host-pick / policy / close-room ops. Mirrors the phone - * [org.prairieserver.prairie.android.ui.screens.watchtogether.WatchTogetherLobbyViewModel]. - * - * The repository owns the room JWT and reads the active roomId from its own - * snapshot, so the room-scoped ops take only request/id params (not a roomId). - * The application-scoped [RoomSession] owns reconnect and survives navigation - * from this lobby into the synced player. - */ -class TvWatchTogetherLobbyViewModel( - private val roomId: String, - private val repository: WatchTogetherRepository, - private val roomSession: RoomSession, -) : ViewModel() { - - init { - roomSession.adopt(roomId) - } - - val room: StateFlow<RoomSnapshot?> = repository.roomSnapshot - .stateIn(viewModelScope, SharingStarted.Eagerly, repository.roomSnapshot.value) - val suggestions: StateFlow<List<Suggestion>> = repository.suggestions - .stateIn(viewModelScope, SharingStarted.Eagerly, repository.suggestions.value) - val roomClosedReason: StateFlow<String?> = repository.roomClosedReason - .stateIn(viewModelScope, SharingStarted.Eagerly, repository.roomClosedReason.value) - val errors = repository.errors - - fun vote(suggestionId: String) = - launchOperation("Could not vote") { repository.vote(suggestionId) } - - fun unvote(suggestionId: String) = - launchOperation("Could not remove vote") { repository.unvote(suggestionId) } - - fun removeSuggestion(suggestionId: String) = - launchOperation("Could not remove suggestion") { - repository.deleteSuggestion(suggestionId) - } - - /** Host: promote a suggestion to the room selection (moves everyone to the player). */ - fun promote(suggestionId: String) = - launchOperation("Could not start suggestion") { - repository.promoteSuggestion(PromoteSuggestionRequest(suggestionId = suggestionId)) - } - - /** Host: change the guest-control policy. */ - fun updatePolicy(guestControlPolicyWire: String) = - launchOperation("Could not update room policy") { - repository.updatePolicy(UpdatePolicyRequest(guestControlPolicy = guestControlPolicyWire)) - } - - /** Host: close the room for everyone. */ - fun closeRoom() = launchOperation("Could not close room") { repository.closeRoom() } - - private fun <T> launchOperation( - fallback: String, - operation: suspend () -> ApiResult<T>, - ) = viewModelScope.launch { - val result = operation() - if (result !is ApiResult.Success) { - repository.reportDeliveryFailure(result.errorMessage(fallback)) - } - } - - /** - * Leave the room: tear down our own WS and reset shared repo state. Matches - * mobile — leaving does NOT close the room for everyone. A host closes the - * room explicitly via [closeRoom] (and stays on the lobby until the server's - * `room_closed` broadcast reactively backs them out); otherwise the server's - * host-left timeout reaps it. Closing on leave used to race the closeRoom - * network call against viewModelScope cancellation during screen dispose. - */ - fun leave() { - roomSession.depart() - } - - override fun onCleared() { - super.onCleared() - // Do NOT reset here — the player binding reuses the same repo connection - // when we auto-navigate into the synced player. reset() is called - // explicitly via leave() when the user backs out without playing. - } -} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherMenuEntryDialog.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherMenuEntryDialog.kt deleted file mode 100644 index 0eb4c82957..0000000000 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherMenuEntryDialog.kt +++ /dev/null @@ -1,140 +0,0 @@ -package org.prairieserver.prairie.tv.ui.screens.watchtogether - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupProperties -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import org.prairieserver.prairie.tv.ui.components.rememberTvDialogInitialFocus -import org.prairieserver.prairie.tv.ui.screens.player.TvDialogActionRow -import org.prairieserver.prairie.tv.ui.theme.DarkBackground -import org.prairieserver.prairie.watchtogether.canDismissRoomEntry - -enum class TvWatchTogetherMenuInitialAction { - Resume, - Host, -} - -fun tvWatchTogetherMenuInitialAction( - canResume: Boolean, -): TvWatchTogetherMenuInitialAction = - if (canResume) { - TvWatchTogetherMenuInitialAction.Resume - } else { - TvWatchTogetherMenuInitialAction.Host - } - -/** - * Focus-owning Watch Together entry popup launched from the authenticated - * profile dropdown. Resume is initially focused when a current room exists; - * otherwise Host owns initial focus. - */ -@Composable -fun TvWatchTogetherMenuEntryDialog( - canResume: Boolean, - isBusy: Boolean, - error: String?, - onResume: () -> Unit, - onHost: () -> Unit, - onJoin: () -> Unit, - onDismiss: () -> Unit, -) { - val resumeFocus = remember { FocusRequester() } - val hostFocus = remember { FocusRequester() } - val initialAction = tvWatchTogetherMenuInitialAction(canResume) - val initialFocus = when (initialAction) { - TvWatchTogetherMenuInitialAction.Resume -> resumeFocus - TvWatchTogetherMenuInitialAction.Host -> hostFocus - } - - Popup( - alignment = Alignment.Center, - onDismissRequest = { - if (canDismissRoomEntry(isBusy)) onDismiss() - }, - properties = PopupProperties( - focusable = true, - dismissOnBackPress = canDismissRoomEntry(isBusy), - dismissOnClickOutside = canDismissRoomEntry(isBusy), - clippingEnabled = false, - ), - ) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(start = 36.dp, top = 50.dp, end = 36.dp, bottom = 42.dp), - contentAlignment = Alignment.Center, - ) { - val panelShape = RoundedCornerShape(14.dp) - Column( - modifier = Modifier - .width(340.dp) - .background(color = DarkBackground.copy(alpha = 0.68f), shape = panelShape) - .border(0.6.dp, Color.White.copy(alpha = 0.20f), panelShape) - .padding(horizontal = 14.dp, vertical = 14.dp) - .then(rememberTvDialogInitialFocus(initialFocus)), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - Text( - text = "WATCH TOGETHER", - style = MaterialTheme.typography.labelMedium.copy( - fontSize = 16.sp, - letterSpacing = 1.1.sp, - fontWeight = FontWeight.Bold, - ), - color = Color.White.copy(alpha = 0.58f), - modifier = Modifier.padding(horizontal = 8.dp), - ) - - if (canResume) { - TvDialogActionRow( - title = "Resume current room", - enabled = !isBusy, - onClick = onResume, - modifier = Modifier.focusRequester(resumeFocus), - ) - } - - TvDialogActionRow( - title = if (isBusy) "Working…" else "Host a room", - enabled = !isBusy, - onClick = onHost, - modifier = Modifier.focusRequester(hostFocus), - ) - - TvDialogActionRow( - title = "Join by code", - enabled = !isBusy, - onClick = onJoin, - ) - - error?.let { message -> - Text( - text = message, - style = MaterialTheme.typography.bodySmall, - color = Color(0xFFEF4444), - modifier = Modifier.padding(horizontal = 8.dp), - ) - } - } - } - } -} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherViewModel.kt deleted file mode 100644 index c96438b1b5..0000000000 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherViewModel.kt +++ /dev/null @@ -1,155 +0,0 @@ -package org.prairieserver.prairie.tv.ui.screens.watchtogether - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import org.prairieserver.prairie.model.watchtogether.CreateRoomRequest -import org.prairieserver.prairie.model.watchtogether.JoinRoomRequest -import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode -import org.prairieserver.prairie.model.watchtogether.RoomSnapshot -import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest -import org.prairieserver.prairie.network.ApiResult -import org.prairieserver.prairie.network.errorMessage -import org.prairieserver.prairie.watchtogether.WatchTogetherEntryGateway -import org.prairieserver.prairie.watchtogether.resumableWatchTogetherRoom -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -/** - * Backs the TV Watch Together entry + join-code dialogs. Hosts a room (create + - * set this title as the room selection) or joins an existing room by invite code, - * then surfaces a one-shot [UiState.result] [RoomSnapshot] the detail screen routes - * on (host-with-selection → synced player, no selection → lobby). - * - * The gateway stores the room JWT internally on create/join and reads the - * active roomId from its own snapshot, so [WatchTogetherEntryGateway.setSelection] - * takes only the request. createRoom does NOT auto-select, so the host flow must - * createRoom THEN setSelection(contentId, fileId) so the host lands on the player. - * The ordering is safe because createRoom synchronously stores the snapshot before - * returning. Create/join/selection all return a `{room, room_access_token}` - * wrapper; the snapshot lives at `.data.room`. - * - * Mirrors the mobile `WatchTogetherEntryViewModel` host()/joinByCode() shape. - */ -class TvWatchTogetherViewModel( - private val gateway: WatchTogetherEntryGateway, -) : ViewModel() { - - data class UiState( - val isBusy: Boolean = false, - val error: String? = null, - /** Set once a create/join resolves — the screen observes this and navigates. */ - val result: RoomSnapshot? = null, - ) - - private val _uiState = MutableStateFlow(UiState()) - val uiState: StateFlow<UiState> = _uiState.asStateFlow() - val currentRoom: StateFlow<RoomSnapshot?> = gateway.roomSnapshot - .map(::resumableWatchTogetherRoom) - .stateIn( - viewModelScope, - SharingStarted.Eagerly, - resumableWatchTogetherRoom(gateway.roomSnapshot.value), - ) - - /** Host flow: create a room with this title pre-selected as the room selection. */ - fun createRoom( - contentId: String, - fileId: Int?, - selectionMode: RoomSelectionMode = RoomSelectionMode.HostPick, - ) { - if (selectionMode == RoomSelectionMode.Vote) { - createEmptyVoteRoom() - return - } - if (_uiState.value.isBusy) return - _uiState.update { it.copy(isBusy = true, error = null) } - viewModelScope.launch { - when ( - val created = gateway.createRoom( - CreateRoomRequest(selectionMode = selectionMode.wire), - ) - ) { - is ApiResult.Success -> { - // createRoom does NOT auto-select; set this title as the room - // selection so the host lands on the synced player. The gateway - // already stored the snapshot synchronously, so setSelection - // reads the right roomId/token. - if (created.data.room.selectedContentId.isNullOrBlank()) { - when ( - val sel = gateway.setSelection( - SetSelectionRequest(contentId = contentId, fileId = fileId), - ) - ) { - is ApiResult.Success -> finish(sel.data.room) - is ApiResult.Error, is ApiResult.NetworkError -> - fail(sel.errorMessage("Failed to set selection")) - } - } else { - finish(created.data.room) - } - } - is ApiResult.Error, is ApiResult.NetworkError -> - fail(created.errorMessage("Failed to create room")) - } - } - } - - /** Host flow for the menu action: create an empty vote room, then enter its lobby. */ - fun createEmptyVoteRoom() { - if (_uiState.value.isBusy) return - _uiState.update { it.copy(isBusy = true, error = null) } - viewModelScope.launch { - when ( - val created = gateway.createRoom( - CreateRoomRequest(selectionMode = RoomSelectionMode.Vote.wire), - ) - ) { - is ApiResult.Success -> finish(created.data.room) - is ApiResult.Error, is ApiResult.NetworkError -> - fail(created.errorMessage("Failed to create room")) - } - } - } - - /** Resume the valid room snapshot already held by the process session. */ - fun resumeCurrentRoom() { - if (_uiState.value.isBusy) return - val room = resumableWatchTogetherRoom(gateway.roomSnapshot.value) - if (room == null) { - fail("Current room is no longer available") - } else { - finish(room) - } - } - - /** Join flow: resolve an invite code; the screen routes to player or lobby. */ - fun joinRoom(code: String) { - val trimmed = code.trim().uppercase() - if (_uiState.value.isBusy || trimmed.isBlank()) return - _uiState.update { it.copy(isBusy = true, error = null) } - viewModelScope.launch { - when (val joined = gateway.joinRoom(JoinRoomRequest(code = trimmed))) { - is ApiResult.Success -> finish(joined.data.room) - is ApiResult.Error, is ApiResult.NetworkError -> - fail(joined.errorMessage("Could not join — check the code")) - } - } - } - - private fun finish(room: RoomSnapshot) { - _uiState.update { it.copy(isBusy = false, error = null, result = room) } - } - - private fun fail(message: String) { - _uiState.update { it.copy(isBusy = false, error = message) } - } - - fun consumeResult() = _uiState.update { it.copy(result = null) } - fun clearError() = _uiState.update { it.copy(error = null) } -} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvLibraryTabType.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvLibraryTabType.kt index 483e47dee2..f738f2c32c 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvLibraryTabType.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvLibraryTabType.kt @@ -71,13 +71,18 @@ enum class TvLibraryTabType { fun matches(library: UserLibrary): Boolean { val type = library.type.trim().lowercase() return when (this) { - Movies -> type in MOVIE_TYPES - Series -> type in SERIES_TYPES + Movies -> type in MOVIE_TYPES || type == "mixed" + Series -> type in SERIES_TYPES || type == "mixed" Music -> type in MUSIC_TYPES Audiobooks -> isAudiobookLikeLibraryType(library.type) } } + /** A mixed source keeps its server identity while each video tab has its own view. */ + fun mediaScope(library: UserLibrary): String? = + if (!library.type.trim().equals("mixed", ignoreCase = true)) null + else when (this) { Movies -> "movie"; Series -> "series"; else -> null } + companion object { // Subsets of the shared videoLibraryTypes set, split by tvOS's // movies-vs-series tab distinction (which the shared set doesn't make). diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt index 504b0904ab..ecb20af2fd 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt @@ -28,7 +28,6 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Logout -import androidx.compose.material.icons.filled.AutoAwesome import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.filled.Dns import androidx.compose.material.icons.filled.Favorite @@ -117,11 +116,10 @@ import org.prairieserver.prairie.common.ui.components.avatarRef import org.prairieserver.prairie.common.ui.components.rememberProfileAvatarImage import org.prairieserver.prairie.common.ui.components.rememberProfileServerUrl import org.prairieserver.prairie.model.catalog.BrowseItem -import org.prairieserver.prairie.model.feature.CLIENT_WATCH_TOGETHER_SURFACE_ENABLED import org.prairieserver.prairie.model.feature.LiveTvFeatureStore +import org.prairieserver.prairie.model.feature.WatchPartyExposure import org.prairieserver.prairie.model.feature.RequestsFeatureStore import org.prairieserver.prairie.model.personal.UserLibrary -import org.prairieserver.prairie.model.watchtogether.RoomSnapshot import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.ServerRegistry import org.prairieserver.prairie.repository.AuthRepository @@ -150,20 +148,18 @@ import org.prairieserver.prairie.tv.ui.screens.recommendations.TvRecommendations import org.prairieserver.prairie.tv.ui.screens.recommendations.SavedListSelection import org.prairieserver.prairie.tv.ui.screens.recommendations.TvForYouEntryRequest import org.prairieserver.prairie.tv.ui.screens.recommendations.TvForYouEntryRequestSaver -import org.prairieserver.prairie.tv.ui.screens.requests.TvMyRequestsScreen import org.prairieserver.prairie.tv.ui.screens.requests.TvRequestDetailScreen import org.prairieserver.prairie.tv.ui.screens.livetv.TvLiveTvPlayerScreen import org.prairieserver.prairie.tv.ui.screens.livetv.TvLiveTvScreen -import org.prairieserver.prairie.tv.ui.screens.requests.TvRequestsScreen +import org.prairieserver.prairie.tv.ui.screens.requests.TvRequestsPage import org.prairieserver.prairie.tv.ui.screens.search.TvSearchScreen import org.prairieserver.prairie.tv.ui.screens.settings.TvSettingsScreen -import org.prairieserver.prairie.tv.ui.screens.watchtogether.TvJoinCodeDialog -import org.prairieserver.prairie.tv.ui.screens.watchtogether.TvWatchTogetherMenuEntryDialog -import org.prairieserver.prairie.tv.ui.screens.watchtogether.TvWatchTogetherViewModel import org.prairieserver.prairie.tv.ui.theme.TvSkyline import org.prairieserver.prairie.tv.ui.util.visibleOnTv import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.common.ui.rememberViewerAccessKey +import org.prairieserver.prairie.network.AccessChangeSignals import org.prairieserver.prairie.tv.ui.focus.TvObservedFocusResult import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved import org.prairieserver.prairie.viewmodel.HomeViewModel @@ -206,6 +202,8 @@ fun TvMainShell( collectionId: String, title: String, libraryType: String, + collectionSource: String, + mediaScope: String?, ) -> Unit, onOpenCollectionDetail: (collectionId: String, title: String) -> Unit, onSignedOut: () -> Unit, @@ -213,7 +211,10 @@ fun TvMainShell( onSwitchServer: () -> Unit, onPairDevice: () -> Unit, onPlayItem: (contentId: String, type: String?, resumePositionSeconds: Double?) -> Unit, - onOpenWatchTogether: (RoomSnapshot) -> Unit, + /** Open the Watch Party hub. */ + onOpenWatchParty: () -> Unit, + // Plays the first pick of a shuffle started from a library screen. + onPlayShuffle: (shuffle: org.prairieserver.prairie.model.shuffle.Shuffle, libraryId: Int?) -> Unit = { _, _ -> }, onOpenPersonDetail: (personId: Long) -> Unit, ) { val nestedNav = rememberNavController() @@ -228,10 +229,12 @@ fun TvMainShell( // Prairie: Live TV is gated on the server having channels. val liveTvFeatureStore: LiveTvFeatureStore = koinInject() val metadataAiFeatureStore: org.prairieserver.prairie.model.feature.MetadataAiFeatureStore = koinInject() + val shuffleFeatureStore: org.prairieserver.prairie.model.feature.ShuffleFeatureStore = koinInject() val serverRegistry: ServerRegistry = koinInject() val reachabilityState by reachabilityMonitor.state.collectAsState() val requestsEnabled by requestsFeatureStore.isEnabled.collectAsState() val liveTvEnabled by liveTvFeatureStore.isEnabled.collectAsState() + val requestsResolved by requestsFeatureStore.isResolved.collectAsState() val activeServerEntry by serverRegistry.activeEntry.collectAsState() val tvLibraryScopeStore: TvLibraryScopeStore = koinInject() // One shell-scoped Home owner feeds both the Home screen and General's @@ -239,19 +242,9 @@ fun TvMainShell( // while the already-visible Home instance held the populated rows. val homeViewModel: HomeViewModel = koinViewModel(key = "tv-main-home") val serverUrl = rememberProfileServerUrl() - val watchTogetherViewModel = koinViewModel<TvWatchTogetherViewModel>() - val watchTogetherState by watchTogetherViewModel.uiState.collectAsState() - val currentWatchTogetherRoom by watchTogetherViewModel.currentRoom.collectAsState() - var watchTogetherEntryOpen by rememberSaveable { mutableStateOf(false) } - var watchTogetherJoinOpen by rememberSaveable { mutableStateOf(false) } - - LaunchedEffect(watchTogetherState.result) { - val room = watchTogetherState.result ?: return@LaunchedEffect - watchTogetherViewModel.consumeResult() - watchTogetherEntryOpen = false - watchTogetherJoinOpen = false - onOpenWatchTogether(room) - } + // Settings → Experimental → Watch Party decides whether the entry shows. + val watchPartyExposure: WatchPartyExposure = koinInject() + val watchPartyEnabled by watchPartyExposure.enabled.collectAsState() // The raw list of libraries visible to this profile on TV, sorted by the // server's sort order (ebook-like libraries filtered out by visibleOnTv). @@ -260,15 +253,48 @@ fun TvMainShell( // `visibleRoots` is only Home + Calendar, so a restored/deep-linked // `main/movies` route must NOT be treated as "type has no libraries" yet. var librariesLoaded by remember { mutableStateOf(false) } + // An access change can add or remove whole libraries, so the tab set is + // re-derived under the new policy (the previous list stays until then). + val viewerAccessKey = rememberViewerAccessKey(koinInject<AccessChangeSignals>()) + // The list leaves out libraries the profile hid; hiding or showing one + // on another device re-loads it. + val hiddenLibrariesRevision by personalDataRepository.hiddenLibrariesRevision.collectAsState() + // A failed load is retried on the next reachable probe: the revision that + // asked for it won't come again, and a library shown again on another + // device can only come back from the server. + var librariesReloadPending by remember { mutableStateOf(false) } + var librariesRetry by remember { mutableIntStateOf(0) } + LaunchedEffect(reachabilityState.status, reachabilityState.lastCheckedAtMs) { + if (librariesReloadPending && reachabilityState.status == ServerReachabilityStatus.Reachable) librariesRetry++ + } val libraries by produceState( initialValue = emptyList<UserLibrary>(), personalDataRepository, + hiddenLibrariesRevision, + librariesRetry, + viewerAccessKey, ) { - when (val result = personalDataRepository.listUserLibraries()) { - is ApiResult.Success -> + // Pending means the last load failed and none is running, so a probe + // never cancels a load still in flight. + librariesReloadPending = false + // Only the first load may fall back to the offline cache. A later one + // re-checks, so a failure stays a failure and stays pending, rather + // than a cached list that misses a library shown again since. + val result = if (librariesLoaded) { + personalDataRepository.recheckUserLibraries(value.mapTo(mutableSetOf()) { it.id }) + } else { + personalDataRepository.listUserLibraries() + } + when (result) { + is ApiResult.Success -> { value = result.data.visibleOnTv().sortedBy { it.sortOrder } + } + // Keep what's shown, minus a library hidden since. is ApiResult.Error, - is ApiResult.NetworkError -> Unit + is ApiResult.NetworkError -> { + value = personalDataRepository.withoutHidden(value) + librariesReloadPending = true + } } // Mark loaded even on error (we've attempted) so the redirect can run; // an empty list then legitimately means "no libraries for this profile". @@ -292,8 +318,8 @@ fun TvMainShell( showAudiobooksTab = runCatching { tvLibraryScopeStore.getShowAudiobooksTab() }.getOrDefault(false) showAudiobooksTabResolved = true } - val visibleRoots = remember(libraries, showAudiobooksTab) { - visibleTvRoots(libraries, showAudiobooks = showAudiobooksTab) + val visibleRoots = remember(libraries, showAudiobooksTab, requestsEnabled) { + visibleTvRoots(libraries, showAudiobooks = showAudiobooksTab, requestsEnabled = requestsEnabled) } // In-session scope/pill selections per library type. Scope selections are @@ -351,6 +377,12 @@ fun TvMainShell( calendarFocusHandoffPending = false } } + // Requests owns its entry focus the way Calendar does: the page claims its + // first card (or the card it opened a detail from) and the bar stays out of + // focus search until it has. + var requestsFocusHandoffPending by remember(currentRoute) { + mutableStateOf(currentRoute == TvMainRoute.Requests.route) + } LaunchedEffect(activeServerEntry?.id, activeServerEntry?.profileId) { requestsFeatureStore.reset() @@ -359,6 +391,8 @@ fun TvMainShell( liveTvFeatureStore.refresh() metadataAiFeatureStore.reset() metadataAiFeatureStore.refresh() + shuffleFeatureStore.reset() + shuffleFeatureStore.refresh() } val focusManager = LocalFocusManager.current @@ -504,8 +538,13 @@ fun TvMainShell( { libraryId, collectionId, title, libraryType -> restoreContentAfterDetail = true detailReturnRoot = null - onOpenLibraryCollectionDetail(libraryId, collectionId, title, libraryType) + onOpenLibraryCollectionDetail(libraryId, collectionId, title, libraryType, "library_collection", null) } + val openScopedCollection: (Int, String, String, String, Boolean) -> Unit = { libraryId, id, title, scope, isUser -> + restoreContentAfterDetail = true + detailReturnRoot = null + onOpenLibraryCollectionDetail(libraryId, id, title, "mixed", if (isUser) "user_collection" else "library_collection", scope) + } val openCollectionDetail: (String, String) -> Unit = { collectionId, title -> restoreContentAfterDetail = true detailReturnRoot = null @@ -556,6 +595,10 @@ fun TvMainShell( // top), while ordinary content re-entry keeps the focusRestorer()'s // last-focused card. var contentFocusRequest by remember { mutableIntStateOf(0) } + // Bumped only when the Requests tab is selected from the bar. Saveable, so + // a return to the shell from an outer route (a library title opened from + // Requests) restores the last card instead of reading as a fresh entry. + var requestsEntryRequest by rememberSaveable { mutableIntStateOf(0) } // Saveable, because the For You screen guards against replaying an entry // request with a SAVED "last applied sequence". A shell recreated with a // plain remember restarted the counter at 0 while the screen still held @@ -637,11 +680,21 @@ fun TvMainShell( // (QA 2026-07-08: Movies → Collections → black 'No collections' page). // null = unknown (still loading) → pill stays visible. var librariesWithCollections by remember { mutableStateOf<Set<Int>?>(null) } - LaunchedEffect(libraries) { + // Keyed on the access key too: an access change can alter which libraries + // have collections while the library list itself stays equal. + LaunchedEffect(libraries, viewerAccessKey) { if (libraries.isEmpty()) return@LaunchedEffect val ids = mutableSetOf<Int>() libraries.forEach { lib -> - val result = runCatching { sectionRepository.getLibraryCollections(lib.id) }.getOrNull() + // Rethrow cancellation: a restarted probe must stop here, not + // finish the loop and publish a partial set. + val result = try { + sectionRepository.getLibraryCollections(lib.id) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (_: Exception) { + null + } if (result is ApiResult.Success && result.data.isNotEmpty()) ids += lib.id } librariesWithCollections = ids @@ -674,7 +727,7 @@ fun TvMainShell( } // Secondary routes (reached FROM another screen — Settings -> Favorites/ - // Watchlist/History/Collections/Requests, Requests -> MyRequests) push onto + // Watchlist/History/Collections, Requests -> request detail) push onto // the current route instead of flattening to the tab root, // so Back returns to the parent screen (e.g. Settings) rather than Home. val navigateToSecondary: (String) -> Unit = { route -> @@ -686,6 +739,14 @@ fun TvMainShell( } } + // Request detail can open another request detail ("More like this"). A + // single-top push would reuse the current entry, its view model and its + // back stack slot, so the new title would show the old one's state and + // Back would skip it. Each title gets its own entry. + val openRequestDetail: (String, Int) -> Unit = { mediaType, tmdbId -> + nestedNav.navigate(TvMainRoute.RequestDetail(mediaType, tmdbId).route) + } + // True when a requester actually took focus. `requestFocus()` throws rather // than returning false when its node has not composed yet, so each call has // to be guarded — and that guard is what used to swallow the failure whole. @@ -794,6 +855,10 @@ fun TvMainShell( if (dest == TvRootDestination.Calendar) { calendarFocusHandoffPending = true } + if (dest == TvRootDestination.Requests) { + requestsFocusHandoffPending = true + requestsEntryRequest++ + } // A dwell preview can still be open when Center commits For You (or a // library root). Close it without returning focus to the bar before the // content handoff, otherwise the overlay lingers and races page focus. @@ -825,6 +890,9 @@ fun TvMainShell( // transition. Leave focus on the Calendar tab until its screen is // composed, then let TvCalendarScreen target the filter directly. focusState.closeProfileMenuForContent() + } else if (dest == TvRootDestination.Requests) { + // The Requests page claims its first card from the shell token. + focusState.closeProfileMenuForContent() } else if (dest == TvRootDestination.Home) { // Home's contentFocusRequest targets row 0 / card 0 directly. // Re-entering the parent content focusRestorer here would restore @@ -909,7 +977,7 @@ fun TvMainShell( } } - LaunchedEffect(currentRoute, visibleRoots, librariesLoaded, showAudiobooksTabResolved) { + LaunchedEffect(currentRoute, visibleRoots, librariesLoaded, showAudiobooksTabResolved, requestsResolved) { // Wait until libraries have actually loaded — before that `visibleRoots` // is just Home + Calendar, and a restored/deep-linked `main/movies` route // would be wrongly ejected even though that type exists. Likewise wait for @@ -917,6 +985,10 @@ fun TvMainShell( // omits Audiobooks, so a restored `main/audiobooks` route would be ejected // to Home before the DataStore read completes. if (!librariesLoaded || !showAudiobooksTabResolved) return@LaunchedEffect + // Likewise the Requests tab appears only once the server's requests + // status has been read; a restored Requests route (or a return from + // a title opened there) must not be ejected while that read runs. + if (currentRoute == TvMainRoute.Requests.route && !requestsResolved) return@LaunchedEffect // Only media-root tabs are eligible for the "tab no longer visible" // redirect. Non-tab routes (Settings, Favorites, Search, …) map // to null and must be left alone — otherwise navigating to Settings @@ -1216,9 +1288,7 @@ fun TvMainShell( onOpenPersonDetail = onOpenPersonDetail, ) }, - onOpenRequestDetail = { mediaType, tmdbId -> - navigateToSecondary(TvMainRoute.RequestDetail(mediaType, tmdbId).route) - }, + onOpenRequestDetail = openRequestDetail, onOpenLibraryItem = onOpenItemDetail, onOpenPersonDetail = onOpenPersonDetail, searchFieldFocusRequester = searchInputFocusRequester, @@ -1236,6 +1306,7 @@ fun TvMainShell( onLibraryCollectionClick = openLibraryCollectionDetail, onUserCollectionClick = openCollectionDetail, onInitialContentFocus = { focusState.closeProfileMenuForContent() }, + onPlayShuffle = onPlayShuffle, ) } shellComposable(TvMainRoute.Libraries.route) { @@ -1244,6 +1315,7 @@ fun TvMainShell( onLibraryCollectionClick = openLibraryCollectionDetail, onUserCollectionClick = openCollectionDetail, onInitialContentFocus = { focusState.closeProfileMenuForContent() }, + onPlayShuffle = onPlayShuffle, ) } // Content-type tabs (Skyline §3.1). Each renders the library @@ -1254,6 +1326,7 @@ fun TvMainShell( shellComposable(TvMainRoute.Movies.route) { TvLibraryTypeContent( type = TvLibraryTabType.Movies, + onScopedCollectionClick = openScopedCollection, library = activeLibrary(TvLibraryTabType.Movies), emptyConfirmed = librariesLoaded && libraries.none { TvLibraryTabType.Movies.matches(it) }, selectedPill = pillSelections[TvLibraryTabType.Movies] ?: TvLibraryPill.Recommended, @@ -1265,11 +1338,13 @@ fun TvMainShell( onUserCollectionClick = openCollectionDetail, onInitialContentFocus = { focusState.closeProfileMenuForContent() }, onContentUpFallbackChanged = onContentUpFallback, + onPlayShuffle = onPlayShuffle, ) } shellComposable(TvMainRoute.Series.route) { TvLibraryTypeContent( type = TvLibraryTabType.Series, + onScopedCollectionClick = openScopedCollection, library = activeLibrary(TvLibraryTabType.Series), emptyConfirmed = librariesLoaded && libraries.none { TvLibraryTabType.Series.matches(it) }, selectedPill = pillSelections[TvLibraryTabType.Series] ?: TvLibraryPill.Recommended, @@ -1281,11 +1356,13 @@ fun TvMainShell( onUserCollectionClick = openCollectionDetail, onInitialContentFocus = { focusState.closeProfileMenuForContent() }, onContentUpFallbackChanged = onContentUpFallback, + onPlayShuffle = onPlayShuffle, ) } shellComposable(TvMainRoute.Music.route) { TvLibraryTypeContent( type = TvLibraryTabType.Music, + onScopedCollectionClick = openScopedCollection, library = activeLibrary(TvLibraryTabType.Music), emptyConfirmed = librariesLoaded && libraries.none { TvLibraryTabType.Music.matches(it) }, selectedPill = pillSelections[TvLibraryTabType.Music] ?: TvLibraryPill.Recommended, @@ -1297,11 +1374,13 @@ fun TvMainShell( onUserCollectionClick = openCollectionDetail, onInitialContentFocus = { focusState.closeProfileMenuForContent() }, onContentUpFallbackChanged = onContentUpFallback, + onPlayShuffle = onPlayShuffle, ) } shellComposable(TvMainRoute.Audiobooks.route) { TvLibraryTypeContent( type = TvLibraryTabType.Audiobooks, + onScopedCollectionClick = openScopedCollection, library = activeLibrary(TvLibraryTabType.Audiobooks), emptyConfirmed = librariesLoaded && libraries.none { TvLibraryTabType.Audiobooks.matches(it) }, selectedPill = pillSelections[TvLibraryTabType.Audiobooks] ?: TvLibraryPill.Recommended, @@ -1313,6 +1392,7 @@ fun TvMainShell( onUserCollectionClick = openCollectionDetail, onInitialContentFocus = { focusState.closeProfileMenuForContent() }, onContentUpFallbackChanged = onContentUpFallback, + onPlayShuffle = onPlayShuffle, ) } shellComposable(TvMainRoute.ForYou.route) { @@ -1360,22 +1440,21 @@ fun TvMainShell( ) } shellComposable(TvMainRoute.Requests.route) { - TvRequestsScreen( + TvRequestsPage( onOpenLibraryItem = onOpenItemDetail, - onOpenMyRequests = { navigateToSecondary(TvMainRoute.MyRequests.route) }, - onOpenRequestDetail = { mt, id -> - navigateToSecondary(TvMainRoute.RequestDetail(mt, id).route) + onOpenRequestDetail = openRequestDetail, + onInitialContentFocus = { + focusState.closeProfileMenuForContent() + requestsFocusHandoffPending = false }, - onInitialContentFocus = { focusState.closeProfileMenuForContent() }, - ) - } - shellComposable(TvMainRoute.MyRequests.route) { - TvMyRequestsScreen( - onOpenLibraryItem = onOpenItemDetail, - onOpenRequestDetail = { mt, id -> - navigateToSecondary(TvMainRoute.RequestDetail(mt, id).route) + onFocusHandoffFailed = { + requestsFocusHandoffPending = false + focusState.requestMenuFocus( + target = TvTopMenuPanel.Root(TvRootDestination.Requests), + suppressDwellPreview = true, + ) }, - onInitialContentFocus = { focusState.closeProfileMenuForContent() }, + focusRequest = requestsEntryRequest, ) } shellComposable( @@ -1389,6 +1468,8 @@ fun TvMainShell( mediaType = entry.arguments?.getString(TvMainRoute.RequestDetail.ARG_MEDIA_TYPE).orEmpty(), tmdbId = entry.arguments?.getInt(TvMainRoute.RequestDetail.ARG_TMDB_ID) ?: 0, onBack = { if (nestedNav.previousBackStackEntry != null) nestedNav.popBackStack() }, + onOpenLibraryItem = onOpenItemDetail, + onOpenRequestDetail = openRequestDetail, // Back closes an open panel or profile menu first, as // on every other screen; the shell's handler does that. backEnabled = focusState.openPanel == null && !focusState.profileMenuOpen, @@ -1456,6 +1537,16 @@ fun TvMainShell( onInitialContentFocus = { focusState.closeProfileMenuForContent() }, ) } + // My Requests became the Requests tab's "Your requests" row; a + // saved back stack naming it lands on the tab. + composable(route = "main/requests/mine") { + LaunchedEffect(Unit) { + nestedNav.navigate(TvMainRoute.Requests.route) { + popUpTo("main/requests/mine") { inclusive = true } + launchSingleTop = true + } + } + } // ---- Removed route aliases (defensive) ---- see // [TvRemovedMainRoutes]. Registered, never rendered: each one // redirects into Settings so a back stack saved by a build that @@ -1525,7 +1616,9 @@ fun TvMainShell( // still pops the route via DelegateToNav. isFocusSuppressed = focusState.isMenuFocusSuppressed || calendarFocusHandoffPending || - currentRoute == TvMainRoute.Settings.route, + requestsFocusHandoffPending || + currentRoute == TvMainRoute.Settings.route || + currentRoute == TvMainRoute.RequestDetail.ROUTE, focusRequest = focusState.menuFocusRequest, focusRequestTarget = focusState.menuFocusTarget, focusRequestSuppressesDwell = focusState.menuFocusSuppressesDwell, @@ -1534,7 +1627,13 @@ fun TvMainShell( onInstallAnchorFocus = { hook -> focusState.focusBarAnchorNow = hook }, profileFocusRequest = focusState.profileFocusRequest, isSearchActive = currentRoute == TvMainRoute.Search.route, - visibility = if (currentRoute == TvMainRoute.Settings.route) 0f else menuVisibility.value, + // Request detail is a full detail page like a library title's, + // which has no bar either (tvOS pushes it over the tab). + visibility = if (currentRoute == TvMainRoute.Settings.route || currentRoute == TvMainRoute.RequestDetail.ROUTE) { + 0f + } else { + menuVisibility.value + }, openPanel = focusState.openPanel, onDwell = focusState::previewPanel, onEnterPanel = focusState::enterPanel, @@ -1669,22 +1768,13 @@ fun TvMainShell( navigateToSecondary(TvMainRoute.History.route) moveFocusToContent(TvMainRoute.History.route) }, - showRequests = requestsEnabled, - onRequests = closeMenuAnd { - navigateToSecondary(TvMainRoute.Requests.route) - moveFocusToContent(TvMainRoute.Requests.route) - }, showLiveTv = liveTvEnabled, onLiveTv = closeMenuAnd { navigateToSecondary(TvMainRoute.LiveTv.route) moveFocusToContent(TvMainRoute.LiveTv.route) }, - showWatchTogether = CLIENT_WATCH_TOGETHER_SURFACE_ENABLED, - onWatchTogether = { - focusState.closeProfileMenuForContent() - watchTogetherViewModel.clearError() - watchTogetherEntryOpen = true - }, + showWatchParty = watchPartyEnabled, + onWatchParty = closeMenuAnd(onOpenWatchParty), onSettings = { // Keep focus on the dropdown row through the route fade. // TvSettingsScreen closes the menu only after its General @@ -1706,38 +1796,6 @@ fun TvMainShell( .zIndex(2f), ) } - - if (watchTogetherEntryOpen) { - if (watchTogetherJoinOpen) { - TvJoinCodeDialog( - isBusy = watchTogetherState.isBusy, - error = watchTogetherState.error, - onJoin = watchTogetherViewModel::joinRoom, - onDismiss = { - watchTogetherViewModel.clearError() - watchTogetherJoinOpen = false - }, - ) - } else { - TvWatchTogetherMenuEntryDialog( - canResume = currentWatchTogetherRoom != null, - isBusy = watchTogetherState.isBusy, - error = watchTogetherState.error, - onResume = { watchTogetherViewModel.resumeCurrentRoom() }, - onHost = { watchTogetherViewModel.createEmptyVoteRoom() }, - onJoin = { - watchTogetherViewModel.clearError() - watchTogetherJoinOpen = true - }, - onDismiss = { - watchTogetherViewModel.clearError() - watchTogetherEntryOpen = false - watchTogetherJoinOpen = false - focusState.dismissProfileMenu() - }, - ) - } - } } } @@ -1752,6 +1810,7 @@ fun TvMainShell( @Composable private fun TvLibraryTypeContent( type: TvLibraryTabType, + onScopedCollectionClick: (Int, String, String, String, Boolean) -> Unit, library: UserLibrary?, emptyConfirmed: Boolean, selectedPill: TvLibraryPill, @@ -1766,6 +1825,7 @@ private fun TvLibraryTypeContent( onUserCollectionClick: (collectionId: String, title: String) -> Unit, onInitialContentFocus: () -> Unit, onContentUpFallbackChanged: ((((Boolean) -> Boolean)?) -> Unit)? = null, + onPlayShuffle: (shuffle: org.prairieserver.prairie.model.shuffle.Shuffle, libraryId: Int?) -> Unit = { _, _ -> }, ) { if (library == null) { // Only assert "no libraries" once loading has settled AND this type @@ -1797,9 +1857,13 @@ private fun TvLibraryTypeContent( libraryId = library.id, libraryTitle = library.name, libraryType = library.type, + mediaScope = type.mediaScope(library), onItemClick = onItemClick, onCollectionClick = { collectionId, title, isUserCollection -> - if (isUserCollection) { + val scope = type.mediaScope(library) + if (scope != null) { + onScopedCollectionClick(library.id, collectionId, title, scope, isUserCollection) + } else if (isUserCollection) { onUserCollectionClick(collectionId, title) } else { onLibraryCollectionClick(library.id, collectionId, title, library.type) @@ -1809,6 +1873,7 @@ private fun TvLibraryTypeContent( initialSection = selectedPill.toLibraryTab(), sectionRequestNonce = sectionRequestNonce, onContentUpFallbackChanged = onContentUpFallbackChanged, + onShuffleStarted = { shuffle -> onPlayShuffle(shuffle, library.id) }, ) } } @@ -1846,8 +1911,9 @@ private fun mapRouteToRoot(route: String): TvRootDestination? = when (route) { TvMainRoute.Audiobooks.route -> TvRootDestination.LibraryType(TvLibraryTabType.Audiobooks) TvMainRoute.Calendar.route -> TvRootDestination.Calendar TvMainRoute.ForYou.route -> TvRootDestination.ForYou + TvMainRoute.Requests.route -> TvRootDestination.Requests // Search maps to null so no top tab is highlighted (trailing icon). - // Requests/MyRequests/Settings/Audio/Libraries are likewise non-tab. + // Request detail/Settings/Audio/Libraries are likewise non-tab. else -> null } @@ -1862,6 +1928,7 @@ private fun TvRootDestination.toRoute(): String = when (this) { TvRootDestination.Home -> TvMainRoute.Home.route TvRootDestination.ForYou -> TvMainRoute.ForYou.route TvRootDestination.Calendar -> TvMainRoute.Calendar.route + TvRootDestination.Requests -> TvMainRoute.Requests.route is TvRootDestination.LibraryType -> when (type) { TvLibraryTabType.Movies -> TvMainRoute.Movies.route TvLibraryTabType.Series -> TvMainRoute.Series.route @@ -1921,8 +1988,8 @@ private fun cascadePanelOffset( * returns focus to the avatar via [onDismiss]. * * Row set + order mirrors tvOS: Switch Profile · Watchlist · Favorites · - * History · Requests (server-gated) · Watch Together (client-policy-gated) · - * Settings · Switch Server · Sign Out. Calendar is a top-level tab. + * History · Watch Party (Settings → Experimental) · Settings · Switch Server · + * Sign Out. Calendar and Requests are top-level tabs. */ @OptIn(ExperimentalComposeUiApi::class) @Composable @@ -1934,12 +2001,10 @@ private fun TvProfileDropdown( onWatchlist: () -> Unit, onFavorites: () -> Unit, onHistory: () -> Unit, - showRequests: Boolean, - onRequests: () -> Unit, showLiveTv: Boolean, onLiveTv: () -> Unit, - showWatchTogether: Boolean, - onWatchTogether: () -> Unit, + showWatchParty: Boolean, + onWatchParty: () -> Unit, onSettings: () -> Unit, onSwitchServer: () -> Unit, onSignOut: () -> Unit, @@ -1985,13 +2050,6 @@ private fun TvProfileDropdown( ProfileDropdownRow(label = "Watchlist", icon = Icons.Filled.Bookmark, onClick = onWatchlist) ProfileDropdownRow(label = "Favorites", icon = Icons.Filled.Favorite, onClick = onFavorites) ProfileDropdownRow(label = "History", icon = Icons.Filled.History, onClick = onHistory) - if (showRequests) { - ProfileDropdownRow( - label = "Requests", - icon = Icons.Filled.AutoAwesome, - onClick = onRequests, - ) - } if (showLiveTv) { ProfileDropdownRow( label = "Live TV", @@ -1999,11 +2057,11 @@ private fun TvProfileDropdown( onClick = onLiveTv, ) } - if (showWatchTogether) { + if (showWatchParty) { ProfileDropdownRow( - label = "Watch Together", + label = "Watch Party", icon = Icons.Filled.People, - onClick = onWatchTogether, + onClick = onWatchParty, ) } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMediaDestinations.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMediaDestinations.kt index 4d32ff3f87..851b5d57f4 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMediaDestinations.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMediaDestinations.kt @@ -6,8 +6,8 @@ import org.prairieserver.prairie.tv.ui.navigation.TvMainRoute /** * Skyline content-type-first shell (§3.1): a fixed root order of `Home`, then * one tab per [TvLibraryTabType] the profile can actually see (a library of - * that type exists), then `Calendar`. Search and For You are no longer tabs — - * Search is a trailing icon button and For You is reached as a Home row. + * that type exists), then For You and `Calendar`, then Requests while the + * server has it enabled. Search is a trailing icon button. * * Mirrors tvOS `TVMainTabView.visibleRoots`. */ @@ -16,6 +16,9 @@ fun visibleTvRoots( /** tvOS navPrefs.showAudiobooks parity: the Audiobooks tab is opt-in * (hidden by default) even when an audiobook library exists. */ showAudiobooks: Boolean = false, + /** Requests is a server capability, not a customizable menu item: it + * trails the content tabs whenever the server has it enabled. */ + requestsEnabled: Boolean = false, ): List<TvRootDestination> = buildList { add(TvRootDestination.Home) TvLibraryTabType.entries @@ -25,6 +28,7 @@ fun visibleTvRoots( // tvOS root order: libraries, then For You, then Calendar. add(TvRootDestination.ForYou) add(TvRootDestination.Calendar) + if (requestsEnabled) add(TvRootDestination.Requests) } fun firstTvRoute(): String = TvMainRoute.Home.route diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvRootDestination.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvRootDestination.kt index b94dbb6f8a..1a68ab31d2 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvRootDestination.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvRootDestination.kt @@ -18,6 +18,12 @@ sealed class TvRootDestination { /** Personal recommendations ("For You") — tvOS `.recommendations`. */ data object ForYou : TvRootDestination() - /** Upcoming releases by week. Always the last tab. */ + /** Upcoming releases by week. */ data object Calendar : TvRootDestination() + + /** + * Media requests, tvOS `.requests`. Present only while the server has + * requests enabled for this profile; it trails the content tabs. + */ + data object Requests : TvRootDestination() } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuBar.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuBar.kt index 90622ca419..21965d9a5f 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuBar.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuBar.kt @@ -127,6 +127,7 @@ private sealed class TvTopMenuFocus { data class Tab(val type: TvLibraryTabType) : TvTopMenuFocus() data object ForYou : TvTopMenuFocus() data object Calendar : TvTopMenuFocus() + data object Requests : TvTopMenuFocus() data object Search : TvTopMenuFocus() data object Profile : TvTopMenuFocus() } @@ -202,6 +203,7 @@ fun TvTopMenuBar( val homeFocusRequester = remember { FocusRequester() } val calendarFocusRequester = remember { FocusRequester() } val forYouFocusRequester = remember { FocusRequester() } + val requestsFocusRequester = remember { FocusRequester() } val searchFocusRequester = remember { FocusRequester() } val profileFocusRequester = remember { FocusRequester() } // One requester per library-type tab; stable across recompositions so a @@ -244,6 +246,7 @@ fun TvTopMenuBar( TvRootDestination.Home -> TvTopMenuFocus.Home TvRootDestination.ForYou -> TvTopMenuFocus.ForYou TvRootDestination.Calendar -> TvTopMenuFocus.Calendar + TvRootDestination.Requests -> TvTopMenuFocus.Requests is TvRootDestination.LibraryType -> TvTopMenuFocus.Tab(root.type) } @@ -257,6 +260,7 @@ fun TvTopMenuBar( is TvTopMenuFocus.Tab -> tabFocusRequesters[focus.type] ?: homeFocusRequester TvTopMenuFocus.ForYou -> forYouFocusRequester TvTopMenuFocus.Calendar -> calendarFocusRequester + TvTopMenuFocus.Requests -> requestsFocusRequester TvTopMenuFocus.Search -> searchFocusRequester TvTopMenuFocus.Profile -> profileFocusRequester } @@ -271,6 +275,7 @@ fun TvTopMenuBar( fun selectedEntryRequester(): FocusRequester = when (val root = selectedRoot) { TvRootDestination.Home -> homeFocusRequester TvRootDestination.Calendar -> calendarFocusRequester + TvRootDestination.Requests -> requestsFocusRequester is TvRootDestination.LibraryType -> tabFocusRequesters[root.type] ?: homeFocusRequester TvRootDestination.ForYou -> forYouFocusRequester null -> if (isSearchActive) searchFocusRequester else homeFocusRequester @@ -631,6 +636,22 @@ fun TvTopMenuBar( }, onClick = { onSelectRoot(TvRootDestination.Calendar) }, ) + + TvRootDestination.Requests -> TvTopMenuTab( + label = "Requests", + isSelected = selectedRoot == TvRootDestination.Requests, + isFocused = focusedButton == TvTopMenuFocus.Requests, + canFocus = canFocusButton(TvTopMenuFocus.Requests), + focusRequester = requestsFocusRequester, + onFocusChanged = { hasFocus -> + focusedButton = if (hasFocus) { + TvTopMenuFocus.Requests + } else { + focusedButton.takeUnless { it == TvTopMenuFocus.Requests } + } + }, + onClick = { onSelectRoot(TvRootDestination.Requests) }, + ) } } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/theme/Theme.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/theme/Theme.kt index 673dc904a1..927079aa6c 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/theme/Theme.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/theme/Theme.kt @@ -10,6 +10,7 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Shapes import androidx.tv.material3.darkColorScheme +import org.prairieserver.prairie.common.ui.components.LocalDefaultArtworkMarkMaxSize private val PrairieTvDarkColorScheme = darkColorScheme( primary = PrairiePrimary, @@ -58,6 +59,9 @@ private val PrairieTvShapes = Shapes( private const val TvUiFontScale = 0.86f +/** Missing-artwork marks may grow larger on TV posters than on phone. */ +private val TvDefaultArtworkMarkMaxSize = 64.dp + @OptIn(ExperimentalTvMaterial3Api::class) @Composable fun PrairieTvTheme( @@ -77,7 +81,10 @@ fun PrairieTvTheme( typography = PrairieTvTypography, shapes = PrairieTvShapes, ) { - CompositionLocalProvider(LocalDensity provides tvDensity) { + CompositionLocalProvider( + LocalDensity provides tvDensity, + LocalDefaultArtworkMarkMaxSize provides TvDefaultArtworkMarkMaxSize, + ) { content() } } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/watchnext/TvWorkerFactory.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/watchnext/TvWorkerFactory.kt index ea1cfe133d..c4fcafbda7 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/watchnext/TvWorkerFactory.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/watchnext/TvWorkerFactory.kt @@ -13,7 +13,9 @@ import org.prairieserver.prairie.common.diagnostics.HostedDiagnosticsDeletionWor import org.prairieserver.prairie.common.diagnostics.HostedDiagnosticsReportDeleter import org.prairieserver.prairie.common.diagnostics.PendingReportStore import org.prairieserver.prairie.repository.SectionRepository +import org.prairieserver.prairie.common.profiles.ProfileLaunchPreferences import org.koin.core.context.GlobalContext +import org.prairieserver.prairie.network.TokenManager /** * Hand-rolled WorkerFactory that constructs DI-dependent workers via Koin. @@ -47,6 +49,10 @@ class TvWorkerFactory : WorkerFactory() { params = workerParameters, sectionRepository = koin.get<SectionRepository>(), repository = koin.get<WatchNextRepository>(), + spoilerStore = koin.get(), + catalogRepository = koin.get(), + allowsWatchNext = koin.get<ProfileLaunchPreferences>()::allowsWatchNext, + hasTemporaryScope = koin.get<TokenManager>()::hasTemporaryScope, ) } SyncWorker::class.java.name -> { diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/watchnext/WatchNextHomeSync.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/watchnext/WatchNextHomeSync.kt index 0eb552b2e3..b1aad0dd68 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/watchnext/WatchNextHomeSync.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/watchnext/WatchNextHomeSync.kt @@ -4,28 +4,47 @@ import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.repository.SectionRepository +import org.prairieserver.prairie.model.catalog.ItemDetail +import org.prairieserver.prairie.model.settings.EpisodeSpoilerPrefs +import org.prairieserver.prairie.model.settings.EpisodeSpoilers -/** Actual worker read branch, with provider dispatch injected for synthetic tests. */ +/** + * Actual worker read branch, with provider dispatch injected for synthetic tests. + * [seriesArtwork] answers null only when the lookup failed; the run then retries + * instead of dropping the tile. + */ internal suspend fun syncWatchNextHome( sections: SectionRepository, gate: WatchNextWriteGate, stopped: () -> Boolean, + spoilerPreferences: suspend () -> EpisodeSpoilerPrefs? = { EpisodeSpoilerPrefs.NONE }, + preferencesCurrent: (EpisodeSpoilerPrefs) -> Boolean = { true }, + seriesArtwork: suspend (String) -> ItemDetail? = { null }, + /** A phone's cast identity is the current scope; the launcher row is never its to write. */ + borrowedIdentity: suspend () -> Boolean = { false }, apply: suspend (List<WatchNextProgramFields>, Long, suspend () -> Boolean) -> Unit, ): Boolean { val run = gate.capture() + if (borrowedIdentity()) return true val owner = sections.captureHomeAuthority() ?: return true + var prefs: EpisodeSpoilerPrefs? = null suspend fun authority(): Boolean { - val valid = sections.isHomeAuthorityCurrent(owner) + // The suspending identity lookup first, so the owner is validated last. + val valid = !borrowedIdentity() && sections.isHomeAuthorityCurrent(owner) currentCoroutineContext().ensureActive() - return valid && !stopped() + return valid && !stopped() && (prefs?.let(preferencesCurrent) ?: true) } suspend fun current() = gate.allowed(run, ::authority) if (!current()) return true + val resolvedPrefs = spoilerPreferences() ?: return false + prefs = resolvedPrefs + if (!current()) return true val response = sections.getHomeSections(owner) if (!current()) return true if (response !is ApiResult.Success) return false val fields = mutableListOf<WatchNextProgramFields>() + val artworkBySeries = mutableMapOf<String, ItemDetail>() for (section in response.data.sections.filter { it.sectionType in setOf("continue_watching", "next_up") }) { var items = section.items if (items.isEmpty() && section.totalCount > 0) { @@ -35,7 +54,24 @@ internal suspend fun syncWatchNextHome( items = fallback.data.items if (items.isEmpty() && (fallback.data.section?.totalCount ?: 0) > 0) return false } - fields += items.mapNotNull { WatchNextProgramMapper.map(it, section.sectionType) } + for (item in items) { + var mapped = WatchNextProgramMapper.map(item, section.sectionType, resolvedPrefs) + if (mapped == null && item.type.equals("episode", ignoreCase = true) && + resolvedPrefs.hidesImage(EpisodeSpoilers.isUnwatched(item))) { + val seriesId = item.seriesId?.takeIf { it.isNotBlank() } + if (seriesId != null) { + if (seriesId !in artworkBySeries) { + val series = seriesArtwork(seriesId) + if (!current()) return true + // A failed lookup is not "no safe artwork": publishing + // without the tile would remove it from the launcher. + artworkBySeries[seriesId] = series ?: return false + } + mapped = WatchNextProgramMapper.map(item, section.sectionType, resolvedPrefs, artworkBySeries[seriesId]) + } + } + mapped?.let(fields::add) + } } if (!current()) return true apply(fields, run, ::authority) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/watchnext/WatchNextProfileExpiryReceiver.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/watchnext/WatchNextProfileExpiryReceiver.kt new file mode 100644 index 0000000000..a3cff6d8a5 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/watchnext/WatchNextProfileExpiryReceiver.kt @@ -0,0 +1,62 @@ +package org.prairieserver.prairie.tv.watchnext + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import org.koin.core.context.GlobalContext +import org.prairieserver.prairie.common.profiles.ProfileLaunchPreferences + +/** + * Keeps the launcher's Watch Next row in line with Profile Selection while + * Prairie is away, at the moments nothing in Prairie is running to do it: + * + * - the wake-up alarm [WatchNextSeeder.scheduleProfileExpiryCheck] sets for + * when a timed choice runs out. JobScheduler never wakes a TV in standby + * and can defer work for an app not used lately, so a WorkManager job + * alone could leave the previous viewer's titles up well past the limit; + * - a reboot, which clears that alarm; + * - a clock change, which can end the away interval early (a clock set back + * behind its start counts as expired). + * + * Each removes the row when the setting now hides it, and otherwise re-arms + * the alarm for an interval still running. + */ +class WatchNextProfileExpiryReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action !in HANDLED_ACTIONS) return + val koin = GlobalContext.get() + val preferences = koin.get<ProfileLaunchPreferences>() + val seeder = koin.get<WatchNextSeeder>() + // This runs on the main thread, as choosing a profile does, so a + // choice cannot land between this check and the wipe, and the seed + // that choice starts waits for the wipe to finish. + if (preferences.allowsWatchNext()) { + preferences.state.value.awayExpiresAtMs?.let(seeder::scheduleProfileExpiryCheck) + return + } + val pending = goAsync() + // Keep the hourly refresh: a clock set back and then corrected can + // make the same profile's titles allowed again. + seeder.clear(keepRefresh = true).invokeOnCompletion { cause -> + if (cause == null) { + Log.i(TAG, "Profile Selection hides Watch Next; removed the row") + } else { + Log.w(TAG, "Watch Next wipe failed", cause) + } + pending.finish() + } + } + + companion object { + /** The expiry alarm's own action; the others come from the system. */ + const val ACTION_PROFILE_EXPIRY = "org.prairieserver.prairie.tv.action.WATCH_NEXT_PROFILE_EXPIRY" + + private const val TAG = "WatchNextExpiry" + private val HANDLED_ACTIONS = setOf( + ACTION_PROFILE_EXPIRY, + Intent.ACTION_BOOT_COMPLETED, + Intent.ACTION_TIME_CHANGED, + ) + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/watchnext/WatchNextProgramMapper.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/watchnext/WatchNextProgramMapper.kt index 8a8f8ccbc0..7155325bcb 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/watchnext/WatchNextProgramMapper.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/watchnext/WatchNextProgramMapper.kt @@ -2,6 +2,9 @@ package org.prairieserver.prairie.tv.watchnext import org.prairieserver.prairie.model.catalog.isAudiobookItemType import org.prairieserver.prairie.model.section.SectionItem +import org.prairieserver.prairie.model.catalog.ItemDetail +import org.prairieserver.prairie.model.settings.EpisodeSpoilerPrefs +import org.prairieserver.prairie.model.settings.EpisodeSpoilers import java.net.URLEncoder import java.time.Instant import java.time.format.DateTimeParseException @@ -21,8 +24,19 @@ data class WatchNextProgramFields( object WatchNextProgramMapper { - fun map(item: SectionItem, sectionType: String): WatchNextProgramFields? { - val poster = item.backdropUrl ?: item.posterUrl ?: return null + fun map( + item: SectionItem, + sectionType: String, + spoilerPrefs: EpisodeSpoilerPrefs = EpisodeSpoilerPrefs.NONE, + seriesArtwork: ItemDetail? = null, + ): WatchNextProgramFields? { + val protect = item.type.equals("episode", ignoreCase = true) && + spoilerPrefs.hidesImage(EpisodeSpoilers.isUnwatched(item)) + val backdrop = item.backdropUrl?.takeIf { it.isNotBlank() && (!protect || item.backdropIsEpisodeStill == false) } + val portrait = item.posterUrl?.takeIf { it.isNotBlank() && (!protect || item.posterIsEpisodeStill == false) } + val seriesBackdrop = seriesArtwork?.takeIf { it.type == "series" }?.backdropUrl?.takeIf { it.isNotBlank() } + val seriesPoster = seriesArtwork?.takeIf { it.type == "series" }?.posterUrl?.takeIf { it.isNotBlank() } + val poster = backdrop ?: seriesBackdrop ?: portrait ?: seriesPoster ?: return null val watchNextType = when (sectionType) { "continue_watching" -> WATCH_NEXT_TYPE_CONTINUE "next_up" -> WATCH_NEXT_TYPE_NEXT @@ -36,8 +50,12 @@ object WatchNextProgramMapper { isAudiobook -> PROGRAM_TYPE_ALBUM else -> PROGRAM_TYPE_TV_EPISODE } - // Audiobook cover art is square; video art stays 16:9. - val aspectRatio = if (isAudiobook) ASPECT_RATIO_1_1 else ASPECT_RATIO_16_9 + // Safe parent posters keep their portrait aspect ratio. + val aspectRatio = when { + isAudiobook -> ASPECT_RATIO_1_1 + protect && backdrop == null && seriesBackdrop == null -> ASPECT_RATIO_2_3 + else -> ASPECT_RATIO_16_9 + } val intentUri = when (watchNextType) { // Contract with the deep-link handler: tag the play intent with the // item type (URL-encoded) so it can route audiobooks to the audio @@ -78,4 +96,6 @@ object WatchNextProgramMapper { const val PROGRAM_TYPE_ALBUM = 8 const val ASPECT_RATIO_16_9 = 0 const val ASPECT_RATIO_1_1 = 3 + // Mirrors TvContractCompat.WatchNextPrograms.ASPECT_RATIO_2_3. + const val ASPECT_RATIO_2_3 = 4 } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/watchnext/WatchNextSeeder.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/watchnext/WatchNextSeeder.kt index 4272c7b045..065912fd44 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/watchnext/WatchNextSeeder.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/watchnext/WatchNextSeeder.kt @@ -1,6 +1,9 @@ package org.prairieserver.prairie.tv.watchnext +import android.app.AlarmManager +import android.app.PendingIntent import android.content.Context +import android.content.Intent import androidx.work.Constraints import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingWorkPolicy @@ -9,6 +12,7 @@ import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OutOfQuotaPolicy import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager +import androidx.work.workDataOf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -41,10 +45,45 @@ class WatchNextSeeder( // AFTER the seed and erase the fresh rows it just inserted. private var clearJob: Job? = null + private val protectionPrefs = context.getSharedPreferences("silo_watch_next_protection", Context.MODE_PRIVATE) + + // Bumped by every [updateImageProtection] under the prefs lock; a wipe + // that finishes after a newer call must not record its older setting. + private var protectionGeneration = 0 + + /** Clear exposed tiles only when known image protection changes from off to on. */ + fun updateImageProtection(enabled: Boolean) { + val generation = synchronized(protectionPrefs) { ++protectionGeneration } + val wasEnabled = protectionPrefs.getBoolean("hide_images", false) + if (enabled && !wasEnabled) { + clear() + // Record the change only once the wipe has finished: a process + // death before then must wipe again on the next start. + val wipe = clearJob + scope.launch { + wipe?.join() + synchronized(protectionPrefs) { + if (wipe?.isCancelled != true && generation == protectionGeneration) { + protectionPrefs.edit().putBoolean("hide_images", true).apply() + } + } + } + } else { + protectionPrefs.edit().putBoolean("hide_images", enabled).apply() + } + seedNow() + enqueuePeriodic() + } + fun seedNow() { val pending = clearJob + // A clear() after this call supersedes it: clear() invalidates the + // write generation first, so a seed still waiting on an older wipe + // must not enqueue after that clear()'s cancellation. + val generation = repository.writeGate.capture() scope.launch { pending?.join() + if (!repository.writeGate.current(generation)) return@launch val request = OneTimeWorkRequestBuilder<WatchNextSyncWorker>() .setConstraints(networkConstraints) .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) @@ -70,7 +109,50 @@ class WatchNextSeeder( ) } - fun clear() { + /** + * Re-checks Profile Selection when a timed away interval runs out at + * [expiresAtMs] (wall clock, like the policy), so the launcher stops + * showing this profile's titles then rather than at the next hourly + * refresh. Needs no network: it only wipes the row, or does nothing if + * Prairie was opened in time. + * + * A wake-up alarm does the timely check, since WorkManager's delay is + * only a minimum and JobScheduler never wakes a TV in standby. + * [WatchNextProfileExpiryReceiver] re-arms it after a reboot or a clock + * change. The WorkManager job stays as a backstop. + */ + fun scheduleProfileExpiryCheck(expiresAtMs: Long) { + context.getSystemService(AlarmManager::class.java)?.setWindow( + AlarmManager.RTC_WAKEUP, + expiresAtMs, + EXPIRY_ALARM_WINDOW_MS, + PendingIntent.getBroadcast( + context, + 0, + Intent(context, WatchNextProfileExpiryReceiver::class.java) + .setAction(WatchNextProfileExpiryReceiver.ACTION_PROFILE_EXPIRY), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ), + ) + val delayMs = (expiresAtMs - System.currentTimeMillis()).coerceAtLeast(0L) + val request = OneTimeWorkRequestBuilder<WatchNextSyncWorker>() + .setInitialDelay(delayMs, TimeUnit.MILLISECONDS) + .setInputData(workDataOf(WatchNextSyncWorker.KEY_POLICY_CHECK_ONLY to true)) + .build() + WorkManager.getInstance(context).enqueueUniqueWork( + WatchNextSyncWorker.UNIQUE_NAME_PROFILE_EXPIRY, + ExistingWorkPolicy.REPLACE, + request, + ) + } + + /** + * Returns the provider wipe, which runs after this returns. [keepRefresh] + * leaves the hourly refresh in place for a wipe that only Profile + * Selection asked for: each run re-checks the setting, so the row comes + * back if the same profile may show it again. + */ + fun clear(keepRefresh: Boolean = false): Job { repository.invalidate() // Cancel FIRST, then wipe. Cancel BOTH the periodic refresh and any // in-flight one-shot seed — without cancelling before the wipe, a @@ -81,16 +163,22 @@ class WatchNextSeeder( // cancellation. The repository orders the wipe after any dispatched // write, and rejects old writers waiting behind that wipe. WorkManager.getInstance(context).apply { - cancelUniqueWork(WatchNextSyncWorker.UNIQUE_NAME_PERIODIC) + if (!keepRefresh) cancelUniqueWork(WatchNextSyncWorker.UNIQUE_NAME_PERIODIC) cancelUniqueWork(WatchNextSyncWorker.UNIQUE_NAME_ONESHOT) } // Cross-process ContentResolver deletes are binder I/O: run them off // the caller's (main) thread so clear() stays cheap for call sites. // Track the wipe so [seedNow] can join it before enqueuing a seed. - clearJob = scope.launch { repository.clearAll() } + return scope.launch { repository.clearAll() }.also { clearJob = it } } private val networkConstraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build() + + private companion object { + // The shortest window an inexact alarm gets from Android 12 on; an + // exact alarm would need the SCHEDULE_EXACT_ALARM permission. + const val EXPIRY_ALARM_WINDOW_MS = 10 * 60 * 1000L + } } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/watchnext/WatchNextSyncWorker.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/watchnext/WatchNextSyncWorker.kt index ff009f1985..cbdf87e388 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/watchnext/WatchNextSyncWorker.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/watchnext/WatchNextSyncWorker.kt @@ -4,6 +4,11 @@ import android.content.Context import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import org.prairieserver.prairie.repository.SectionRepository +import org.prairieserver.prairie.repository.CatalogRepository +import org.prairieserver.prairie.common.settings.EpisodeSpoilerStore +import org.prairieserver.prairie.common.settings.EpisodeSpoilerSupport +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.common.profiles.ProfileLaunchPreferences import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -21,17 +26,58 @@ class WatchNextSyncWorker( params: WorkerParameters, private val sectionRepository: SectionRepository, private val repository: WatchNextRepository, + private val spoilerStore: EpisodeSpoilerStore, + private val catalogRepository: CatalogRepository, + /** Profile Selection's verdict; see [ProfileLaunchPreferences.allowsWatchNext]. */ + private val allowsWatchNext: () -> Boolean = { true }, + /** + * A phone's cast identity is active; its titles never reach this TV's + * launcher. Returning to this TV's profile after the cast seeds again. + */ + private val hasTemporaryScope: suspend () -> Boolean = { false }, ) : CoroutineWorker(appContext, params) { override suspend fun doWork(): Result = withContext(Dispatchers.IO) { - val completed = syncWatchNextHome(sectionRepository, repository.writeGate, { isStopped }) { fields, run, authority -> + if (wipeIfHidden()) return@withContext Result.success() + if (inputData.getBoolean(KEY_POLICY_CHECK_ONLY, false)) return@withContext Result.success() + val stopped = { isStopped || !allowsWatchNext() } + val completed = syncWatchNextHome( + sectionRepository, repository.writeGate, stopped, + spoilerPreferences = { + spoilerStore.refresh() + spoilerStore.state.value.takeUnless { it.support == EpisodeSpoilerSupport.Unknown }?.prefs + }, + preferencesCurrent = { prefs -> + spoilerStore.state.value.let { it.support != EpisodeSpoilerSupport.Unknown && it.prefs == prefs } + }, + seriesArtwork = { id -> (catalogRepository.getItemDetail(id) as? ApiResult.Success)?.data }, + borrowedIdentity = hasTemporaryScope, + ) { fields, run, authority -> repository.diffAndApply(fields, run, authority) } + // The verdict can flip mid-run (a timed choice expiring); wipe what + // this run already wrote instead of leaving it for a retry. + if (wipeIfHidden()) return@withContext Result.success() if (completed) Result.success() else Result.retry() } + /** + * Profile Selection hides the previous viewer's titles from the launcher + * (prairie-apple TopShelfProfilePolicy): wipe instead of writing. + */ + private suspend fun wipeIfHidden(): Boolean { + if (allowsWatchNext()) return false + repository.invalidate() + repository.clearAll() + return true + } + companion object { const val UNIQUE_NAME_PERIODIC = "watch_next_sync_periodic" const val UNIQUE_NAME_ONESHOT = "watch_next_sync_oneshot" + const val UNIQUE_NAME_PROFILE_EXPIRY = "watch_next_profile_expiry" + + /** Input flag: only apply Profile Selection's verdict, never sync. */ + const val KEY_POLICY_CHECK_ONLY = "policy_check_only" } } diff --git a/androidTvApp/src/androidMain/res/drawable/silo_mark.xml b/androidTvApp/src/androidMain/res/drawable/silo_mark.xml new file mode 100644 index 0000000000..574290123a --- /dev/null +++ b/androidTvApp/src/androidMain/res/drawable/silo_mark.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- The Prairie mark, flat, from prairie-branding logo/svg/prairie-mark-flat.svg. --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="16dp" + android:height="51.41dp" + android:viewportWidth="266.2084" + android:viewportHeight="855.3524"> + <path + android:fillColor="#0034FB" + android:fillType="evenOdd" + android:pathData="M0.167,24.131 A24.000,24.000 0 0 1 36.167,3.346 L239.822,120.927 A24.000,24.000 0 0 1 239.822,162.496 L36.167,280.077 A24.000,24.000 0 0 1 0.167,259.292 Z" /> + <path + android:fillColor="#0034FB" + android:fillType="evenOdd" + android:pathData="M0.167,356.350 A27.000,27.000 0 0 1 13.667,332.967 L225.667,210.569 A27.000,27.000 0 0 1 266.167,233.952 L266.167,323.098 A27.000,27.000 0 0 1 252.667,346.481 L40.667,468.879 A27.000,27.000 0 0 1 0.167,445.496 Z M81.167,349.097 A11.000,11.000 0 0 1 86.667,339.571 L168.667,292.228 A11.000,11.000 0 0 1 185.167,301.754 L185.167,330.351 A11.000,11.000 0 0 1 179.667,339.877 L97.667,387.220 A11.000,11.000 0 0 1 81.167,377.694 Z" /> + <path + android:fillColor="#F50B4F" + android:fillType="evenOdd" + android:pathData="M0.167,547.750 A27.000,27.000 0 0 1 13.667,524.367 L225.667,401.969 A27.000,27.000 0 0 1 266.167,425.352 L266.167,514.498 A27.000,27.000 0 0 1 252.667,537.881 L40.667,660.279 A27.000,27.000 0 0 1 0.167,636.896 Z M81.167,540.497 A11.000,11.000 0 0 1 86.667,530.971 L168.667,483.628 A11.000,11.000 0 0 1 185.167,493.154 L185.167,521.751 A11.000,11.000 0 0 1 179.667,531.277 L97.667,578.620 A11.000,11.000 0 0 1 81.167,569.094 Z" /> + <path + android:fillColor="#FA7604" + android:fillType="evenOdd" + android:pathData="M0.167,739.150 A27.000,27.000 0 0 1 13.667,715.767 L225.667,593.369 A27.000,27.000 0 0 1 266.167,616.752 L266.167,705.898 A27.000,27.000 0 0 1 252.667,729.281 L40.667,851.679 A27.000,27.000 0 0 1 0.167,828.296 Z M81.167,731.897 A11.000,11.000 0 0 1 86.667,722.371 L168.667,675.028 A11.000,11.000 0 0 1 185.167,684.554 L185.167,713.151 A11.000,11.000 0 0 1 179.667,722.677 L97.667,770.020 A11.000,11.000 0 0 1 81.167,760.494 Z" /> +</vector> diff --git a/androidTvApp/src/androidMain/res/values/strings.xml b/androidTvApp/src/androidMain/res/values/strings.xml index ba391b48da..810dcbb5c6 100644 --- a/androidTvApp/src/androidMain/res/values/strings.xml +++ b/androidTvApp/src/androidMain/res/values/strings.xml @@ -29,4 +29,146 @@ <string name="loading">Loading…</string> <string name="error_generic">Something went wrong</string> <string name="coming_soon">Coming soon</string> + + <!-- TV sign-in (TvLoginScreen). One copy deck with Apple TV; see the + server's TV device sign-in spec. The code is one element that + TalkBack reads character by character. --> + <string name="tv_signin_eyebrow">Account</string> + <string name="tv_signin_title">Sign in to %1$s</string> + <string name="tv_signin_step_scan">Scan with your phone\'s camera</string> + <string name="tv_signin_step_url">Or go to %1$s and enter the code</string> + <string name="tv_signin_step_approve">Approve on your phone. This TV signs in by itself.</string> + <string name="tv_signin_nearby_hint">Have Prairie on your phone? Open it on the same Wi‑Fi to sign in this TV.</string> + <string name="tv_signin_session_expired">You were signed out of %1$s. Sign in again.</string> + <string name="tv_signin_code_label">Sign-in code</string> + <string name="tv_signin_qr_description">Sign-in QR code. Or go to %1$s slash activate and enter %2$s.</string> + <string name="tv_signin_status_getting_code">Getting a sign-in code…</string> + <string name="tv_signin_status_waiting">Waiting for approval</string> + <string name="tv_signin_status_opened">Continue on your phone</string> + <string name="tv_signin_status_new_code">New code. Waiting for approval.</string> + <string name="tv_signin_status_signed_in_as">Signed in as %1$s</string> + <string name="tv_signin_status_signed_in">Signed in</string> + <string name="tv_signin_status_couldnt_finish">Couldn\'t finish signing in on this TV.</string> + <string name="tv_signin_status_denied">Sign-in was declined on your phone.</string> + <string name="tv_signin_status_paused">Sign-in paused.</string> + <string name="tv_signin_status_unreachable">Can\'t reach %1$s. Check this TV\'s connection.</string> + <string name="tv_signin_status_too_many">Too many sign-in attempts. Trying again shortly.</string> + <string name="tv_signin_status_failed">Couldn\'t get a sign-in code.</string> + <string name="tv_signin_status_update_required">This server needs an update before this TV can sign in.</string> + <string name="tv_signin_password_only">This server only supports password sign-in.</string> + <string name="tv_signin_action_password">Sign in with a password</string> + <string name="tv_signin_action_change_server">Change server</string> + <string name="tv_signin_action_try_again">Try again</string> + <string name="tv_signin_action_new_code">Show a new code</string> + + <!-- Pair a device: this TV approves another device\'s sign-in --> + <string name="tv_pair_error_enter_code">Enter the code shown on the other device.</string> + <string name="tv_pair_error_sign_in_first">Sign in before approving this device.</string> + <string name="tv_pair_error_not_found">We couldn\'t find that code. Check the code on the other device.</string> + <string name="tv_pair_error_not_found_on">We couldn\'t find that code on %1$s. Check the code on the other device.</string> + <string name="tv_pair_error_expired">This code expired. The other device is showing a new one.</string> + <string name="tv_pair_error_already_signed_in">That device is already signed in.</string> + <string name="tv_pair_error_declined">This sign-in was declined.</string> + <string name="tv_pair_error_canceled">The other device stopped this sign-in.</string> + <string name="tv_pair_error_network">Network error. Check your connection.</string> + <string name="tv_pair_error_provider_unavailable">The sign-in provider can\'t be reached right now, so this device couldn\'t confirm your account. You\'re still signed in. Try again in a moment.</string> + <string name="tv_pair_error_generic">Couldn\'t approve the device. Try again.</string> + + <!-- TV password sign-in form --> + <string name="tv_signin_form_title">Sign in with a password</string> + <string name="tv_signin_form_username_placeholder">Username</string> + <string name="tv_signin_form_password_placeholder">Password</string> + <string name="tv_signin_form_submit">Sign in</string> + <string name="tv_signin_form_submitting">Signing in…</string> + <string name="tv_signin_form_create_account">Create Account</string> + <string name="tv_signin_form_use_phone">Use your phone instead</string> + <string name="tv_signin_form_show_password">Show password</string> + <string name="tv_signin_form_hide_password">Hide password</string> + <string name="tv_signin_error_username_required">Username is required</string> + <string name="tv_signin_error_password_required">Password is required</string> + <string name="tv_signin_error_invalid_credentials">Invalid username or password</string> + <string name="tv_signin_error_account_disabled">Account is disabled</string> + <string name="tv_signin_error_network">Network error. Check your connection.</string> + <string name="tv_signin_error_local_login_disabled">Password sign-in is turned off on this server. Use your phone instead.</string> + <string name="tv_signin_error_not_permitted">Your account isn\'t allowed to use this server.</string> + <string name="tv_signin_error_password_expired">Your password has expired. Change it, then sign in again.</string> + <string name="tv_signin_error_provider_unavailable">The sign-in service can\'t be reached right now. Try again later.</string> + <string name="tv_signin_error_login_failed">Login failed</string> + <string name="tv_signin_error_invalid_credentials_provider">Invalid username or password. If you sign in with %1$s, use your phone instead.</string> + <string name="tv_signin_form_provider_hint">If you sign in with %1$s, use your phone instead.</string> + <string name="tv_signin_error_account_required">You don\'t have an account on this server yet. Ask an admin to add you.</string> + <string name="tv_signin_error_email_in_use">An account with this email already exists. Ask an admin to connect it to %1$s.</string> + <string name="tv_signin_error_identity_linked_elsewhere">That %1$s account is already connected to another account.</string> + <string name="tv_signin_error_rate_limited">Too many attempts. Wait a moment, then try again.</string> + <string name="tv_signin_provider_fallback">the sign-in provider</string> + <string name="tv_signin_error_identity_changed">The account or server changed. Start sign-in again.</string> + <string name="tv_signin_error_save_failed">Unable to save this session. Try again.</string> + <string name="tv_signin_error_cleanup_incomplete">Signed in, but local diagnostics cleanup did not finish.</string> + + <!-- "Continue as …": sign-in through a network provider such as Tailscale, + listed only when this TV reached the server through that provider's + network. %1$s is always the provider's own name, never a fixed brand. --> + <string name="tv_signin_network_continue_as">Continue as %1$s</string> + <string name="tv_signin_network_continue_with">Continue with %1$s</string> + <string name="tv_signin_network_via">via %1$s</string> + <string name="tv_signin_error_network_identity_required">Open this server at its %1$s address to sign in this way.</string> + <string name="tv_signin_error_network_not_permitted">%1$s doesn\'t allow this device to sign in to this server.</string> + <!-- The TV has no Sign-in settings: the phone app and the web app do. --> + <string name="tv_signin_error_network_email_in_use">An account with your email already exists. Sign in to it with Prairie on your phone or on the web, connect %1$s in Sign-in settings, then try again.</string> + <string name="tv_signin_error_network_provider_gone">%1$s is no longer available on this server.</string> + + <!-- Nearby phone setup and sign-in, shown on server setup and on the + sign-in screen. People compare the TV's sign-in code; the phone may + be any phone or tablet. --> + <string name="tv_pairing_eyebrow_connect">Step 01 — Connect</string> + <string name="tv_pairing_eyebrow_signin">Step 02 — Account</string> + <string name="tv_pairing_eyebrow_almost">Almost there</string> + <string name="tv_pairing_eyebrow_done">All set</string> + <string name="tv_pairing_connected_title">Phone or tablet connected</string> + <string name="tv_pairing_connected_detail">On your phone or tablet, choose which servers this TV should sign in to.</string> + <string name="tv_pairing_connected_detail_signin">Continue on your phone or tablet.</string> + <string name="tv_pairing_consent_title">Allow this setup?</string> + <string name="tv_pairing_consent_title_signin">Allow this sign-in?</string> + <string name="tv_pairing_consent_detail">A nearby phone or tablet wants to sign this TV in to %1$s.</string> + <string name="tv_pairing_allow">Allow</string> + <string name="tv_pairing_dont_allow">Don\'t allow</string> + <string name="tv_pairing_cancel">Cancel</string> + <string name="tv_pairing_starting_title">Setting up this TV</string> + <string name="tv_pairing_starting_title_signin">Signing in this TV</string> + <string name="tv_pairing_starting_detail">Starting secure sign-in…</string> + <string name="tv_pairing_server_label">SETTING UP</string> + <string name="tv_pairing_server_label_signin">SIGNING IN TO</string> + <string name="tv_pairing_confirm_title">Confirm on your phone or tablet</string> + <string name="tv_pairing_confirm_title_automatic">Signing in</string> + <string name="tv_pairing_confirm_detail">Check that your phone or tablet shows this code, then approve.</string> + <string name="tv_pairing_confirm_detail_automatic">Your phone or tablet is checking this code for you.</string> + <!-- Rollout fallback: phones released before user codes compare the match + words. Remove together with the phones' "Older TV apps show ..." line. --> + <string name="tv_pairing_confirm_older_phones">Older phones show %1$s instead.</string> + <string name="tv_pairing_signed_in">Signed in</string> + <string name="tv_pairing_signed_in_count">Signed in to %1$d servers</string> + <string name="tv_pairing_finishing">Finishing up on your phone or tablet…</string> + <string name="tv_pairing_completed_title">You\'re all set</string> + <string name="tv_pairing_completed_none">Taking you to your profiles…</string> + <string name="tv_pairing_completed_named">Signed in to %1$s. Taking you to your profiles…</string> + <string name="tv_pairing_continue">Continue</string> + <string name="tv_pairing_failed_title">Setup didn\'t finish</string> + <string name="tv_pairing_failed_title_signin">Sign-in didn\'t finish</string> + <string name="tv_pairing_failed_generic">Something went wrong signing in to %1$s. Try again from your phone, or sign in on this TV.</string> + <string name="tv_pairing_failed_denied">The sign-in to %1$s was declined on the phone.</string> + <string name="tv_pairing_failed_expired">The code for %1$s expired before it was approved. Try again from your phone.</string> + <string name="tv_pairing_failed_unreachable">This TV can\'t reach %1$s. Check its network connection and try again.</string> + <string name="tv_pairing_failed_identity">The address for %1$s answered as a different server, so nothing was signed in.</string> + <string name="tv_pairing_failed_update">%1$s or this app needs an update before this TV can sign in.</string> + <string name="tv_pairing_try_again">Try again</string> + <string name="tv_pairing_unreachable_title">Can\'t reach %1$s</string> + <string name="tv_pairing_unreachable_provider">This TV can\'t reach %1$s through %2$s. Install or open the %2$s app on this TV and make sure it has access to the server\'s network, then try again.</string> + <string name="tv_pairing_unreachable_public">This TV can\'t reach %1$s at %2$s. Check its network connection and try again.</string> + <string name="tv_pairing_use_alternate">Use %1$s</string> + <string name="tv_setup_subtitle">Use your phone or tablet, or enter the server address with the remote.</string> + <string name="tv_setup_phone_pill">RECOMMENDED · USE YOUR PHONE</string> + <string name="tv_setup_phone_looking">Looking for a phone or tablet…</string> + <string name="tv_setup_phone_detail">Open Prairie on a phone or tablet on the same Wi‑Fi. Accept the setup card and Prairie will securely bring over the server and account.</string> + <string name="marker_skip_recap">Skip Recap</string> + <string name="marker_skip_credits">Skip Credits</string> </resources> diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/cast/FakeRemotePlaybackApi.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/cast/FakeRemotePlaybackApi.kt new file mode 100644 index 0000000000..1a577acfcc --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/cast/FakeRemotePlaybackApi.kt @@ -0,0 +1,66 @@ +package org.prairieserver.prairie.tv.cast + +import org.prairieserver.prairie.cast.PrairieCastProtocol +import org.prairieserver.prairie.model.auth.DeviceLoginCapabilityResponse +import org.prairieserver.prairie.model.auth.DeviceLoginPollResponse +import org.prairieserver.prairie.model.auth.DeviceLoginStartResponse +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.api.DeviceLoginApi + +/** Remote-playback device-login API that approves every handoff unless told otherwise. */ +internal class FakeRemotePlaybackApi : DeviceLoginApi { + var endCalls = 0 + var approvedProfileId = "profile-1" + var pollStatus = "approved" + + override suspend fun remotePlaybackCapabilityAt(serverUrl: String) = ApiResult.Success( + DeviceLoginCapabilityResponse( + remotePlaybackHandoff = true, + protocolVersions = listOf(PrairieCastProtocol.version), + ), + ) + + override suspend fun startRemotePlaybackAt( + serverUrl: String, + deviceName: String?, + devicePlatform: String?, + ) = ApiResult.Success( + DeviceLoginStartResponse( + deviceCode = "device-code", + userCode = "USER", + matchCode = "12", + verificationUri = "$serverUrl/pair", + verificationUriComplete = "$serverUrl/pair?code=USER", + expiresAt = "2099-01-01T00:00:00Z", + expiresIn = 60, + interval = 1, + deviceName = deviceName.orEmpty(), + devicePlatform = devicePlatform.orEmpty(), + clientPurpose = "remote_playback", + temporary = true, + ), + ) + + override suspend fun pollDeviceLoginAt(serverUrl: String, deviceCode: String) = ApiResult.Success( + DeviceLoginPollResponse( + status = pollStatus, + accessToken = "access", + refreshToken = "refresh", + profileId = approvedProfileId, + profileToken = "profile-token", + temporary = true, + ), + ) + + override suspend fun endRemotePlayback(scope: AuthScopeSnapshot): ApiResult<Unit> { + endCalls += 1 + return ApiResult.Success(Unit) + } + + override suspend fun startDeviceLogin(deviceName: String?, devicePlatform: String?) = error("unused") + override suspend fun pollDeviceLogin(deviceCode: String) = error("unused") + override suspend fun lookupDeviceLogin(token: String?, code: String?) = error("unused") + override suspend fun approveDeviceLogin(token: String?, code: String?) = error("unused") + override suspend fun denyDeviceLogin(token: String?, code: String?) = error("unused") +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/cast/RemotePlaybackHandoffOrderTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/cast/RemotePlaybackHandoffOrderTest.kt new file mode 100644 index 0000000000..f74f5e4b0c --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/cast/RemotePlaybackHandoffOrderTest.kt @@ -0,0 +1,101 @@ +package org.prairieserver.prairie.tv.cast + +import android.app.Application +import kotlin.test.* +import kotlinx.coroutines.test.runTest +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.prairieserver.prairie.cast.PrairieCastHandoffOffer +import org.prairieserver.prairie.network.AndroidServerRegistry +import org.prairieserver.prairie.network.TokenManagerImpl + +/** + * Whatever the TV is playing has to close its server session under the + * identity it started with. The handoff therefore stops it only once the + * phone's profile is approved, and before that profile replaces the TV's + * credentials. An offer that is denied or fails leaves playback alone. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class RemotePlaybackHandoffOrderTest { + + private val api = FakeRemotePlaybackApi() + private val tokens = TokenManagerImpl() + private val manager = RemotePlaybackIdentityManager(api, tokens, deviceNameProvider = { "TV" }) + + @Test fun theTvsOwnPlaybackStopsBeforeTheFirstPhoneProfileIsInstalled() = runTest { + var hadTemporaryScopeAtStop: Boolean? = null + + prepare(offer()) { hadTemporaryScopeAtStop = tokens.hasTemporaryScope() } + + assertEquals(false, hadTemporaryScopeAtStop) + assertTrue(tokens.hasTemporaryScope()) + } + + @Test fun anEarlierPhoneProfileOutlivesItsPlayersStop() = runTest { + prepare(offer()) + val earlier = assertNotNull(manager.activeIdentity) + var identityAtStop: String? = null + var endCallsAtStop = -1 + + api.approvedProfileId = "profile-2" + prepare(offer(profileId = "profile-2")) { + identityAtStop = manager.activeIdentity?.generationId + endCallsAtStop = api.endCalls + } + + assertEquals(earlier.generationId, identityAtStop) + assertEquals(0, endCallsAtStop) + assertEquals(1, api.endCalls) + assertNotEquals(earlier.generationId, manager.activeIdentity?.generationId) + } + + @Test fun aDeniedHandoffNeitherStopsPlaybackNorEndsTheCurrentProfile() = runTest { + prepare(offer()) + val current = assertNotNull(manager.activeIdentity) + var stopped = false + + api.pollStatus = "denied" + assertFails { prepare(offer(profileId = "profile-2")) { stopped = true } } + + assertFalse(stopped) + assertEquals(current, manager.activeIdentity) + assertTrue(tokens.hasTemporaryScope()) + assertEquals(0, api.endCalls) + } + + @Test fun aStopThatFailsKeepsTheCurrentProfile() = runTest { + prepare(offer()) + val current = assertNotNull(manager.activeIdentity) + + api.approvedProfileId = "profile-2" + assertFails { prepare(offer(profileId = "profile-2")) { error("still playing") } } + + assertEquals(current, manager.activeIdentity) + assertEquals(0, api.endCalls) + } + + private suspend fun prepare( + offer: PrairieCastHandoffOffer, + beforeActivation: suspend () -> Unit = {}, + ) = manager.prepare( + offer, + CONTROLLER, + controllerDeviceName = null, + receiverRun = 1L, + beforeActivation = beforeActivation, + ) {} + + private fun offer(profileId: String = "profile-1") = PrairieCastHandoffOffer( + requestId = "request", + serverId = AndroidServerRegistry.idFor(SERVER_URL), + serverURL = SERVER_URL, + profileId = profileId, + ) + + private companion object { + const val SERVER_URL = "https://media.example.test" + const val CONTROLLER = "phone-1" + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/cast/RemotePlaybackIdentityClaimTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/cast/RemotePlaybackIdentityClaimTest.kt new file mode 100644 index 0000000000..07a3fef1cf --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/cast/RemotePlaybackIdentityClaimTest.kt @@ -0,0 +1,152 @@ +package org.prairieserver.prairie.tv.cast + +import android.app.Application +import kotlin.test.* +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.prairieserver.prairie.cast.PrairieCastHandoffOffer +import org.prairieserver.prairie.model.auth.DeviceLoginStartResponse +import org.prairieserver.prairie.network.AndroidServerRegistry +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.TokenManagerImpl +import org.prairieserver.prairie.network.api.DeviceLoginApi + +/** + * The receiver's stop() ends the temporary identity only after the player's + * teardown, so a returning phone can hand off again (reusing the same + * generation, or replacing it) in the meantime. The deferred cleanup must leave + * an identity claimed by a later receiver run alone, but still end one that + * only the stopped run ever claimed. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class RemotePlaybackIdentityClaimTest { + + private val api = FakeRemotePlaybackApi() + private val tokens = TokenManagerImpl() + private val manager = RemotePlaybackIdentityManager(api, tokens, deviceNameProvider = { "TV" }) + + @Test fun reuseByALaterRunBlocksTheStaleCleanup() = runTest { + prepare(offer(), run = STOPPED_RUN) + val installed = assertNotNull(manager.activeIdentity) + + val ready = prepare(offer(), run = NEXT_RUN) + + assertTrue(ready.reused) + assertFalse(manager.endIfNotClaimedSince(STOPPED_RUN)) + assertEquals(installed.generationId, manager.activeIdentity?.generationId) + assertTrue(tokens.hasTemporaryScope()) + assertEquals(0, api.endCalls) + } + + @Test fun reuseByTheStoppedRunStillLetsTheCleanupEndIt() = runTest { + prepare(offer(), run = STOPPED_RUN) + + // A handoff from the session stop() is tearing down can finish its + // reuse after stop() ran. That run is gone, so nothing else ends it. + assertTrue(prepare(offer(), run = STOPPED_RUN).reused) + + assertTrue(manager.endIfNotClaimedSince(STOPPED_RUN)) + assertNull(manager.activeIdentity) + assertFalse(tokens.hasTemporaryScope()) + assertEquals(1, api.endCalls) + } + + @Test fun anOfferThatFailsBeforeClaimingLeavesItToTheCleanup() = runTest { + prepare(offer(), run = STOPPED_RUN) + + // The server id does not encode the URL, so validation rejects it + // before the active identity is touched. + assertFails { prepare(offer().copy(serverId = "mismatched"), run = NEXT_RUN) } + + assertTrue(manager.endIfNotClaimedSince(STOPPED_RUN)) + assertNull(manager.activeIdentity) + assertFalse(tokens.hasTemporaryScope()) + assertEquals(1, api.endCalls) + } + + @Test fun aReplacementIdentityIsNotEndedByTheOldCleanup() = runTest { + prepare(offer(), run = STOPPED_RUN) + val installed = assertNotNull(manager.activeIdentity) + + api.approvedProfileId = "profile-2" + prepare(offer(profileId = "profile-2"), run = NEXT_RUN) + val replacement = assertNotNull(manager.activeIdentity) + + assertNotEquals(installed.generationId, replacement.generationId) + assertFalse(manager.endIfNotClaimedSince(STOPPED_RUN)) + assertEquals(replacement, manager.activeIdentity) + assertTrue(tokens.hasTemporaryScope()) + } + + @Test fun anIdentityBeingEndedIsNeverReused() = runTest { + val gated = GatedLogoutApi(api) + val manager = RemotePlaybackIdentityManager(gated, tokens, deviceNameProvider = { "TV" }) + suspend fun prepareOn(run: Long) = + manager.prepare(offer(), CONTROLLER, controllerDeviceName = null, receiverRun = run) {} + + prepareOn(STOPPED_RUN) + val ending = assertNotNull(manager.activeIdentity) + val logout = CompletableDeferred<Unit>() + gated.logoutGate = logout + + val end = launch { manager.end(ending.generationId) } + runCurrent() + // The logout is in flight: nothing outside the lock may launch on it. + assertNull(manager.activeIdentity) + + val sameOffer = async { prepareOn(NEXT_RUN) } + runCurrent() + logout.complete(Unit) + end.join() + val ready = sameOffer.await() + + assertFalse(ready.reused) + assertEquals(2, gated.startCalls) + assertNotEquals(ending.generationId, manager.activeIdentity?.generationId) + } + + private suspend fun prepare(offer: PrairieCastHandoffOffer, run: Long) = + manager.prepare(offer, CONTROLLER, controllerDeviceName = null, receiverRun = run) {} + + private fun offer(profileId: String = "profile-1") = PrairieCastHandoffOffer( + requestId = "request", + serverId = AndroidServerRegistry.idFor(SERVER_URL), + serverURL = SERVER_URL, + profileId = profileId, + ) + + /** Counts handoff starts and can hold a logout open; the rest is [FakeRemotePlaybackApi]. */ + private class GatedLogoutApi(private val inner: FakeRemotePlaybackApi) : DeviceLoginApi by inner { + var startCalls = 0 + var logoutGate: CompletableDeferred<Unit>? = null + + override suspend fun startRemotePlaybackAt( + serverUrl: String, + deviceName: String?, + devicePlatform: String?, + ): ApiResult<DeviceLoginStartResponse> { + startCalls += 1 + return inner.startRemotePlaybackAt(serverUrl, deviceName, devicePlatform) + } + + override suspend fun endRemotePlayback(scope: AuthScopeSnapshot): ApiResult<Unit> { + logoutGate?.await() + return inner.endRemotePlayback(scope) + } + } + + private companion object { + const val SERVER_URL = "https://media.example.test" + const val CONTROLLER = "phone-1" + const val STOPPED_RUN = 1L + const val NEXT_RUN = 2L + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/cast/RemotePlaybackOwnIdentityPolicyTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/cast/RemotePlaybackOwnIdentityPolicyTest.kt new file mode 100644 index 0000000000..fafbc7ac76 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/cast/RemotePlaybackOwnIdentityPolicyTest.kt @@ -0,0 +1,55 @@ +package org.prairieserver.prairie.tv.cast + +import android.app.Application +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.prairieserver.prairie.network.AndroidServerRegistry + +/** + * The same-profile fast path skips the server's profile exchange, so every + * condition must hold; failing any one sends the offer down the full handoff. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class RemotePlaybackOwnIdentityPolicyTest { + + @Test fun acceptsTheTvsOwnProfileOnItsOwnServer() { + assertTrue(accepts()) + } + + @Test fun refusesEachMissingCondition() { + assertFalse(accepts(authorizedAtHello = false), "a controller not authorized at hello") + assertFalse(accepts(holdsTemporaryIdentity = true), "a phone's identity installed or ending") + assertFalse(accepts(signedIn = false), "a TV that is signed out") + assertFalse(accepts(ownServerId = OTHER_SERVER_ID), "a different server") + assertFalse(accepts(ownServerId = null), "no active server") + assertFalse(accepts(ownProfileId = "profile-2"), "a different profile") + assertFalse(accepts(ownProfileId = null), "no active profile") + assertFalse(accepts(ownProfileId = ""), "a blank profile") + } + + private fun accepts( + authorizedAtHello: Boolean = true, + holdsTemporaryIdentity: Boolean = false, + signedIn: Boolean = true, + ownServerId: String? = SERVER_ID, + ownProfileId: String? = "profile-1", + ) = RemotePlaybackOwnIdentityPolicy.accepts( + authorizedAtHello = authorizedAtHello, + holdsTemporaryIdentity = holdsTemporaryIdentity, + signedIn = signedIn, + ownServerId = ownServerId, + ownProfileId = ownProfileId, + offerServerId = SERVER_ID, + offerProfileId = "profile-1", + ) + + private companion object { + val SERVER_ID = AndroidServerRegistry.idFor("https://media.example.test") + val OTHER_SERVER_ID = AndroidServerRegistry.idFor("https://other.example.test") + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/testing/FakePlayerSettingsStore.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/testing/FakePlayerSettingsStore.kt index 61052b51af..e20599b9a0 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/testing/FakePlayerSettingsStore.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/testing/FakePlayerSettingsStore.kt @@ -142,7 +142,7 @@ internal class FakePlayerSettingsStore : PlayerSettingsStore { override suspend fun refreshFromServer() {} override suspend fun setSubtitleDeviceOverrideEnabled(enabled: Boolean) {} override suspend fun resetDeviceSetting(key: String) {} - override suspend fun resetAllDeviceSettings() {} + override suspend fun resetAllDeviceSettings(): Boolean = true var importSucceeds = true override suspend fun importLegacyDeviceSettings( authority: org.prairieserver.prairie.network.AuthScopeSnapshot, diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/TvUsabilityGuardTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/TvUsabilityGuardTest.kt index fedc552a8f..e8c1882a8f 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/TvUsabilityGuardTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/TvUsabilityGuardTest.kt @@ -52,11 +52,12 @@ class TvUsabilityGuardTest { assertTrue(destinations.contains("TvRootDestination.LibraryType")) assertTrue(destinations.contains("TvRootDestination.Calendar")) // For You IS a root tab again (QA 2026-07-08, tvOS .recommendations - // parity, with its Watchlist/Favorites dropdown). Search stays a + // parity, with its Watchlist/Favorites dropdown), and so is Requests + // while the server enables it (tvOS `.requests`). Search stays a // trailing icon, and secondary utility surfaces never become tabs. assertTrue(destinations.contains("TvRootDestination.ForYou")) + assertTrue(destinations.contains("if (requestsEnabled) add(TvRootDestination.Requests)")) assertFalse(destinations.contains("TvRootDestination.Search")) - assertFalse(destinations.contains("TvRootDestination.Requests")) assertFalse(destinations.contains("TvRootDestination.Settings")) } diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvControlWiringCallSiteTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvControlWiringCallSiteTest.kt index 1883b54765..d1feb4c866 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvControlWiringCallSiteTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvControlWiringCallSiteTest.kt @@ -39,8 +39,8 @@ class TvControlWiringCallSiteTest { wiring = "enabled = enabled", ), StructuralControl( - path = "ui/components/TvAuroraChrome.kt", - composable = "AuroraPrimaryButton", + path = "ui/components/marquee/TvMarquee.kt", + composable = "TvMarqueeButton", primitive = ".clickable(", wiring = "enabled = enabled", ), @@ -91,7 +91,7 @@ class TvControlWiringCallSiteTest { listOf( Triple("ui/components/TvPinEntryDialog.kt", "PinKeypad", "PinKey"), Triple( - "ui/screens/watchtogether/TvJoinCodeDialog.kt", + "ui/screens/watchparty/TvJoinCodeDialog.kt", "TvJoinCodeDialog", "JoinCodeKey", ), @@ -169,9 +169,9 @@ class TvControlWiringCallSiteTest { fun noControlFakesDisabledStateInsideItsClickHandler() { listOf( "ui/components/TvOptionDialog.kt", - "ui/components/TvAuroraChrome.kt", + "ui/components/marquee/TvMarquee.kt", "ui/components/TvPinEntryDialog.kt", - "ui/screens/watchtogether/TvJoinCodeDialog.kt", + "ui/screens/watchparty/TvJoinCodeDialog.kt", ).forEach { path -> assertFalse( source(path).containsLoosely("onClick = { if ("), diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModelTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModelTest.kt index 4e7a6d99d1..0ed33eb795 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModelTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModelTest.kt @@ -1,14 +1,144 @@ package org.prairieserver.prairie.tv.ui.components import java.io.File +import org.prairieserver.prairie.model.catalog.MediaItemUserState +import org.prairieserver.prairie.model.catalog.DisplayRating import org.prairieserver.prairie.model.catalog.OverlaySummary +import org.prairieserver.prairie.model.section.ResolvedSection import org.prairieserver.prairie.model.section.SectionItem +import org.prairieserver.prairie.model.settings.EpisodeSpoilerPrefs import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue class TvFocusMarqueeModelTest { + @Test + fun rowUpdatesRefreshWatchStateWithoutChangingFocusOrEnrichment() { + val state = TvFocusMarqueeState() + state.spoilerPrefs = EpisodeSpoilerPrefs(true, true) + val episode = SectionItem( + contentId = "episode", type = "episode", title = "Episode", + overview = "Spoiler", backdropUrl = "https://example.test/still.jpg", + userState = MediaItemUserState(played = true), + ) + val row = ResolvedSection("row", "next_up", "Next Up", items = listOf(episode)) + state.preview(episode, row.title, row.id) + state.commit(state.candidate) + val enrichment = TvMarqueeEnrichment(null, "https://example.test/series.jpg", null, backdropIsEpisodeStill = false) + state.applyEnrichment(episode.contentId, enrichment) + + val unwatched = episode.copy(userState = MediaItemUserState(played = false)) + state.refreshSources(listOf(row.copy(items = listOf(unwatched)))) + + assertEquals(null, state.content?.synopsis) + assertEquals(null, state.content?.heroBackdropUrl) + assertEquals("row#episode", state.focusedMarqueeId) + assertTrue(state.hasSettledRealFocus) + assertEquals(enrichment, state.enrichment) + assertEquals(enrichment.backdropUrl, state.backdropContent?.heroBackdropUrl) + // A detail backdrop that is, or may be, the episode's still stays hidden. + state.applyEnrichment(episode.contentId, enrichment.copy(backdropIsEpisodeStill = null)) + assertEquals(null, state.backdropContent?.heroBackdropUrl) + + state.refreshSources(listOf(row)) + assertEquals("Spoiler", state.content?.synopsis) + assertEquals(episode.backdropUrl, state.content?.heroBackdropUrl) + } + + @Test + fun aDepartingLayerFollowsAProtectionChange() { + val episode = SectionItem( + contentId = "episode", type = "episode", title = "Episode", + overview = "Spoiler", backdropUrl = "https://example.test/still.jpg", + ) + val shown = TvMarqueeContent.from(episode, "Next Up", "row") + + val protected = shown.underPrefs(EpisodeSpoilerPrefs(true, true)) + assertEquals(null, protected.heroBackdropUrl) + assertEquals(null, protected.synopsis) + assertEquals(shown.id, protected.id) + assertTrue(shown.underPrefs(EpisodeSpoilerPrefs.NONE) === shown) + val watched = TvMarqueeContent.from(episode.copy(userState = MediaItemUserState(played = true)), "Next Up", "row") + assertTrue(watched.underPrefs(EpisodeSpoilerPrefs(true, true)) === watched) + // An unchanged layer without a description stays the same instance. + val noOverview = TvMarqueeContent.from(episode.copy(overview = null), "Next Up", "row", EpisodeSpoilerPrefs(true, true)) + assertTrue(noOverview.underPrefs(EpisodeSpoilerPrefs(true, true)) === noOverview) + } + + @Test + fun hidingKeepsAnExplicitSeriesBackdropFromEnrichment() { + val episode = SectionItem( + contentId = "episode", type = "episode", title = "Episode", + overview = "Spoiler", backdropUrl = "https://example.test/still.jpg", + ) + val shown = TvMarqueeContent.from(episode, "Next Up", "row") + val series = TvMarqueeEnrichment(null, "https://example.test/series.jpg", null, backdropIsEpisodeStill = false) + + val keptSeries = shown.withEnrichment(series).underPrefs(EpisodeSpoilerPrefs(true, true)) + assertEquals("https://example.test/series.jpg", keptSeries.heroBackdropUrl) + assertEquals(null, keptSeries.synopsis) + val unknown = shown.withEnrichment(series.copy(backdropIsEpisodeStill = null)).underPrefs(EpisodeSpoilerPrefs(true, true)) + assertEquals(null, unknown.heroBackdropUrl) + } + + @Test + fun rowUpdatesKeepDisplayedAndPendingCopiesBoundToTheirOwnRows() { + val state = TvFocusMarqueeState() + state.spoilerPrefs = EpisodeSpoilerPrefs(true, true) + val watched = SectionItem( + contentId = "episode", type = "episode", title = "Episode", + overview = "Displayed spoiler", userState = MediaItemUserState(played = true), + ) + val displayedRow = ResolvedSection("displayed", "next_up", "Next Up", items = listOf(watched)) + val pendingItem = watched.copy(overview = "Pending spoiler") + val pendingRow = displayedRow.copy(id = "pending", items = listOf(pendingItem)) + state.preview(watched, displayedRow.title, displayedRow.id) + state.commit(state.candidate) + state.preview(pendingItem, pendingRow.title, pendingRow.id) + + val unwatched = watched.copy(userState = MediaItemUserState(played = false)) + state.refreshSources(listOf(pendingRow, displayedRow.copy(items = listOf(unwatched)))) + + assertEquals(unwatched, state.content?.source) + assertEquals(null, state.content?.synopsis) + assertEquals(pendingItem, state.candidate?.source) + assertEquals("Pending spoiler", state.candidate?.synopsis) + assertEquals("pending#episode", state.focusedMarqueeId) + + val displayed = state.content + val pending = state.candidate + state.refreshSources(emptyList()) + assertEquals(displayed, state.content) + assertEquals(pending, state.candidate) + } + + @Test + fun preferencesReprojectTheDisplayedAndPendingEpisodes() { + val state = TvFocusMarqueeState() + val episode = SectionItem( + contentId = "unstarted", type = "episode", title = "Episode", + overview = "Spoiler", backdropUrl = "https://example.test/still.jpg", + ) + state.preview(episode, "Next Up") + state.commit(state.candidate) + state.preview(episode.copy(contentId = "pending"), "Next Up") + val pendingBeforeHydration = state.candidate + + state.spoilerPrefs = EpisodeSpoilerPrefs(true, true) + + assertEquals(null, state.content?.synopsis) + assertEquals(null, state.backdropContent?.heroBackdropUrl) + assertEquals(null, state.candidate?.synopsis) + state.commit(pendingBeforeHydration) + assertEquals(null, state.content?.synopsis) + assertEquals(null, state.backdropContent?.heroBackdropUrl) + + state.spoilerPrefs = EpisodeSpoilerPrefs.NONE + assertEquals("Spoiler", state.content?.synopsis) + assertEquals(episode.backdropUrl, state.backdropContent?.heroBackdropUrl) + } + @Test fun movieHeroSeparatesEditorialMetadataFromFormatBadges() { val content = TvMarqueeContent.from( @@ -32,8 +162,12 @@ class TvFocusMarqueeModelTest { assertEquals(listOf("PG-13"), content.badges) assertEquals( - listOf("2016", "1h 56m", "7.9", "Science Fiction"), - content.metaParts, + listOf("2016", "1h 56m", "IMDb 7.9", "Science Fiction"), + content.metaText(), + ) + assertEquals( + TvHeroFactToken.ExternalRating(DisplayRating("imdb", "IMDb", 79.0, "7.9")), + content.metaParts[2], ) assertEquals("4K · Dolby Vision · Atmos", content.specLine) } @@ -61,7 +195,10 @@ class TvFocusMarqueeModelTest { assertEquals("The Last of Us", content.title) assertEquals(listOf("TV-MA"), content.badges) - assertEquals(listOf("S01E03", "Long, Long Time"), content.metaParts) + assertEquals( + listOf(TvHeroFactToken.TextToken("S01E03"), TvHeroFactToken.TextToken("Long, Long Time", truncates = true)), + content.metaParts, + ) assertEquals("1080P · EAC3", content.specLine) } @@ -82,9 +219,9 @@ class TvFocusMarqueeModelTest { rowTitle = "Continue Watching", ) - assertEquals(listOf("S03E01", "Persuader"), content.metaParts) - assertFalse(content.metaParts.any { it.contains("left", ignoreCase = true) }) - assertFalse(content.metaParts.any { it.contains("min", ignoreCase = true) }) + assertEquals(listOf("S03E01", "Persuader"), content.metaText()) + assertFalse(content.metaText().any { it.contains("left", ignoreCase = true) }) + assertFalse(content.metaText().any { it.contains("min", ignoreCase = true) }) } @Test @@ -144,7 +281,7 @@ class TvFocusMarqueeModelTest { ) assertEquals(emptyList(), content.metaParts) - } + } } @Test @@ -160,7 +297,7 @@ class TvFocusMarqueeModelTest { rowTitle = "Row", ) - assertEquals(listOf("2h"), content.metaParts) + assertEquals(listOf("2h"), content.metaText()) } @Test @@ -176,7 +313,7 @@ class TvFocusMarqueeModelTest { rowTitle = "Row", ) - assertEquals(listOf("8.4"), content.metaParts) + assertEquals(listOf("IMDb 8.4"), content.metaText()) } @Test @@ -192,7 +329,7 @@ class TvFocusMarqueeModelTest { rowTitle = "Row", ) - assertEquals(listOf("2h 5m"), content.metaParts) + assertEquals(listOf("2h 5m"), content.metaText()) } @Test @@ -208,6 +345,15 @@ class TvFocusMarqueeModelTest { rowTitle = "Row", ) - assertEquals(listOf("1h 56m"), content.metaParts) + assertEquals(listOf("1h 56m"), content.metaText()) + } + + private fun TvMarqueeContent.metaText(): List<String> = metaParts.map { token -> + when (token) { + is TvHeroFactToken.TextToken -> token.value + is TvHeroFactToken.ExternalRating -> "${token.rating.name} ${token.rating.display}" + is TvHeroFactToken.Rating -> token.value + is TvHeroFactToken.Chip -> token.value + } } } diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvMarqueeTranslationTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvMarqueeTranslationTest.kt new file mode 100644 index 0000000000..5f86f16b68 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvMarqueeTranslationTest.kt @@ -0,0 +1,161 @@ +package org.prairieserver.prairie.tv.ui.components + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import org.prairieserver.prairie.network.api.MetadataAiApi +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.model.metadata.MetadataAiStatus +import org.prairieserver.prairie.model.metadata.MetadataTranslationJob +import org.prairieserver.prairie.model.catalog.ItemDetail +import org.prairieserver.prairie.repository.MetadataAiRepository +import kotlin.test.assertEquals + +import org.prairieserver.prairie.model.section.ResolvedSection +import org.prairieserver.prairie.model.section.SectionItem +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class TvMarqueeTranslationTest { + private val pendingCard = SectionItem( + contentId = "movie-1", + type = "movie", + title = "Film", + overview = "Original", + pendingTranslationLanguage = "nl", + ) + private val featured = ResolvedSection( + id = "featured", + sectionType = "featured", + title = "Featured", + featured = true, + items = listOf(pendingCard), + ) + private val ordinary = ResolvedSection( + id = "recently_added", + sectionType = "recently_added", + title = "Recently Added", + items = listOf(pendingCard), + ) + private val sections = listOf(featured, ordinary) + + @Test + fun aCardFocusedInAFeaturedSectionTranslatesOnView() { + val content = TvMarqueeContent.from(pendingCard, featured.title, featured.id) + + assertTrue(isFeaturedMarqueeContent(content, sections)) + } + + @Test + fun theSameCardFocusedInAnOrdinaryRowDoesNot() { + val content = TvMarqueeContent.from(pendingCard, ordinary.title, ordinary.id) + + assertFalse(isFeaturedMarqueeContent(content, sections)) + } + + @Test + fun noContentOrAnUnknownRowDoesNot() { + assertFalse(isFeaturedMarqueeContent(null, sections)) + val content = TvMarqueeContent.from(pendingCard, "Gone", "gone") + assertFalse(isFeaturedMarqueeContent(content, sections)) + } + @Test + fun retainedCardQueuesOnlyTheCurrentProfilesPendingLanguage() = runTest { + val requested = mutableListOf<String>() + var reads = 0 + var refreshed = false + val api = object : MetadataAiApi { + override suspend fun status() = ApiResult.Success( + MetadataAiStatus()) + override suspend fun translateDescription(contentId: String, targetLanguage: String, + scope: AuthScopeSnapshot?) : ApiResult<MetadataTranslationJob> { + requested += targetLanguage + return ApiResult.Success( + MetadataTranslationJob("job", "item", contentId, targetLanguage, "pending")) + } + override suspend fun refreshDetail(contentId: String, scope: AuthScopeSnapshot?): ApiResult<ItemDetail> { + reads++ + return ApiResult.Success(ItemDetail( + contentId, "movie", title = "Film", overview = if (reads == 1) "Original" else "Localized", + pendingTranslationLanguage = if (reads == 1) "de" else null, + machineTranslatedFields = if (reads == 1) emptyList() else listOf("overview"))) + } + } + val state = TvMarqueeTranslationState(MetadataAiRepository(api), this) + state.translateOnView(pendingCard) { refreshed = true } + advanceUntilIdle() + assertEquals(listOf("de"), requested) + assertTrue(refreshed) + val presented = state.presentation(pendingCard, state.phase.value, state.runningContentId.value) + assertEquals("Localized", presented.synopsis) + assertTrue(presented.machineTranslated) + assertFalse(state.working.value) + } + + @Test + fun disposingDuringLanguagePreflightCancelsItsQueuedWork() = runTest { + val response = CompletableDeferred<ItemDetail>() + var posts = 0 + val api = object : MetadataAiApi { + override suspend fun status() = ApiResult.Success( + MetadataAiStatus()) + override suspend fun translateDescription(contentId: String, targetLanguage: String, + scope: AuthScopeSnapshot?): ApiResult<MetadataTranslationJob> { + posts++ + return ApiResult.Error(503, "unavailable", "Unavailable") + } + override suspend fun refreshDetail(contentId: String, scope: AuthScopeSnapshot?) = + ApiResult.Success(response.await()) + } + val state = TvMarqueeTranslationState(MetadataAiRepository(api), this) + state.translateOnView(pendingCard) {} + runCurrent() + assertTrue(state.working.value) + state.close() + response.complete(ItemDetail("movie-1", "movie", title = "Film", pendingTranslationLanguage = "de")) + runCurrent() + assertEquals(0, posts) + assertFalse(state.working.value) + } + + @Test + fun failedPreflightRetriesOnRefocusWithoutConsumingTheTranslationClaim() = runTest { + var reads = 0 + var posts = 0 + val api = object : MetadataAiApi { + override suspend fun status() = ApiResult.Success(MetadataAiStatus()) + override suspend fun translateDescription(contentId: String, targetLanguage: String, + scope: AuthScopeSnapshot?): ApiResult<MetadataTranslationJob> { + posts++ + return ApiResult.Success(MetadataTranslationJob("job", "item", contentId, targetLanguage, "pending")) + } + override suspend fun refreshDetail(contentId: String, scope: AuthScopeSnapshot?): ApiResult<ItemDetail> { + if (++reads == 1) return ApiResult.NetworkError(IllegalStateException("Temporary failure")) + return ApiResult.Success(ItemDetail("movie-1", "movie", title = "Film", + overview = if (reads == 2) "Original" else "Localized", + pendingTranslationLanguage = if (reads == 2) "nl" else null)) + } + } + val state = TvMarqueeTranslationState(MetadataAiRepository(api), this) + state.onFocusChanged(pendingCard.contentId) + state.translateOnView(pendingCard) {} + advanceUntilIdle() + state.translateOnView(pendingCard) {} + advanceUntilIdle() + assertEquals(1, reads, "resting focus must not loop on a failing preflight") + assertEquals(0, posts) + + state.onFocusChanged("another-card") + state.onFocusChanged(pendingCard.contentId) + state.translateOnView(pendingCard) {} + advanceUntilIdle() + assertEquals(1, posts) + assertEquals("Localized", state.presentation(pendingCard, state.phase.value, state.runningContentId.value).synopsis) + } + +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvLoginMatchCodeLayoutTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvLoginMatchCodeLayoutTest.kt deleted file mode 100644 index 79d6025c70..0000000000 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvLoginMatchCodeLayoutTest.kt +++ /dev/null @@ -1,27 +0,0 @@ -package org.prairieserver.prairie.tv.ui.screens.auth - -import kotlin.test.Test -import kotlin.test.assertTrue - -class TvLoginMatchCodeLayoutTest { - - @Test - fun `standard match code fits inside QR card content width`() { - val rowWidthDp = matchCodeRowWidthDp("WILLOW-GRO") - - assertTrue( - actual = rowWidthDp <= 252, - message = "Expected match-code row to fit within 252dp, but it was ${rowWidthDp}dp", - ) - } - - @Test - fun `long match code fits inside QR card content width`() { - val rowWidthDp = matchCodeRowWidthDp("PRAIRIE-SNOW") - - assertTrue( - actual = rowWidthDp <= 252, - message = "Expected long match-code row to fit within 252dp, but it was ${rowWidthDp}dp", - ) - } -} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvLoginScreenNetworkSignInTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvLoginScreenNetworkSignInTest.kt new file mode 100644 index 0000000000..0e450956a4 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvLoginScreenNetworkSignInTest.kt @@ -0,0 +1,225 @@ +package org.prairieserver.prairie.tv.ui.screens.auth + +import android.app.Application +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertAll +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.isEnabled +import androidx.compose.ui.test.isNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.pressKey +import androidx.test.core.app.ApplicationProvider +import androidx.test.platform.app.InstrumentationRegistry +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respondBadRequest +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.prairieserver.prairie.common.pairing.DeviceLoginRepositoryPort +import org.prairieserver.prairie.common.pairing.PairingAuthPort +import org.prairieserver.prairie.common.pairing.PairingDeviceIdentity +import org.prairieserver.prairie.common.pairing.PairingReceiver +import org.prairieserver.prairie.common.pairing.TvPairingAdvertiser +import org.prairieserver.prairie.model.auth.DeviceLoginCapabilityResponse +import org.prairieserver.prairie.model.auth.LoginResponse +import org.prairieserver.prairie.model.auth.NetworkIdentity +import org.prairieserver.prairie.model.auth.OAuthHandshakeCapabilities +import org.prairieserver.prairie.model.auth.SignInProvider +import org.prairieserver.prairie.model.auth.SignInProviders +import org.prairieserver.prairie.model.server.ServerEntry +import org.prairieserver.prairie.network.AccountSessionExpectation +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.ServerRegistry +import org.prairieserver.prairie.network.TokenManager +import org.prairieserver.prairie.network.TokenManagerImpl +import org.prairieserver.prairie.network.api.AuthApi +import org.prairieserver.prairie.network.api.DeviceLoginApi +import org.prairieserver.prairie.network.api.ExternalSignInApi +import org.prairieserver.prairie.network.apiv2.ApiV2Gate +import org.prairieserver.prairie.repository.AuthRepository +import org.prairieserver.prairie.repository.DeviceLoginRepository +import org.prairieserver.prairie.repository.ExternalSignInRepository +import org.prairieserver.prairie.tv.ui.theme.PrairieTvTheme + +/** + * "Continue as …" on a server without device sign-in, where the screen skips + * the code. With passwords on it opens on the password form, which offers the + * button above the fields and focuses it; with network identity alone it + * stays on the code screen, which offers it too. + */ +@RunWith(RobolectricTestRunner::class) +// The reference TV surface: 1920x1080 at 320dpi. +@Config(sdk = [35], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +class TvLoginScreenNetworkSignInTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun thePasswordFormOffersContinueAsAndFocusesIt() { + show(SignInProviders(listOf(LOCAL, TAILSCALE), passwordLogin = true)) + composeRule.onAllNodes(TEXT_FIELD).assertCountEquals(2) + composeRule.onNodeWithText(CONTINUE_AS).assertIsDisplayed().assertIsFocused() + } + + @Test + fun aServerWithOnlyNetworkSignInStillOffersContinueAs() { + show(SignInProviders(listOf(TAILSCALE), passwordLogin = false)) + composeRule.onAllNodes(TEXT_FIELD).assertCountEquals(0) + composeRule.onNodeWithText(CONTINUE_AS).assertIsDisplayed().assertIsFocused() + } + + /** The providers can answer before the device check: focus follows the button into the form. */ + @Test + fun focusFollowsContinueAsIntoTheForm() { + val deviceCheck = CompletableDeferred<Unit>() + show(SignInProviders(listOf(LOCAL, TAILSCALE), passwordLogin = true), deviceCheck) + composeRule.onAllNodes(TEXT_FIELD).assertCountEquals(0) + composeRule.onNodeWithText(CONTINUE_AS).assertIsFocused() + + deviceCheck.complete(Unit) + composeRule.waitUntil(5_000) { composeRule.onAllNodes(TEXT_FIELD).fetchSemanticsNodes().isNotEmpty() } + composeRule.waitForIdle() + composeRule.onNodeWithText(CONTINUE_AS).assertIsFocused() + } + + /** + * While "Continue as …" waits, the password form takes nothing either: its + * fields and Sign in are disabled, focus stays on the waiting button, and + * the D-pad steps past the disabled controls to the secondary actions. + */ + @OptIn(ExperimentalTestApi::class) + @Test + fun continueAsInProgressDisablesThePasswordForm() { + show(SignInProviders(listOf(LOCAL, TAILSCALE), passwordLogin = true)) + composeRule.onAllNodes(TEXT_FIELD).assertCountEquals(2).assertAll(isEnabled()) + composeRule.onNodeWithText(SIGN_IN).assertIsEnabled() + + composeRule.onNodeWithText(CONTINUE_AS).performSemanticsAction(SemanticsActions.OnClick) + composeRule.waitForIdle() + composeRule.onNodeWithText(SIGNING_IN).assertIsFocused() + composeRule.onAllNodes(TEXT_FIELD).assertCountEquals(2).assertAll(isNotEnabled()) + composeRule.onNodeWithText(SIGN_IN).assertIsNotEnabled() + + composeRule.onNodeWithText(SIGNING_IN).performKeyInput { pressKey(Key.DirectionDown) } + composeRule.waitForIdle() + composeRule.onNodeWithText(CHANGE_SERVER).assertIsFocused() + composeRule.onNodeWithText(CHANGE_SERVER).performKeyInput { pressKey(Key.DirectionUp) } + composeRule.waitForIdle() + composeRule.onNodeWithText(SIGNING_IN).assertIsFocused() + } + + private fun show(providers: SignInProviders, deviceCheck: CompletableDeferred<Unit> = CompletableDeferred(Unit)) { + // D-pad, not touch: the screen claims focus only for remote users. + InstrumentationRegistry.getInstrumentation().setInTouchMode(false) + val deviceLogin = DeviceLoginRepository(NoDeviceSignIn(deviceCheck)) + val tokens = object : TokenManager by TokenManagerImpl() { + override suspend fun captureAccountSessionExpectation() = AccountSessionExpectation(0, null, SERVER.url) + } + val viewModel = TvLoginViewModel( + authRepository = AuthRepository(AuthApi(HttpClient(MockEngine { respondBadRequest() }), ApiV2Gate.Unrestricted), tokens), + tokenManager = tokens, + deviceLogin = deviceLogin, + serverRegistry = OneServer, + externalSignIn = ExternalSignInRepository(Providers(providers)), + ) + val receiver = PairingReceiver(NoPairing, DeviceLoginRepositoryPort(deviceLogin), { PairingDeviceIdentity("TV", "tv-1") }) + composeRule.setContent { + PrairieTvTheme { + TvLoginScreen( + onLoginSuccess = {}, + viewModel = viewModel, + pairingReceiver = receiver, + pairingAdvertiser = TvPairingAdvertiser(ApplicationProvider.getApplicationContext(), receiver), + ) + } + } + composeRule.waitUntil(5_000) { composeRule.onAllNodesWithText(CONTINUE_AS).fetchSemanticsNodes().isNotEmpty() } + composeRule.waitForIdle() + } + + /** A server without device sign-in, which says so once [answered] completes. */ + private class NoDeviceSignIn(private val answered: CompletableDeferred<Unit>) : DeviceLoginApi { + override suspend fun deviceLoginCapabilityAt(serverUrl: String): ApiResult<DeviceLoginCapabilityResponse> { + answered.await() + return ApiResult.Success(DeviceLoginCapabilityResponse(deviceLoginAvailable = false)) + } + override suspend fun startDeviceLogin(deviceName: String?, devicePlatform: String?) = error("no device sign-in") + override suspend fun pollDeviceLogin(deviceCode: String) = error("no device sign-in") + override suspend fun lookupDeviceLogin(token: String?, code: String?) = error("unused") + override suspend fun approveDeviceLogin(token: String?, code: String?) = error("unused") + override suspend fun denyDeviceLogin(token: String?, code: String?) = error("unused") + } + + private class Providers(private val providers: SignInProviders) : ExternalSignInApi { + override suspend fun listProviders(serverUrl: String) = ApiResult.Success(providers) + override suspend fun oauthCapabilities(serverUrl: String) = ApiResult.Success(OAuthHandshakeCapabilities.None) + override suspend fun externalSignInCapabilities(scope: AuthScopeSnapshot) = error("unused") + override suspend fun completeOAuthLogin(serverUrl: String, code: String, codeVerifier: String) = error("unused") + // Waits like a server that hasn't answered yet. + override suspend fun signInWithNetworkIdentity(serverUrl: String, signInPath: String): ApiResult<LoginResponse> = + awaitCancellation() + override suspend fun listIdentities(scope: AuthScopeSnapshot) = error("unused") + override suspend fun deleteIdentity(scope: AuthScopeSnapshot, identityId: String) = error("unused") + override suspend fun createLinkTicket(scope: AuthScopeSnapshot, installationId: String, password: String) = error("unused") + override suspend fun completeLink(scope: AuthScopeSnapshot, code: String, codeVerifier: String) = error("unused") + override suspend fun linkWithCredentials( + scope: AuthScopeSnapshot, installationId: String, password: String, username: String, directoryPassword: String, + ) = error("unused") + override suspend fun linkWithNetwork(scope: AuthScopeSnapshot, installationId: String, password: String) = error("unused") + } + + private object NoPairing : PairingAuthPort { + override suspend fun persistApprovedSession( + serverUrl: String, serverName: String?, accessToken: String, refreshToken: String, expiresIn: Long, + expectedIdentity: AccountSessionExpectation?, verifiedServerId: String?, + ) = error("unused") + } + + private object OneServer : ServerRegistry { + override val entries = MutableStateFlow(listOf(SERVER)) + override val activeServerId = MutableStateFlow<String?>(SERVER.id) + override val activeEntry = MutableStateFlow<ServerEntry?>(SERVER) + override suspend fun addOrUpdate(url: String, fetchedName: String?): String = SERVER.id + override suspend fun rename(serverId: String, userOverrideName: String?) = Unit + override suspend fun setFetchedName(serverId: String, fetchedName: String?) = Unit + override suspend fun setProfileId(serverId: String, profileId: String?) = Unit + override suspend fun remove(serverId: String) = Unit + override suspend fun signOut(serverId: String) = Unit + override suspend fun switchTo(serverId: String) = Unit + override suspend fun touchActive() = Unit + } + + private companion object { + const val CONTINUE_AS = "Continue as Alice Example" + const val SIGNING_IN = "Signing in…" + const val SIGN_IN = "Sign in" + const val CHANGE_SERVER = "Change server" + val TEXT_FIELD = SemanticsMatcher.keyIsDefined(SemanticsProperties.EditableText) + val SERVER = ServerEntry(id = "entry-1", url = "https://prairie.test", fetchedName = "Prairie") + val LOCAL = SignInProvider("local", "Prairie account", SignInProvider.Mode.Credentials, true, null, null, null) + val TAILSCALE = SignInProvider( + "plugin:7:tailscale", "Tailscale", SignInProvider.Mode.Network, false, null, "7", null, + networkSignInPath = "/api/v2/auth/network/7/sign-in", + networkIdentity = NetworkIdentity("Alice Example", "alice@example.test"), + ) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvLoginViewModelDeviceSignInTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvLoginViewModelDeviceSignInTest.kt new file mode 100644 index 0000000000..6210910c40 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvLoginViewModelDeviceSignInTest.kt @@ -0,0 +1,936 @@ +package org.prairieserver.prairie.tv.ui.screens.auth + +import androidx.lifecycle.ViewModelStore +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withContext +import org.prairieserver.prairie.common.pairing.NearbySignInOutcome +import org.prairieserver.prairie.model.auth.DeviceLoginCancelResponse +import org.prairieserver.prairie.model.auth.DeviceLoginCapabilityResponse +import org.prairieserver.prairie.model.auth.DeviceLoginDecisionResponse +import org.prairieserver.prairie.model.auth.DeviceLoginLookupResponse +import org.prairieserver.prairie.model.auth.DeviceLoginPollResponse +import org.prairieserver.prairie.model.auth.DeviceLoginStartResponse +import org.prairieserver.prairie.model.auth.LoginResponse +import org.prairieserver.prairie.model.auth.NetworkIdentity +import org.prairieserver.prairie.model.auth.User +import org.prairieserver.prairie.network.AccountSessionChangedException +import org.prairieserver.prairie.network.AccountSessionExpectation +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.PrairieAuthPlugin +import org.prairieserver.prairie.network.PrairieJson +import org.prairieserver.prairie.network.TokenManager +import org.prairieserver.prairie.network.api.AuthApi +import org.prairieserver.prairie.network.api.DeviceLoginApi +import org.prairieserver.prairie.network.apiv2.ApiV2Gate +import org.prairieserver.prairie.repository.AuthRepository +import org.prairieserver.prairie.repository.DeviceLoginClock +import org.prairieserver.prairie.repository.DeviceLoginRepository +import org.prairieserver.prairie.repository.ExternalSignInRepository +import org.prairieserver.prairie.model.auth.OAuthHandshakeCapabilities +import org.prairieserver.prairie.model.auth.SignInProvider +import org.prairieserver.prairie.model.auth.SignInProviders +import org.prairieserver.prairie.model.server.ServerEntry +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.ServerRegistry +import org.prairieserver.prairie.network.api.ExternalSignInApi +import org.prairieserver.prairie.network.api.ServerConnections +import org.prairieserver.prairie.network.api.ServerIdentityApi +import org.prairieserver.prairie.network.api.ServerIdentityProbe +import org.prairieserver.prairie.repository.ServerIdentityRepository +import org.prairieserver.prairie.pairing.PairingReceiverState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.serialization.json.jsonObject +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The TV sign-in panel's device-code behavior: #421 (no silent "Loading" + * hang), #422 (no false "Signed in!"), lifecycle-aware polling (#420) and + * server-side cancel of an abandoned code; and the other ways in beside it + * (the password form, "Continue as …" through a network provider). + */ +@OptIn(ExperimentalCoroutinesApi::class) +class TvLoginViewModelDeviceSignInTest { + private val dispatcher = StandardTestDispatcher() + private val stores = mutableListOf<ViewModelStore>() + + @BeforeTest + fun setUp() = Dispatchers.setMain(dispatcher) + + @AfterTest + fun tearDown() { + stores.forEach { it.clear() } + Dispatchers.resetMain() + } + + @Test + fun missingSignInContextOffersTryAgainInsteadOfSpinning() = runTest(dispatcher) { + val tokens = FakeTokens(nullExpectations = 3) + val api = FakeDeviceApi() + val vm = viewModel(tokens, api) + advanceUntilIdle() + assertEquals(TvSignInStatus.Failed, vm.deviceSignIn.value.status) + assertEquals(0, api.starts) + + vm.restartDeviceLogin() + runCurrent() + assertEquals(TvSignInStatus.Waiting, vm.deviceSignIn.value.status) + assertEquals("4821 7730", vm.deviceSignIn.value.code?.userCode) + assertEquals("prairie.test/activate", vm.deviceSignIn.value.code?.activateText) + } + + @Test + fun failedSaveSaysCouldntFinishAndTryAgainGetsANewCode() = runTest(dispatcher) { + val tokens = FakeTokens(failInstall = true) + val api = FakeDeviceApi(approveAfterPolls = 1) + val vm = viewModel(tokens, api) + advanceUntilIdle() + assertEquals(TvSignInStatus.CouldntFinish, vm.deviceSignIn.value.status) + assertFalse(vm.uiState.value.loginSuccess) + + tokens.failInstall = false + api.approveAfterPolls = Int.MAX_VALUE + vm.restartDeviceLogin() + runCurrent() + assertEquals(TvSignInStatus.Waiting, vm.deviceSignIn.value.status) + assertEquals(2, api.starts, "the consumed request can't be collected again; a new code is needed") + } + + @Test + fun approvalShowsSignedInAsBeforeRouting() = runTest(dispatcher) { + val tokens = FakeTokens() + val api = FakeDeviceApi(approveAfterPolls = 1) + val vm = viewModel(tokens, api) + runCurrent() + advanceTimeBy(500) + assertEquals(TvSignInStatus.SignedIn, vm.deviceSignIn.value.status) + assertEquals("laura", vm.deviceSignIn.value.accountName) + assertFalse(vm.uiState.value.loginSuccess) + advanceUntilIdle() + assertTrue(vm.uiState.value.loginSuccess) + assertEquals("qr-access", tokens.accessToken) + } + + @Test + fun stoppingWhileTheApprovalIsSavedStillSignsInAndRoutes() = runTest(dispatcher) { + val tokens = FakeTokens() + val api = FakeDeviceApi(approveAfterPolls = 1) + val vm = viewModel(tokens, api) + runCurrent() + advanceTimeBy(500) + assertEquals(TvSignInStatus.SignedIn, vm.deviceSignIn.value.status) + assertFalse(vm.uiState.value.loginSuccess) + + // Between the approved poll and routing on (the "Signed in" dwell). + vm.onStop() + advanceUntilIdle() + assertTrue(vm.uiState.value.loginSuccess, "the handoff finishes in the background") + assertEquals("qr-access", tokens.accessToken) + } + + @Test + fun stoppingDuringAnApprovedPollKeepsItsTokens() = runTest(dispatcher) { + val tokens = FakeTokens() + val api = FakeDeviceApi(approveAfterPolls = 1, pollDelayMs = 1_000L) + val vm = viewModel(tokens, api) + runCurrent() + assertEquals(1, api.polls, "the poll is in flight") + vm.onStop() + advanceUntilIdle() + assertTrue(vm.uiState.value.loginSuccess) + assertEquals("qr-access", tokens.accessToken) + assertEquals(1, api.starts, "the code was not silently renewed") + } + + @Test + fun aFailedPasswordSaveLeavesTheCodeWorking() = runTest(dispatcher) { + val tokens = FakeTokens(failFirstInstall = true) + val api = FakeDeviceApi() + api.approveWhen = { tokens.installAttempts >= 1 } + val vm = viewModel(tokens, api, loginStatus = HttpStatusCode.OK) + runCurrent() + vm.onUsernameChanged("jim") + vm.onPasswordChanged("pw") + vm.onLoginClick() + advanceUntilIdle() + // The password save failed; a phone then approved the code on screen. + assertTrue(vm.uiState.value.loginSuccess) + assertEquals("qr-access", tokens.accessToken) + assertEquals(TvSignInStatus.SignedIn, vm.deviceSignIn.value.status) + } + + @Test + fun aNearbyPhoneApprovesTheCodeOnScreen() = runTest(dispatcher) { + val tokens = FakeTokens() + val api = FakeDeviceApi() + val vm = viewModel(tokens, api) + runCurrent() + val code = assertNotNull(vm.codeForNearbyApproval()) + assertEquals("dev-1", code.deviceCode) + var outcome: NearbySignInOutcome? = null + backgroundScope.launch { outcome = vm.nearbyApprovalOutcome(code.deviceCode) } + runCurrent() + assertEquals(null, outcome) + + api.approveAfterPolls = api.polls + 1 + advanceUntilIdle() + assertEquals(NearbySignInOutcome.SignedIn, outcome) + assertEquals(1, api.starts, "one code on screen, one request") + assertEquals("qr-access", tokens.accessToken) + } + + @Test + fun stoppingPausesPollingAndStartingPollsAtOnce() = runTest(dispatcher) { + val api = FakeDeviceApi() + val vm = viewModel(FakeTokens(), api) + runCurrent() + advanceTimeBy(11_000) + vm.onStop() + val polls = api.polls + advanceTimeBy(120_000) + assertEquals(polls, api.polls, "no polling while the screen is stopped") + + vm.onStart() + runCurrent() + assertEquals(polls + 1, api.polls) + assertEquals(1, api.starts) + } + + @Test + fun stopStartStopDuringAPollLeavesPollingStopped() = runTest(dispatcher) { + val api = FakeDeviceApi(pollDelayMs = 1_000L) + val vm = viewModel(FakeTokens(), api) + runCurrent() + assertEquals(1, api.polls, "the first poll is in flight") + + // All while that poll is still out: the second run waits for it. + vm.onStop() + vm.onStart() + runCurrent() + vm.onStop() + advanceTimeBy(120_000) + assertEquals(1, api.polls, "no polling while the screen is stopped") + + vm.onStart() + runCurrent() + assertEquals(2, api.polls, "back in the foreground it polls at once") + assertEquals(1, api.starts) + } + + @Test + fun restartingDuringAStoppedSlowPollLeavesOnePollStreamOnTheNewCode() = runTest(dispatcher) { + for (late in listOf("pending", "expired")) { + // dev-1's poll is on the wire and can't be called back; it answers + // at 12 s, between the new code's polls at 10 s and 15 s. + val api = FakeDeviceApi(pollDelayMs = 12_000L, pollsIgnoreCancel = true) + api.statusFor = { code -> if (code == "dev-1") late else "pending" } + val vm = viewModel(FakeTokens(), api) + runCurrent() + assertEquals(listOf("dev-1"), api.polledCodes, "the first poll is in flight") + api.pollDelayMs = 0L + + // "Try again" in the foreground after a stop/start during that poll. + vm.onStop() + vm.onStart() + vm.restartDeviceLogin() + runCurrent() + assertEquals("dev-2", vm.codeForNearbyApproval()?.deviceCode) + + advanceTimeBy(12_001) + assertEquals("dev-2", vm.codeForNearbyApproval()?.deviceCode, "the withdrawn code never comes back ($late)") + advanceTimeBy(18_000) + assertEquals("dev-2", vm.codeForNearbyApproval()?.deviceCode, "the new code isn't orphaned ($late)") + assertEquals(2, api.starts, "no third code ($late)") + assertEquals(1, api.polledCodes.count { it == "dev-1" }, "the old run polls no more ($late)") + // One stream: dev-2 polled at 0, 5, ... 30 s. + assertEquals(7, api.polledCodes.count { it == "dev-2" }, "one poll stream ($late): ${api.polledCodes}") + assertEquals(listOf("dev-1"), api.canceled) + } + } + + @Test + fun startingAfterAPasswordSignInWonAsksForNoNewCode() = runTest(dispatcher) { + val api = FakeDeviceApi() + val vm = viewModel(FakeTokens(), api, loginStatus = HttpStatusCode.OK) + runCurrent() + vm.onUsernameChanged("jim") + vm.onPasswordChanged("pw") + // The password sign-in completes while the app is in the background. + vm.onStop() + vm.onLoginClick() + // The login request runs on the mock engine's own threads: wait for + // its outcome rather than for virtual time, which is idle while stopped. + vm.uiState.first { it.loginSuccess } + runCurrent() + val starts = api.starts + + vm.onStart() + advanceTimeBy(60_000) + assertEquals(starts, api.starts, "no code for a TV that is already signed in") + } + + @Test + fun stoppingBeforeTheCodeIsRequestedWaitsForTheScreenToReturn() = runTest(dispatcher) { + val api = FakeDeviceApi() + // The first read of the sign-in context races and is retried, so the + // machine doesn't exist yet when the screen stops. + val vm = viewModel(FakeTokens(nullExpectations = 1), api) + runCurrent() + vm.onStop() + advanceTimeBy(120_000) + assertEquals(0, api.starts, "no code requested while the screen is stopped") + assertEquals(0, api.polls) + + vm.onStart() + runCurrent() + assertEquals(1, api.starts) + assertEquals(1, api.polls) + assertEquals(TvSignInStatus.Waiting, vm.deviceSignIn.value.status) + } + + @Test + fun passwordSignInWithdrawsTheCodeOnTheServer() = runTest(dispatcher) { + val api = FakeDeviceApi() + val tokens = FakeTokens() + val vm = viewModel(tokens, api, loginStatus = HttpStatusCode.OK) + runCurrent() + vm.onUsernameChanged("jim") + vm.onPasswordChanged("pw") + vm.onLoginClick() + advanceUntilIdle() + assertTrue(vm.uiState.value.loginSuccess) + // The login request runs on the mock engine's own threads while virtual + // time advances, so codes may have renewed meanwhile (a renewal + // withdraws the code it replaces). Whatever was live when the password + // won is withdrawn: no code the TV showed stays approvable. + assertTrue(api.polledCodes.isNotEmpty()) + assertEquals(api.polledCodes.distinct(), api.canceled.distinct()) + } + + @Test + fun leavingTheScreenWithdrawsTheCodeOnlyWhenTheServerSupportsCancel() = runTest(dispatcher) { + val api = FakeDeviceApi() + val store = ViewModelStore() + store.put("login", viewModel(FakeTokens(), api, track = false)) + runCurrent() + store.clear() + advanceUntilIdle() + assertEquals(listOf("dev-1"), api.canceled) + + val old = FakeDeviceApi(capability = DeviceLoginCapabilityResponse(cancel = false)) + val oldStore = ViewModelStore() + oldStore.put("login", viewModel(FakeTokens(), old, track = false)) + runCurrent() + oldStore.clear() + advanceUntilIdle() + assertEquals(emptyList(), old.canceled) + } + + @Test + fun serverWithoutDeviceSignInGoesToThePasswordForm() = runTest(dispatcher) { + val api = FakeDeviceApi(capability = DeviceLoginCapabilityResponse(deviceLoginAvailable = false)) + val vm = viewModel(FakeTokens(), api) + advanceUntilIdle() + assertTrue(vm.deviceSignIn.value.passwordOnly) + assertEquals(0, api.starts) + } + + // --- Password gating (external sign-in) --- + + @Test + fun oidcOnlyServerWithLocalPasswordsOffOffersNoPassword() = runTest(dispatcher) { + val signIn = FakeSignInApi(SignInProviders(listOf(OIDC), passwordLogin = false)) + val vm = viewModel(FakeTokens(), FakeDeviceApi(), registry = FakeRegistry(), externalSignIn = signIn) + advanceUntilIdle() + assertFalse(vm.uiState.value.passwordAvailable, "the TV signs in with a phone only") + assertEquals(listOf("https://prairie.test"), signIn.asked) + } + + @Test + fun ldapServerKeepsThePasswordFormAndSendsNoProvider() = runTest(dispatcher) { + val signIn = FakeSignInApi(SignInProviders(listOf(LDAP), passwordLogin = true)) + val bodies = mutableListOf<String>() + val vm = viewModel( + FakeTokens(), FakeDeviceApi(), loginStatus = HttpStatusCode.OK, + registry = FakeRegistry(), externalSignIn = signIn, loginBodies = bodies, + ) + runCurrent() + assertTrue(vm.uiState.value.passwordAvailable) + vm.onUsernameChanged("alice") + vm.onPasswordChanged("directory-pw") + vm.onLoginClick() + advanceUntilIdle() + assertTrue(vm.uiState.value.loginSuccess, "${vm.uiState.value}") + val login = kotlinx.serialization.json.Json.parseToJsonElement(bodies.single()).jsonObject + assertEquals(setOf("username", "password"), login.keys, "the server routes a directory login by account") + } + + @Test + fun theProvidersNamesAreKeptForThePasswordFormsErrors() = runTest(dispatcher) { + val signIn = FakeSignInApi(SignInProviders(listOf(OIDC, LDAP), passwordLogin = true)) + val vm = viewModel(FakeTokens(), FakeDeviceApi(), registry = FakeRegistry(), externalSignIn = signIn) + advanceUntilIdle() + assertEquals(OIDC.displayName, vm.uiState.value.providerName) + assertEquals(LDAP.displayName, vm.uiState.value.directoryName) + } + + @Test + fun directoryRefusalsAndThrottlingHaveTheirOwnErrors() { + assertEquals(TvLoginError.EmailInUse, tvLoginError(ApiResult.Error(409, "email_in_use", "raw detail"))) + assertEquals(TvLoginError.IdentityLinkedElsewhere, tvLoginError(ApiResult.Error(409, "identity_linked_elsewhere", "raw"))) + assertEquals(TvLoginError.RateLimited, tvLoginError(ApiResult.Error(429, "", "raw"))) + } + + @Test + fun aServerThatCantListProvidersKeepsThePasswordForm() = runTest(dispatcher) { + val vm = viewModel(FakeTokens(), FakeDeviceApi(), registry = FakeRegistry(), externalSignIn = FakeSignInApi(null)) + advanceUntilIdle() + assertTrue(vm.uiState.value.passwordAvailable) + } + + /** + * An admin turns password sign-in off while the TV shows the form or sits + * in the background: the TV re-reads its options when the person goes + * back to the code, when the screen returns, and when the server refuses + * a password, so "Sign in with a password" goes away. + */ + @Test + fun theSignInOptionsAreReadAgainOnReturn() = runTest(dispatcher) { + val signIn = FakeSignInApi(SignInProviders(listOf(LOCAL, OIDC), passwordLogin = true)) + val vm = viewModel(FakeTokens(), FakeDeviceApi(), registry = FakeRegistry(), externalSignIn = signIn) + advanceUntilIdle() + assertTrue(vm.uiState.value.passwordAvailable) + assertEquals(1, signIn.asked.size) + + // Back from the password form ("Use your phone instead"). + signIn.providers = SignInProviders(listOf(OIDC), passwordLogin = false) + vm.restartDeviceLogin() + advanceUntilIdle() + assertFalse(vm.uiState.value.passwordAvailable) + assertEquals(2, signIn.asked.size) + + // The screen comes back from the background. + signIn.providers = SignInProviders(listOf(LOCAL, OIDC), passwordLogin = true) + vm.onStop() + vm.onStart() + advanceUntilIdle() + assertTrue(vm.uiState.value.passwordAvailable) + assertEquals(3, signIn.asked.size) + + // A start without a stop first (the screen's first start) asks nothing more. + vm.onStart() + advanceUntilIdle() + assertEquals(3, signIn.asked.size) + } + + @Test + fun aRefusedPasswordBecauseLocalLoginIsOffRereadsTheOptions() = runTest(dispatcher) { + val signIn = FakeSignInApi(SignInProviders(listOf(LOCAL, OIDC), passwordLogin = true)) + val vm = viewModel( + FakeTokens(), FakeDeviceApi(), loginStatus = HttpStatusCode.Forbidden, + loginBody = """{"type":"https://prairieserver.org/docs/api/v2/problems/local_login_disabled","title":"x","status":403}""", + registry = FakeRegistry(), externalSignIn = signIn, + ) + runCurrent() + assertTrue(vm.uiState.value.passwordAvailable) + signIn.providers = SignInProviders(listOf(OIDC), passwordLogin = false) + vm.onUsernameChanged("jim") + vm.onPasswordChanged("pw") + vm.onLoginClick() + advanceUntilIdle() + // The form closes; the screen shows the refusal outside it. + assertFalse(vm.uiState.value.passwordAvailable) + assertTrue(vm.uiState.value.passwordTurnedOff) + + // Passwords on again: the form can come back, without the old refusal. + signIn.providers = SignInProviders(listOf(LOCAL, OIDC), passwordLogin = true) + vm.onStop() + vm.onStart() + advanceUntilIdle() + assertTrue(vm.uiState.value.passwordAvailable) + assertFalse(vm.uiState.value.passwordTurnedOff) + assertNull(vm.uiState.value.error) + } + + // --- st=login rollout gate (prairie-apple PairingProtocol.advertisesSignInTVs) --- + + /** Released phones push servers without an identity, so a release TV must not advertise st=login yet. */ + @Test + fun gateOffAdvertisesNothingEvenWithAVerifiedIdentity() = runTest(dispatcher) { + val identity = FakeIdentityApi() + val vm = viewModel( + FakeTokens(), FakeDeviceApi(), registry = FakeRegistry(), identityApi = identity, + advertisesSignIn = false, + ) + advanceUntilIdle() + assertNull(vm.pairingAdvertisement.value) + assertEquals(0, identity.probes, "no identity probe when nothing will be advertised") + } + + @Test + fun gateOnAdvertisesLoginWithTheServersIdentity() = runTest(dispatcher) { + val vm = viewModel( + FakeTokens(), FakeDeviceApi(), registry = FakeRegistry(), identityApi = FakeIdentityApi(), + advertisesSignIn = true, + ) + advanceUntilIdle() + val advertisement = assertNotNull(vm.pairingAdvertisement.value) + assertEquals(PairingReceiverState.Login, advertisement.state) + assertEquals("server-1", advertisement.serverIdentity) + assertEquals("https://prairie.test", advertisement.serverUrl) + } + + /** Password sign-in turned off while the form showed another error: that error goes with the form. */ + @Test + fun theFormsErrorsCloseWithIt() = runTest(dispatcher) { + val signIn = FakeSignInApi(SignInProviders(listOf(LOCAL, OIDC), passwordLogin = true)) + val vm = viewModel(FakeTokens(), FakeDeviceApi(), registry = FakeRegistry(), externalSignIn = signIn) + runCurrent() + vm.onLoginClick() + assertEquals(TvLoginError.UsernameRequired, vm.uiState.value.error) + signIn.providers = SignInProviders(listOf(OIDC), passwordLogin = false) + vm.onStop() + vm.onStart() + advanceUntilIdle() + assertFalse(vm.uiState.value.passwordAvailable) + assertNull(vm.uiState.value.error) + assertFalse(vm.uiState.value.passwordTurnedOff) + } + + @Test + fun localLoginDisabledHasItsOwnError() = runTest(dispatcher) { + val vm = viewModel( + FakeTokens(), FakeDeviceApi(), loginStatus = HttpStatusCode.Forbidden, + loginBody = """{"type":"https://prairieserver.org/docs/api/v2/problems/local_login_disabled","title":"x","status":403}""", + ) + runCurrent() + vm.onUsernameChanged("jim") + vm.onPasswordChanged("pw") + vm.onLoginClick() + advanceUntilIdle() + assertEquals(TvLoginError.LocalLoginDisabled, vm.uiState.value.error) + } + + // --- Network identity ("Continue as …", e.g. Tailscale) --- + + /** + * Reached through Tailscale, the server lists it with the TV's owner: one + * press signs in with no code and no password, on the saved base, and the + * code on screen is withdrawn once the session is saved. + */ + @Test + fun continueAsSignsInWithoutACodeAndWithdrawsTheCode() = runTest(dispatcher) { + val signIn = FakeSignInApi(SignInProviders(listOf(LOCAL, TAILSCALE), passwordLogin = true)) + signIn.networkAnswer = ApiResult.Success( + LoginResponse("net-access", "net-refresh", 3600, User(id = "1", username = "alice", email = "a@example.test", role = "user")), + ) + val tokens = FakeTokens() + val api = FakeDeviceApi() + val vm = viewModel(tokens, api, registry = FakeRegistry(), externalSignIn = signIn) + runCurrent() + assertEquals(TAILSCALE, vm.uiState.value.networkProvider) + assertTrue(vm.uiState.value.passwordAvailable, "the password form stays available") + assertEquals(TvSignInStatus.Waiting, vm.deviceSignIn.value.status, "and so does the code") + + vm.onNetworkSignInClick() + assertTrue(vm.uiState.value.networkBusy) + vm.onNetworkSignInClick() + advanceUntilIdle() + assertEquals(listOf("https://prairie.test" to "/api/v2/auth/network/7/sign-in"), signIn.networkCalls, "one sign-in, on the saved base") + assertTrue(vm.uiState.value.loginSuccess) + assertFalse(vm.uiState.value.networkBusy) + assertNull(vm.uiState.value.networkError) + assertEquals("net-access", tokens.accessToken) + assertEquals(1, tokens.installAttempts) + assertEquals(listOf("dev-1"), api.canceled, "the code on screen can't be approved any more") + } + + @Test + fun aRefusedContinueAsSaysWhyAndKeepsTheCode() = runTest(dispatcher) { + val signIn = FakeSignInApi(SignInProviders(listOf(LOCAL, TAILSCALE), passwordLogin = true)) + signIn.networkAnswer = ApiResult.Error(403, "network_identity_required", "raw detail") + val tokens = FakeTokens() + val api = FakeDeviceApi() + val vm = viewModel(tokens, api, registry = FakeRegistry(), externalSignIn = signIn) + runCurrent() + vm.onNetworkSignInClick() + runCurrent() + assertEquals(TvLoginError.NetworkIdentityRequired, vm.uiState.value.networkError) + assertNull(vm.uiState.value.error, "the password form's error slot is untouched") + assertFalse(vm.uiState.value.networkBusy, "the button can be pressed again") + assertFalse(vm.uiState.value.loginSuccess) + assertNull(tokens.accessToken) + assertEquals(TvSignInStatus.Waiting, vm.deviceSignIn.value.status) + assertTrue(api.canceled.isEmpty(), "the code still works") + + signIn.networkAnswer = ApiResult.Error(403, "not_permitted", "") + vm.onNetworkSignInClick() + runCurrent() + assertEquals(TvLoginError.NetworkNotPermitted, vm.uiState.value.networkError) + + // A phone can still approve the code on screen. + api.approveAfterPolls = api.polls + 1 + advanceUntilIdle() + assertTrue(vm.uiState.value.loginSuccess) + assertEquals("qr-access", tokens.accessToken) + } + + /** + * A phone approves the code while "Continue as …" waits for the server: + * the approval takes the sign-in and cancels the network attempt. When + * that approval can't be saved, the button is free again, not left busy. + */ + @Test + fun anApprovalThatCancelsContinueAsAndFailsFreesItsButton() = runTest(dispatcher) { + val signIn = FakeSignInApi(SignInProviders(listOf(LOCAL, TAILSCALE), passwordLogin = true)) + signIn.networkDelayMs = 60_000L + val tokens = FakeTokens(failInstall = true) + val api = FakeDeviceApi() + api.approveWhen = { signIn.networkCalls.isNotEmpty() } + val vm = viewModel(tokens, api, registry = FakeRegistry(), externalSignIn = signIn) + runCurrent() + vm.onNetworkSignInClick() + runCurrent() + assertTrue(vm.uiState.value.networkBusy) + + // The next poll is approved; saving it fails. + advanceTimeBy(5_001) + assertEquals(TvSignInStatus.CouldntFinish, vm.deviceSignIn.value.status) + assertEquals(TvLoginError.SaveFailed, vm.uiState.value.error) + assertFalse(vm.uiState.value.networkBusy, "the canceled network attempt doesn't hold its button") + assertFalse(vm.uiState.value.isLoading) + + tokens.failInstall = false + signIn.networkDelayMs = 0L + signIn.networkAnswer = ApiResult.Success( + LoginResponse("net-access", "net-refresh", 3600, User(id = "1", username = "alice", email = "a@example.test", role = "user")), + ) + vm.onNetworkSignInClick() + advanceUntilIdle() + assertTrue(vm.uiState.value.loginSuccess) + assertEquals("net-access", tokens.accessToken) + } + + /** Off the provider's network the server stops listing it: the button and its refusal go. */ + @Test + fun continueAsGoesWhenTheServerNoLongerListsIt() = runTest(dispatcher) { + val signIn = FakeSignInApi(SignInProviders(listOf(LOCAL, TAILSCALE), passwordLogin = true)) + signIn.networkAnswer = ApiResult.Error(403, "network_identity_required", "") + val vm = viewModel(FakeTokens(), FakeDeviceApi(), registry = FakeRegistry(), externalSignIn = signIn) + runCurrent() + vm.onNetworkSignInClick() + runCurrent() + assertEquals(TvLoginError.NetworkIdentityRequired, vm.uiState.value.networkError) + + signIn.providers = SignInProviders(listOf(LOCAL), passwordLogin = true) + vm.onStop() + vm.onStart() + runCurrent() + assertNull(vm.uiState.value.networkProvider) + assertNull(vm.uiState.value.networkError) + vm.onNetworkSignInClick() + runCurrent() + assertEquals(1, signIn.networkCalls.size, "nothing to press") + } + + @Test + fun aNetworkProviderWithoutItsSignInPathOffersNothing() = runTest(dispatcher) { + val signIn = FakeSignInApi(SignInProviders(listOf(LOCAL, TAILSCALE.copy(networkSignInPath = null)), passwordLogin = true)) + val vm = viewModel(FakeTokens(), FakeDeviceApi(), registry = FakeRegistry(), externalSignIn = signIn) + runCurrent() + assertNull(vm.uiState.value.networkProvider) + vm.onNetworkSignInClick() + runCurrent() + assertTrue(signIn.networkCalls.isEmpty()) + } + + @Test + fun networkRefusalsMapToTheirErrors() { + fun error(status: Int, code: String) = tvNetworkSignInError(ApiResult.Error(status, code, "raw")) + assertEquals(TvLoginError.NetworkIdentityRequired, error(403, "network_identity_required")) + assertEquals(TvLoginError.NetworkNotPermitted, error(403, "not_permitted")) + assertEquals(TvLoginError.NetworkEmailInUse, error(409, "email_in_use")) + assertEquals(TvLoginError.NetworkProviderGone, error(404, "not_found")) + // The rest read as the other sign-ins' refusals. + assertEquals(TvLoginError.AccountRequired, error(403, "account_required")) + assertEquals(TvLoginError.AccountDisabled, error(403, "permission_denied")) + assertEquals(TvLoginError.IdentityLinkedElsewhere, error(409, "identity_linked_elsewhere")) + assertEquals(TvLoginError.ProviderUnavailable, error(503, "provider_unavailable")) + assertEquals(TvLoginError.RateLimited, error(429, "")) + assertEquals(TvLoginError.IdentityChanged, error(0, "identity_changed")) + assertEquals(TvLoginError.Server("raw"), error(500, "internal")) + } + + private fun TestScope.viewModel( + tokens: FakeTokens, + api: FakeDeviceApi, + loginStatus: HttpStatusCode = HttpStatusCode.NotFound, + track: Boolean = true, + registry: ServerRegistry? = null, + externalSignIn: ExternalSignInApi? = null, + loginBodies: MutableList<String>? = null, + loginBody: String? = null, + identityApi: ServerIdentityApi? = null, + advertisesSignIn: Boolean = false, + ): TvLoginViewModel { + val client = HttpClient(MockEngine) { + engine { + addHandler { request -> + if (request.url.encodedPath.endsWith("/auth/login")) { + loginBodies?.add((request.body as io.ktor.http.content.TextContent).text) + } + respond( + loginBody ?: if (loginStatus == HttpStatusCode.OK) LOGIN_JSON else "{}", + loginStatus, + headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + } + install(ContentNegotiation) { json(PrairieJson) } + install(PrairieAuthPlugin) { this.tokenManager = tokens } + } + val vm = TvLoginViewModel( + authRepository = AuthRepository(AuthApi(client, ApiV2Gate.Unrestricted), tokens), + tokenManager = tokens, + deviceLogin = DeviceLoginRepository(api), + // The test scope itself (not backgroundScope, which advanceUntilIdle + // doesn't wait for), so the best-effort cancel call is observable. + backgroundScope = this, + clock = DeviceLoginClock { testScheduler.currentTime }, + serverRegistry = registry, + serverIdentities = identityApi?.let { api -> registry?.let { ServerIdentityRepository(it, api) } }, + externalSignIn = externalSignIn?.let(::ExternalSignInRepository), + advertisesSignIn = advertisesSignIn, + ) + if (track) stores += ViewModelStore().also { it.put("login", vm) } + return vm + } + + private class FakeDeviceApi( + val capability: DeviceLoginCapabilityResponse = DeviceLoginCapabilityResponse(cancel = true, openedSignal = true), + var approveAfterPolls: Int = Int.MAX_VALUE, + /** Approves the next poll once this is true, whatever the poll count. */ + var approveWhen: () -> Boolean = { false }, + /** How long each poll call takes, so a test can stop the screen mid-call. */ + var pollDelayMs: Long = 0L, + /** A poll call that ignores cancellation, like a request already on the wire. */ + val pollsIgnoreCancel: Boolean = false, + ) : DeviceLoginApi { + /** What a poll that doesn't approve answers for each code. */ + var statusFor: (String) -> String = { "pending" } + + var starts = 0 + var polls = 0 + val canceled = mutableListOf<String>() + + /** Each code polled, i.e. shown on the TV and live on the server. */ + val polledCodes = mutableListOf<String>() + + override suspend fun deviceLoginCapabilityAt(serverUrl: String) = ApiResult.Success(capability) + override suspend fun startDeviceLoginAt(serverUrl: String, deviceName: String?, devicePlatform: String?) = + ApiResult.Success( + DeviceLoginStartResponse( + deviceCode = "dev-${++starts}", + userCode = "4821-7730", + matchCode = "warm pony", + verificationUri = "https://prairie.test/activate", + verificationUriComplete = "https://prairie.test/activate?code=48217730", + expiresAt = "2099-01-01T00:00:00Z", + expiresIn = 900, + interval = 5, + deviceName = deviceName.orEmpty(), + devicePlatform = devicePlatform.orEmpty(), + ), + ) + + override suspend fun pollDeviceLoginAt(serverUrl: String, deviceCode: String): ApiResult<DeviceLoginPollResponse> { + polls++ + polledCodes += deviceCode + val took = pollDelayMs + if (took > 0) { + if (pollsIgnoreCancel) withContext(NonCancellable) { delay(took) } else delay(took) + } + return ApiResult.Success( + if (polls >= approveAfterPolls || approveWhen()) { + DeviceLoginPollResponse( + status = "approved", + accessToken = "qr-access", + refreshToken = "qr-refresh", + expiresIn = 3600, + user = User(id = "1", username = "laura", email = "laura@example.test", role = "user"), + ) + } else { + DeviceLoginPollResponse(status = statusFor(deviceCode)) + }, + ) + } + + override suspend fun cancelDeviceLoginAt(serverUrl: String, deviceCode: String): ApiResult<DeviceLoginCancelResponse> { + canceled += deviceCode + return ApiResult.Success(DeviceLoginCancelResponse("canceled")) + } + + override suspend fun startDeviceLogin(deviceName: String?, devicePlatform: String?) = error("unscoped") + override suspend fun pollDeviceLogin(deviceCode: String) = error("unscoped") + override suspend fun lookupDeviceLogin(token: String?, code: String?): ApiResult<DeviceLoginLookupResponse> = error("unused") + override suspend fun approveDeviceLogin(token: String?, code: String?): ApiResult<DeviceLoginDecisionResponse> = error("unused") + override suspend fun denyDeviceLogin(token: String?, code: String?): ApiResult<DeviceLoginDecisionResponse> = error("unused") + } + + private class FakeRegistry : ServerRegistry { + private val entry = ServerEntry(id = "entry-1", url = "https://prairie.test", fetchedName = "Prairie") + override val entries = MutableStateFlow(listOf(entry)) + override val activeServerId = MutableStateFlow<String?>(entry.id) + override val activeEntry = MutableStateFlow<ServerEntry?>(entry) + override suspend fun addOrUpdate(url: String, fetchedName: String?): String = entry.id + override suspend fun rename(serverId: String, userOverrideName: String?) = Unit + override suspend fun setFetchedName(serverId: String, fetchedName: String?) = Unit + override suspend fun setProfileId(serverId: String, profileId: String?) = Unit + override suspend fun remove(serverId: String) = Unit + override suspend fun signOut(serverId: String) = Unit + override suspend fun switchTo(serverId: String) = Unit + override suspend fun touchActive() = Unit + } + + private class FakeIdentityApi : ServerIdentityApi { + var probes = 0 + override suspend fun probeIdentity(serverUrl: String): ServerIdentityProbe { + probes++ + return ServerIdentityProbe.Identity("server-1") + } + override suspend fun connections(scope: AuthScopeSnapshot): ServerConnections? = null + } + + /** Provider discovery; [providers] null = a server that can't list them. */ + private class FakeSignInApi(var providers: SignInProviders?) : ExternalSignInApi { + val asked = mutableListOf<String>() + + /** What "Continue as …" answers, and where each one was sent (saved base, path). */ + var networkAnswer: ApiResult<LoginResponse> = ApiResult.Error(404, "not_found", "") + val networkCalls = mutableListOf<Pair<String, String>>() + + /** How long "Continue as …" waits for its answer. */ + var networkDelayMs = 0L + override suspend fun listProviders(serverUrl: String): ApiResult<SignInProviders> { + asked += serverUrl + return providers?.let { ApiResult.Success(it) } ?: ApiResult.Error(404, "not_found", "") + } + override suspend fun oauthCapabilities(serverUrl: String): ApiResult<OAuthHandshakeCapabilities> = + ApiResult.Success(OAuthHandshakeCapabilities(available = true, native = true, linking = true)) + override suspend fun externalSignInCapabilities(scope: AuthScopeSnapshot) = error("TVs have no Sign-in settings") + override suspend fun completeOAuthLogin(serverUrl: String, code: String, codeVerifier: String) = error("TVs never run OAuth") + override suspend fun listIdentities(scope: AuthScopeSnapshot) = error("unused") + override suspend fun deleteIdentity(scope: AuthScopeSnapshot, identityId: String) = error("unused") + override suspend fun createLinkTicket(scope: AuthScopeSnapshot, installationId: String, password: String) = error("unused") + override suspend fun completeLink(scope: AuthScopeSnapshot, code: String, codeVerifier: String) = error("unused") + override suspend fun linkWithCredentials( + scope: AuthScopeSnapshot, installationId: String, password: String, username: String, directoryPassword: String, + ) = error("unused") + override suspend fun signInWithNetworkIdentity(serverUrl: String, signInPath: String): ApiResult<LoginResponse> { + networkCalls += serverUrl to signInPath + if (networkDelayMs > 0) delay(networkDelayMs) + return networkAnswer + } + override suspend fun linkWithNetwork(scope: AuthScopeSnapshot, installationId: String, password: String) = + error("TVs have no Sign-in settings") + } + + private class FakeTokens( + var nullExpectations: Int = 0, + var failInstall: Boolean = false, + /** Fail only the first install, then work. */ + var failFirstInstall: Boolean = false, + ) : TokenManager { + private var generation = 0L + var accessToken: String? = null + private var refreshToken: String? = null + var installAttempts = 0 + + override suspend fun captureAccountSessionExpectation(): AccountSessionExpectation? { + if (nullExpectations > 0) { + nullExpectations-- + return null + } + return AccountSessionExpectation(generation, null, "https://prairie.test") + } + + override suspend fun replaceAccountSession( + serverId: String?, serverUrl: String?, accessToken: String, refreshToken: String, + expiresIn: Long, profileId: String?, profileToken: String?, expectedIdentity: AccountSessionExpectation?, + ) { + if (expectedIdentity != null && expectedIdentity.generation != generation) throw AccountSessionChangedException() + installAttempts++ + if (failInstall || (failFirstInstall && installAttempts == 1)) throw IllegalStateException("keystore unavailable") + generation++ + saveTokens(accessToken, refreshToken, expiresIn) + } + + override val sessionExpired = MutableSharedFlow<Unit>() + override suspend fun getAccessToken(): String? = accessToken + override suspend fun getRefreshToken(): String? = refreshToken + override suspend fun saveTokens(accessToken: String, refreshToken: String, expiresIn: Long) { + this.accessToken = accessToken + this.refreshToken = refreshToken + } + override suspend fun clearTokens() { + accessToken = null + refreshToken = null + } + override suspend fun invalidateSession() = Unit + override suspend fun getProfileId(): String? = null + override suspend fun setProfileId(profileId: String?) = Unit + override suspend fun getProfileToken(): String? = null + override suspend fun setProfileToken(token: String?) = Unit + override suspend fun getServerUrl(): String = "https://prairie.test" + override suspend fun setServerUrl(url: String) = Unit + override suspend fun getCurrentServerId(): String? = null + override suspend fun switchActiveServer(serverId: String?) = Unit + override suspend fun signOutCurrentServer() = Unit + } + + private companion object { + val OIDC = SignInProvider( + "plugin:5:oidc", "Keycloak", SignInProvider.Mode.OAuth, false, null, "5", + "/api/v2/auth/oauth/5/native/start", + ) + val LDAP = SignInProvider("plugin:6:ldap", "Directory", SignInProvider.Mode.Credentials, false, null, "6", null) + val LOCAL = SignInProvider("local", "Prairie account", SignInProvider.Mode.Credentials, true, null, null, null) + val TAILSCALE = SignInProvider( + "plugin:7:tailscale", "Tailscale", SignInProvider.Mode.Network, false, null, "7", null, + networkSignInPath = "/api/v2/auth/network/7/sign-in", + networkIdentity = NetworkIdentity("Alice Example", "alice@example.test"), + ) + val LOGIN_JSON = """ + {"access_token":"credential-access","refresh_token":"credential-refresh","expires_in":3600, + "user":{"id":"1","username":"jim","email":"jim@example.com","role":"user","download_allowed":true}} + """.trimIndent() + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvLoginViewModelRaceTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvLoginViewModelRaceTest.kt index ab47664b95..d1fe40d406 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvLoginViewModelRaceTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvLoginViewModelRaceTest.kt @@ -166,7 +166,11 @@ class TvLoginViewModelRaceTest { assertEquals("newer-refresh", actualTokens.getRefreshToken()) assertFalse(viewModel.uiState.value.isLoading) assertFalse(viewModel.uiState.value.loginSuccess) - assertEquals("The account or server changed. Start sign-in again.", viewModel.uiState.value.error) + assertEquals(TvLoginError.IdentityChanged, viewModel.uiState.value.error) + if (qr) { + // #422: the QR card must not claim "Signed in" when the install lost the race. + assertEquals(TvSignInStatus.CouldntFinish, viewModel.deviceSignIn.value.status) + } viewModel.onLoginClick() advanceUntilIdle() @@ -193,7 +197,7 @@ class TvLoginViewModelRaceTest { val viewModel = passwordSignIn(tokenManager, HttpStatusCode.Unauthorized) assertFalse(viewModel.uiState.value.loginSuccess) - assertEquals("Invalid username or password", viewModel.uiState.value.error) + assertEquals(TvLoginError.InvalidCredentials, viewModel.uiState.value.error) assertEquals(emptyList(), tokenManager.savedAccessTokens) } @@ -206,7 +210,7 @@ class TvLoginViewModelRaceTest { } assertFalse(viewModel.uiState.value.loginSuccess) - assertEquals("The account or server changed. Start sign-in again.", viewModel.uiState.value.error) + assertEquals(TvLoginError.IdentityChanged, viewModel.uiState.value.error) assertEquals(listOf("newer-access"), tokenManager.savedAccessTokens) } diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvPairingPanelCopyTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvPairingPanelCopyTest.kt new file mode 100644 index 0000000000..42a0fd2441 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvPairingPanelCopyTest.kt @@ -0,0 +1,44 @@ +package org.prairieserver.prairie.tv.ui.screens.auth + +import java.io.File +import org.prairieserver.prairie.common.pairing.PairingReceiverStatus +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** The nearby-phone panel's copy matches Apple TV's receiver panel. */ +class TvPairingPanelCopyTest { + private fun awaiting(automatic: Boolean, matchCode: String?) = PairingReceiverStatus.AwaitingApproval( + serverURL = "https://prairie.test", + serverName = "Prairie", + userCode = "4821-7730", + automatic = automatic, + matchCode = matchCode, + ) + + @Test + fun olderPhonesLineOnlyForAPersonCheckedAttempt() { + assertEquals("WARM PONY", olderPhonesMatchWords(awaiting(automatic = false, matchCode = "warm pony"))) + assertNull(olderPhonesMatchWords(awaiting(automatic = true, matchCode = "warm pony"))) + assertNull(olderPhonesMatchWords(awaiting(automatic = false, matchCode = " "))) + assertNull(olderPhonesMatchWords(awaiting(automatic = false, matchCode = null))) + } + + @Test + fun copyDeckMatchesAppleTv() { + val strings = File("src/androidMain/res/values/strings.xml").readText() + listOf( + "tv_pairing_connected_title\">Phone or tablet connected<", + "tv_pairing_confirm_title\">Confirm on your phone or tablet<", + "tv_pairing_confirm_title_automatic\">Signing in<", + "tv_pairing_confirm_detail\">Check that your phone or tablet shows this code, then approve.<", + "tv_pairing_confirm_detail_automatic\">Your phone or tablet is checking this code for you.<", + "tv_pairing_confirm_older_phones\">Older phones show %1\$s instead.<", + "tv_setup_phone_looking\">Looking for a phone or tablet…<", + "tv_setup_subtitle\">Use your phone or tablet, or enter the server address with the remote.<", + "tv_signin_form_title\">Sign in with a password<", + "tv_signin_form_submit\">Sign in<", + ).forEach { assertTrue(strings.contains(it), it) } + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailHeroArtworkTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailHeroArtworkTest.kt index dd03378971..11e1705cb4 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailHeroArtworkTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailHeroArtworkTest.kt @@ -4,12 +4,37 @@ import androidx.compose.ui.graphics.Color import java.io.File import org.prairieserver.prairie.model.catalog.EpisodeListItem import org.prairieserver.prairie.model.catalog.ItemDetail +import org.prairieserver.prairie.model.catalog.LeafItemUserData +import org.prairieserver.prairie.model.settings.EpisodeSpoilerPrefs import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull class TvDetailHeroArtworkTest { + @Test + fun standaloneEpisodeFallbackProtectsOnlyUnstartedStills() { + val detail = ItemDetail( + contentId = "episode-1", type = "episode", title = "Episode", + posterUrl = "episode-still", posterThumbhash = "still-hash", + ) + val prefs = EpisodeSpoilerPrefs(hideImages = true) + + assertEquals(TvDetailHeroArtwork(null, "still-hash"), resolveTvDetailHeroArtwork(detail, null, prefs)) + assertEquals( + TvDetailHeroArtwork("episode-still", "still-hash"), + resolveTvDetailHeroArtwork(detail.copy(userData = LeafItemUserData(played = true)), null, prefs), + ) + val withBackdrop = detail.copy( + backdropUrl = "series-backdrop", backdropThumbhash = "series-hash", backdropIsEpisodeStill = false, + ) + assertEquals(TvDetailHeroArtwork("series-backdrop", "series-hash"), resolveTvDetailHeroArtwork(withBackdrop, null, prefs)) + assertEquals( + TvDetailHeroArtwork(null, "series-hash"), + resolveTvDetailHeroArtwork(withBackdrop.copy(backdropIsEpisodeStill = true), null, prefs), + ) + } + @Test fun episodicDetailFallsBackToLandscapeEpisodeStill() { val detail = ItemDetail( diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadataTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadataTest.kt index 82e613d498..4f0ad2b038 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadataTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadataTest.kt @@ -12,6 +12,7 @@ import org.prairieserver.prairie.model.catalog.VideoTrack import org.prairieserver.prairie.model.catalog.selectedMediaRuntimeMinutes import org.prairieserver.prairie.model.catalog.trailerRailEntries import org.prairieserver.prairie.model.ebook.MediaPerson +import org.prairieserver.prairie.tv.ui.components.TvHeroFactToken import java.time.ZoneId import kotlin.test.Test import kotlin.test.assertEquals diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailAccessChangeTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailAccessChangeTest.kt new file mode 100644 index 0000000000..f779b72359 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailAccessChangeTest.kt @@ -0,0 +1,260 @@ +package org.prairieserver.prairie.tv.ui.screens.detail + +import androidx.lifecycle.viewModelScope +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.MockEngineConfig +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.prairieserver.prairie.domain.settings.ProfileSettingsController +import org.prairieserver.prairie.model.catalog.ItemDetail +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.PrairieJson +import org.prairieserver.prairie.network.TokenManagerImpl +import org.prairieserver.prairie.network.api.CatalogApi +import org.prairieserver.prairie.network.api.DefaultMetadataAiApi +import org.prairieserver.prairie.network.api.PersonalDataApi +import org.prairieserver.prairie.network.api.ProfileApi +import org.prairieserver.prairie.network.api.SettingsApi +import org.prairieserver.prairie.network.apiv2.ApiV2Gate +import org.prairieserver.prairie.network.apiv2.SettingsV2Api +import org.prairieserver.prairie.repository.CatalogRepository +import org.prairieserver.prairie.repository.MetadataAiRepository +import org.prairieserver.prairie.repository.PersonalDataRepository +import org.prairieserver.prairie.repository.ProfileRepository +import org.prairieserver.prairie.repository.SettingsRepository +import org.prairieserver.prairie.repository.port.CatalogCachePort +import org.prairieserver.prairie.tv.testing.FakePlayerSettingsStore +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * An access change refreshes an open TV detail page. A refusal replaces the + * page with the error; a later change must bring back a title the viewer + * regains, and must not bring back the durable cached copy of one the server + * still refuses. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class TvItemDetailAccessChangeTest { + + @Test + fun `an access change reloads a refused title once access returns`() = runDetailTest { + var available = true + val viewModel = createViewModel(available = { available }) + viewModel.loadAll() + advanceUntilIdle() + assertEquals(CONTENT_ID, viewModel.uiState.value.detail?.contentId) + + available = false + viewModel.refreshAfterAccessChange() + advanceUntilIdle() + assertEquals(null, viewModel.uiState.value.detail) + + available = true + viewModel.refreshAfterAccessChange() + advanceUntilIdle() + + assertEquals(CONTENT_ID, viewModel.uiState.value.detail?.contentId) + assertEquals(null, viewModel.uiState.value.error) + } + + @Test + fun `an access change on a still refused title does not repaint its cached detail`() = runDetailTest { + var available = true + val viewModel = createViewModel( + available = { available }, + cached = ItemDetail(contentId = CONTENT_ID, type = "movie", title = "Cached"), + ) + viewModel.loadAll() + advanceUntilIdle() + + available = false + viewModel.refreshAfterAccessChange() + advanceUntilIdle() + // A second, unrelated access change runs the full load, which paints + // the durable cached copy before the live request is refused again. + viewModel.refreshAfterAccessChange() + advanceUntilIdle() + + assertEquals(null, viewModel.uiState.value.detail) + assertTrue(viewModel.uiState.value.error != null) + } + + @Test + fun `an access change during the first load replaces it`() = runDetailTest { + var available = true + val firstResponse = CompletableDeferred<Unit>() + val viewModel = createViewModel(available = { available }, holdFirst = firstResponse) + viewModel.loadAll() + // The first request is answered under the old policy but held back. + advanceUntilIdle() + assertTrue(viewModel.uiState.value.isLoading) + + available = false + viewModel.refreshAfterAccessChange() + firstResponse.complete(Unit) + advanceUntilIdle() + + assertEquals(null, viewModel.uiState.value.detail) + assertTrue(viewModel.uiState.value.error != null) + } + + @Test + fun `an access change during the first load brings back a restored title`() = runDetailTest { + var available = false + val firstResponse = CompletableDeferred<Unit>() + val viewModel = createViewModel(available = { available }, holdFirst = firstResponse) + viewModel.loadAll() + advanceUntilIdle() + + available = true + viewModel.refreshAfterAccessChange() + firstResponse.complete(Unit) + advanceUntilIdle() + + assertEquals(CONTENT_ID, viewModel.uiState.value.detail?.contentId) + assertEquals(null, viewModel.uiState.value.error) + } + + @Test + fun `an older quiet refresh does not restore a title an access change refused`() = runDetailTest { + var available = true + val returnResponse = CompletableDeferred<Unit>() + val viewModel = createViewModel(available = { available }, holdFirst = returnResponse, holdRequest = 2) + viewModel.loadAll() + advanceUntilIdle() + // A return refresh answered under the old policy but held back. + viewModel.refreshOnReturn() + advanceUntilIdle() + + available = false + viewModel.refreshAfterAccessChange() + advanceUntilIdle() + returnResponse.complete(Unit) + advanceUntilIdle() + + assertEquals(null, viewModel.uiState.value.detail) + assertTrue(viewModel.uiState.value.error != null) + } + + @Test + fun `a quiet refresh replacing an access change refresh still shows the refusal`() = runDetailTest { + var available = true + val accessResponse = CompletableDeferred<Unit>() + val viewModel = createViewModel(available = { available }, holdFirst = accessResponse, holdRequest = 2) + viewModel.loadAll() + advanceUntilIdle() + + available = false + viewModel.refreshAfterAccessChange() + advanceUntilIdle() + viewModel.refreshOnReturn() + advanceUntilIdle() + accessResponse.complete(Unit) + advanceUntilIdle() + + assertEquals(null, viewModel.uiState.value.detail) + assertTrue(viewModel.uiState.value.error != null) + } + + // ------------------------------------------------------------------ + + private val createdViewModels = mutableListOf<androidx.lifecycle.ViewModel>() + + private fun runDetailTest(block: suspend TestScope.() -> Unit) = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + try { + block() + } finally { + // Cancel viewModelScope work and let the cancellations finish + // while Main is still the test dispatcher. + createdViewModels.forEach { it.viewModelScope.cancel() } + createdViewModels.clear() + advanceUntilIdle() + Dispatchers.resetMain() + } + } + + /** + * With [holdFirst], detail request number [holdRequest] reads [available] + * when it arrives but answers only once [holdFirst] completes. + */ + private fun TestScope.createViewModel( + available: () -> Boolean, + cached: ItemDetail? = null, + holdFirst: CompletableDeferred<Unit>? = null, + holdRequest: Int = 1, + ): TvItemDetailViewModel { + val dispatcher = StandardTestDispatcher(testScheduler) + var detailRequests = 0 + val client = HttpClient( + MockEngine( + MockEngineConfig().apply { + this.dispatcher = dispatcher + addHandler { request -> + val isDetail = request.url.encodedPath == "/api/v2/catalog/items/$CONTENT_ID" + val allowed = isDetail && available() + if (isDetail && ++detailRequests == holdRequest) holdFirst?.await() + when { + !isDetail -> respond( + """{"type":"about:blank","title":"not_found","status":404,"detail":"not found"}""", + HttpStatusCode.NotFound, + PROBLEM_HEADERS, + ) + allowed -> respond( + """{"content_id":"$CONTENT_ID","type":"movie","title":"Movie","cast":[],"crew":[],"versions":[],"subtitles":[]}""", + HttpStatusCode.OK, + headersOf(HttpHeaders.ContentType, "application/json"), + ) + else -> respond( + """{"type":"about:blank","title":"not_found","status":404,"detail":"Item not found"}""", + HttpStatusCode.NotFound, + PROBLEM_HEADERS, + ) + } + } + }, + ), + ) { install(ContentNegotiation) { json(PrairieJson) } } + val tokenManager = TokenManagerImpl() + return TvItemDetailViewModel( + catalogRepository = CatalogRepository( + CatalogApi(client), + catalogCache = object : CatalogCachePort { + override suspend fun getCachedItemDetail(contentId: String): ItemDetail? = cached + }, + requestDispatcher = dispatcher, + ), + personalDataRepository = PersonalDataRepository(PersonalDataApi(client)), + playerSettingsStore = FakePlayerSettingsStore(), + profileRepository = ProfileRepository(ProfileApi(client, ApiV2Gate.Unrestricted), tokenManager), + profileSettings = ProfileSettingsController( + SettingsRepository(SettingsApi(SettingsV2Api(client, tokenManager, ApiV2Gate.Unrestricted))), + ), + metadataAiRepository = MetadataAiRepository(DefaultMetadataAiApi(client, gate = ApiV2Gate.Unrestricted)), + contentId = CONTENT_ID, + tokenManager = tokenManager, + identityTransitions = DefaultIdentityTransitionBarrier(), + ).also { createdViewModels += it } + } + + private companion object { + const val CONTENT_ID = "movie-1" + val PROBLEM_HEADERS = headersOf(HttpHeaders.ContentType, "application/problem+json") + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvNextUpSelectionHandoffTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvNextUpSelectionHandoffTest.kt index 0ba02d8ffe..bd38102aca 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvNextUpSelectionHandoffTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvNextUpSelectionHandoffTest.kt @@ -41,6 +41,10 @@ import org.prairieserver.prairie.network.PrairieJson import org.prairieserver.prairie.network.TokenManager import org.prairieserver.prairie.network.api.CatalogApi import org.prairieserver.prairie.network.api.DefaultMetadataAiApi +import org.prairieserver.prairie.network.api.MetadataAiApi +import org.prairieserver.prairie.model.metadata.MetadataAiStatus +import org.prairieserver.prairie.model.metadata.MetadataTranslationJob +import org.prairieserver.prairie.repository.port.LocalPlaybackProgress import org.prairieserver.prairie.network.api.PersonalDataApi import org.prairieserver.prairie.network.api.ProfileApi import org.prairieserver.prairie.network.api.SettingsApi @@ -75,6 +79,60 @@ import kotlin.test.assertTrue */ @OptIn(ExperimentalCoroutinesApi::class) class TvNextUpSelectionHandoffTest { + @Test + fun delayedTranslationDoesNotRestoreANewerReloadsRemovedEpisodes() = runDetailTest { + val scenario = Scenario(suffix = "-translation-reload") + scenario.translationPending = true + val fixture = createFixture(scenario, acceptingTranslationApi()) + awaitEpisode(fixture.viewModel, scenario.episodeOneId) + awaitCondition { fixture.viewModel.uiState.value.carouselEpisodes.size == 3 } + val initialReads = scenario.completedSeasonOneReads.get() + val oldRead = CompletableDeferred<Unit>() + scenario.seasonOneResponses += DetailResponse(oldRead, scenario.episodesJson() + .replace("\"pending_translation_language\":\"de\"", "\"pending_translation_language\":null")) + fixture.viewModel.translateShownSeasonEpisodes() + awaitCondition { scenario.seasonOneResponses.isEmpty() } + + scenario.seasonOnePageOverride = """{"items":[{"content_id":"${scenario.episodeOneId}","season_number":1,"episode_number":1,"overview":"Newer"}]}""" + fixture.viewModel.refreshOnReturn() + awaitCondition { fixture.viewModel.uiState.value.episodes.singleOrNull()?.overview == "Newer" } + oldRead.complete(Unit) + // A discarded reply keeps polling. The old implementation instead + // publishes the removed row and treats this completed reply as final. + awaitCondition { + fixture.viewModel.uiState.value.episodes.any { it.contentId == scenario.episodeTwoId } || + scenario.completedSeasonOneReads.get() >= initialReads + 3 + } + + assertEquals(listOf(scenario.episodeOneId), fixture.viewModel.uiState.value.episodes.map { it.contentId }) + assertEquals("Newer", fixture.viewModel.uiState.value.episodes.single().overview) + assertFalse(fixture.viewModel.uiState.value.carouselEpisodes.any { it.contentId == scenario.episodeTwoId }) + } + + @Test + fun translationAppliesLocalProgressToNewEpisodeRows() = runDetailTest { + val scenario = Scenario(suffix = "-translation-local-progress") + scenario.seasonOnePageOverride = """{"items":[{"content_id":"${scenario.episodeOneId}","season_number":1,"episode_number":1,"pending_translation_language":"de"}]}""" + val fixture = createFixture(scenario, acceptingTranslationApi()) + awaitEpisode(fixture.viewModel, scenario.episodeOneId) + awaitCondition { fixture.viewModel.uiState.value.carouselEpisodes.size == 2 } + fixture.userState.progress[scenario.episodeTwoId] = LocalPlaybackProgress(201, 30.0, 120.0) + scenario.seasonOnePageOverride = """{"items":[{"content_id":"${scenario.episodeOneId}","season_number":1,"episode_number":1},{"content_id":"${scenario.episodeTwoId}","season_number":1,"episode_number":2,"user_data":{"played":false,"is_in_progress":false}}]}""" + fixture.viewModel.translateShownSeasonEpisodes() + awaitCondition { fixture.viewModel.uiState.value.episodes.size == 2 } + + val inserted = fixture.viewModel.uiState.value.episodes.first { it.contentId == scenario.episodeTwoId } + assertTrue(inserted.userData?.isInProgress == true) + assertEquals(30.0, inserted.userData?.positionSeconds) + } + + private fun acceptingTranslationApi() = object : MetadataAiApi { + override suspend fun status() = ApiResult.Success(MetadataAiStatus()) + override suspend fun translateDescription(contentId: String, targetLanguage: String, + scope: AuthScopeSnapshot?) = ApiResult.Success( + MetadataTranslationJob("job", "season", contentId, targetLanguage, "pending")) + } + @Test fun routedEpisodeReplacesAnEarlierSeasonJump() = runDetailTest { val scenario = Scenario(suffix = "-routed-season-jump") @@ -701,6 +759,72 @@ class TvNextUpSelectionHandoffTest { assertNull(TvDetailTrackSelectionSession.recall(scenario.episodeTwoId)) } + @Test + fun markingSelectedSeasonWatchedChecksItsEpisodesAndRefreshesTheSeason() = runDetailTest { + val scenario = Scenario(suffix = "-season-watched") + val fixture = createFixture(scenario) + awaitEpisode(fixture.viewModel, scenario.episodeOneId) + val season = fixture.viewModel.uiState.value.seasons.first { it.seasonNumber == 1 } + val seasonRequestsBefore = scenario.seasonsRequests.get() + + fixture.viewModel.onSetSeasonWatched(season, true) + + val optimistic = fixture.viewModel.uiState.value + assertEquals(true, optimistic.seasons.first { it.seasonNumber == 1 }.userData?.played) + assertTrue(optimistic.episodes.all { it.userData?.played == true }) + awaitCondition { + scenario.seasonsRequests.get() > seasonRequestsBefore && + fixture.viewModel.uiState.value.seasons.first { it.seasonNumber == 1 }.userData?.played == true + } + assertEquals(1, scenario.seasonOneWatchRequests.get()) + assertTrue(scenario.episodeOneWatched && scenario.episodeTwoWatched) + assertTrue(fixture.viewModel.uiState.value.episodes.all { it.userData?.played == true }) + } + + @Test + fun failedSeasonWatchedRestoresTheSeasonAndItsEpisodes() = runDetailTest { + val scenario = Scenario(suffix = "-season-watched-fails") + scenario.seasonOneWatchFails = true + val fixture = createFixture(scenario) + awaitEpisode(fixture.viewModel, scenario.episodeOneId) + val season = fixture.viewModel.uiState.value.seasons.first { it.seasonNumber == 1 } + + fixture.viewModel.onSetSeasonWatched(season, true) + awaitCondition { + scenario.seasonOneWatchRequests.get() == 1 && + fixture.viewModel.uiState.value.episodes.none { it.userData?.played == true } + } + + val state = fixture.viewModel.uiState.value + assertFalse(state.seasons.first { it.seasonNumber == 1 }.userData?.played == true) + assertEquals(scenario.episodeOneId, state.nextUpEpisode?.contentId) + } + + @Test + fun failedSeasonWatchedKeepsAnEpisodeMarkedWhileItWasPending() = runDetailTest { + val scenario = Scenario(suffix = "-season-watched-overlap") + scenario.episodeTwoWatched = true + scenario.seasonOneWatchFails = true + scenario.seasonOneWatchGate = CompletableDeferred() + val fixture = createFixture(scenario) + awaitEpisode(fixture.viewModel, scenario.episodeOneId) + val season = fixture.viewModel.uiState.value.seasons.first { it.seasonNumber == 1 } + + fixture.viewModel.onSetSeasonWatched(season, true) + awaitCondition { scenario.seasonOneWatchRequests.get() == 1 } + fixture.viewModel.onSetEpisodeWatched(scenario.episodeTwoId, false) + awaitCondition { !scenario.episodeTwoWatched } + scenario.seasonOneWatchGate?.complete(Unit) + + awaitCondition { + fixture.viewModel.uiState.value.seasons.first { it.seasonNumber == 1 }.userData?.played != true && + fixture.viewModel.uiState.value.episodes.none { it.userData?.played == true } + } + val played = fixture.viewModel.uiState.value.episodes.associate { it.contentId to (it.userData?.played == true) } + // Episode one reverts with the failed season write; episode two keeps its own change. + assertEquals(mapOf(scenario.episodeOneId to false, scenario.episodeTwoId to false), played) + } + // ------------------------------------------------------------------ private val createdViewModels = mutableListOf<ViewModel>() @@ -718,7 +842,7 @@ class TvNextUpSelectionHandoffTest { } } - private fun createFixture(scenario: Scenario): Fixture { + private fun createFixture(scenario: Scenario, metadataApi: MetadataAiApi? = null): Fixture { val identityTransitions = DefaultIdentityTransitionBarrier() val tokenManager = FakeTokenManager(identityTransitions) val client = scenario.client(tokenManager) @@ -750,7 +874,7 @@ class TvNextUpSelectionHandoffTest { }, profileRepository = profileRepository, profileSettings = ProfileSettingsController(SettingsRepository(UnavailableSettingsApi())), - metadataAiRepository = MetadataAiRepository(DefaultMetadataAiApi(client, gate = ApiV2Gate.Unrestricted)), + metadataAiRepository = MetadataAiRepository(metadataApi ?: DefaultMetadataAiApi(client, gate = ApiV2Gate.Unrestricted)), contentId = scenario.seriesId, userItemState = userState, tokenManager = tokenManager, @@ -803,11 +927,18 @@ class TvNextUpSelectionHandoffTest { val seasonTwoEpisodeId = "season-2-episode-1$suffix" var episodeOneWatched = false var episodeTwoWatched = false + var seasonOneWatchFails = false + var seasonOneWatchGate: CompletableDeferred<Unit>? = null + val seasonOneWatchRequests = AtomicInteger() var episodeTwoGate: CompletableDeferred<Unit>? = null var episodeOneWatchGate: CompletableDeferred<Unit>? = null var seasonsGate: CompletableDeferred<Unit>? = null var seasonOneEpisodesGate: CompletableDeferred<Unit>? = null var seasonTwoEpisodesGate: CompletableDeferred<Unit>? = null + var translationPending = false + var seasonOnePageOverride: String? = null + val seasonOneResponses = ConcurrentLinkedDeque<DetailResponse>() + val completedSeasonOneReads = AtomicInteger() val seasonsRequests = AtomicInteger() val episodeTwoRequests = AtomicInteger() val pendingEpisodeOneResponses = AtomicInteger() @@ -837,14 +968,17 @@ class TvNextUpSelectionHandoffTest { seasonsGate?.await() json( """{"items":[ - {"content_id":"season-1$suffix","season_number":1,"title":"Season 1"}, + {"content_id":"season-1$suffix","season_number":1,"title":"Season 1","user_data":{"played":${episodeOneWatched && episodeTwoWatched}}}, {"content_id":"season-2$suffix","season_number":2,"title":"Season 2"} ]}""".trimIndent(), ) } "/api/v2/catalog/series/$seriesId/seasons/1/episodes" -> { - seasonOneEpisodesGate?.await() - json(episodesJson()) + val queued = seasonOneResponses.pollFirst() + queued?.gate?.await() + if (queued == null) seasonOneEpisodesGate?.await() + completedSeasonOneReads.incrementAndGet() + json(queued?.json ?: seasonOnePageOverride ?: episodesJson()) } "/api/v2/catalog/series/$seriesId/seasons/2/episodes" -> { seasonTwoEpisodesGate?.await() @@ -876,6 +1010,22 @@ class TvNextUpSelectionHandoffTest { episodeOneWatched = request.method.value != "DELETE" respond("", HttpStatusCode.NoContent) } + "/api/v2/watched/season-1$suffix" -> { + seasonOneWatchRequests.incrementAndGet() + seasonOneWatchGate?.await() + if (seasonOneWatchFails) { + respond( + content = """{"type":"about:blank","title":"failed","status":500}""", + status = HttpStatusCode.InternalServerError, + headers = headersOf(HttpHeaders.ContentType, "application/problem+json"), + ) + } else { + val watched = request.method.value != "DELETE" + episodeOneWatched = watched + episodeTwoWatched = watched + respond("", HttpStatusCode.NoContent) + } + } "/api/v2/watched/$episodeTwoId" -> { episodeTwoWatched = request.method.value != "DELETE" respond("", HttpStatusCode.NoContent) @@ -894,9 +1044,9 @@ class TvNextUpSelectionHandoffTest { install(ContentNegotiation) { json(PrairieJson) } } - private fun episodesJson(): String = + fun episodesJson(): String = """{"items":[ - {"content_id":"$episodeOneId","season_number":1,"episode_number":1,"title":"One","user_data":{"played":$episodeOneWatched}}, + {"content_id":"$episodeOneId","season_number":1,"episode_number":1,"title":"One","pending_translation_language":${if (translationPending) "\"de\"" else "null"},"user_data":{"played":$episodeOneWatched}}, {"content_id":"$episodeTwoId","season_number":1,"episode_number":2,"title":"Two","user_data":{"played":$episodeTwoWatched}} ]}""".trimIndent() } @@ -908,6 +1058,9 @@ class TvNextUpSelectionHandoffTest { val writes = CopyOnWriteArrayList<Write>() val readGates = ConcurrentHashMap<Pair<String, Int>, CompletableDeferred<Unit>>() val startedReads: MutableSet<Pair<String, Int>> = ConcurrentHashMap.newKeySet() + val progress = ConcurrentHashMap<String, LocalPlaybackProgress>() + override suspend fun localPlaybackProgress(contentId: String) = progress[contentId] + override suspend fun localPlaybackProgressForContent(contentIds: List<String>) = progress.filterKeys { it in contentIds } private var nextPersonalWrite = 0L override suspend fun beginPersonalWrite(command: PersonalWrite) = diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvPlaybackFormattingTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvPlaybackFormattingTest.kt index 5800151fc7..1d532ba0e1 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvPlaybackFormattingTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvPlaybackFormattingTest.kt @@ -266,11 +266,6 @@ class TvPlaybackFormattingTest { assertEquals("4K · HDR", TvPlaybackFormatting.versionCompactLabel(v)) } - @Test fun compactSubtitleSelectorValue_dropsAutoAndTechnicalSuffixes() { - assertEquals("English", compactSubtitleSelectorValue("Auto - English · SRT")) - assertEquals("Off", compactSubtitleSelectorValue("Off")) - } - @Test fun versionValueLabel_matchesTvOsDolbyVisionSummary() { val v = fileVersion( resolution = "2160p", diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryBrowsePrefsTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryBrowsePrefsTest.kt new file mode 100644 index 0000000000..6dc6db96d8 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryBrowsePrefsTest.kt @@ -0,0 +1,235 @@ +package org.prairieserver.prairie.tv.ui.screens.library + +import androidx.lifecycle.viewModelScope +import androidx.test.core.app.ApplicationProvider +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.prairieserver.prairie.catalog.filter.CatalogFacet +import org.prairieserver.prairie.common.settings.BrowsePrefsStore +import org.prairieserver.prairie.model.server.ServerEntry +import org.prairieserver.prairie.network.ServerRegistry +import org.prairieserver.prairie.network.PrairieJson +import org.prairieserver.prairie.network.api.CatalogApi +import org.prairieserver.prairie.network.api.SectionApi +import org.prairieserver.prairie.repository.CatalogRepository +import org.prairieserver.prairie.repository.SectionRepository +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +@OptIn(ExperimentalCoroutinesApi::class) +// BrowsePrefsStore writes through real SharedPreferences. +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = android.app.Application::class) +class TvLibraryBrowsePrefsTest { + + @Test + fun savedStateRoundTripsEveryFacetAndDropsPerVisitFields() { + var selection = TvCatalogFacetSelection(matchAll = false) + for (facet in TvCatalogFacet.entries) { + val value = when (facet) { + TvCatalogFacet.Decade -> "1990" + TvCatalogFacet.DynamicRange -> "dolby_vision" + TvCatalogFacet.WatchStatus -> TvWatchStatusFilter.InProgress.wireValue + else -> "${facet.name}-value" + } + selection = selection.toggled(facet, value) + } + val filter = TvLibraryBrowseFilter( + genre = "Drama", + namePrefix = "K", + sort = TvLibrarySortOption.Rating.wireValue, + order = "asc", + facetSelection = selection, + ) + + val saved = filter.toSavedState() + // The shared model (phone + Apple) spells In Progress `inProgress`. + assertEquals(setOf("inProgress"), saved.valuesFor(CatalogFacet.WatchStatus)) + assertEquals(CatalogFacet.entries.size, saved.selections.size) + + assertEquals( + TvLibraryBrowseFilter(sort = "rating_imdb", order = "asc", facetSelection = selection), + saved.toTvBrowseFilter(), + ) + } + + @Test + fun browseSortAndFiltersSurviveANewViewModel() = runPrefsTest { + val prefs = BrowsePrefsStore(ApplicationProvider.getApplicationContext(), FakeServerRegistry(), "androidtv") + val first = viewModel(prefs) + first.onTabSelected(TvLibraryTab.Browse) + first.onSortKeySelected(TvLibrarySortOption.ReleaseDate) + first.onFacetSelectionApplied( + TvCatalogFacetSelection().toggled(TvCatalogFacet.Genre, "Drama"), + ) + first.onNamePrefixChanged("K") + + // A cold start lands on Recommended; opening Browse restores the + // saved sort and filters but not the A–Z jump. + val second = viewModel(prefs) + assertEquals(TvLibraryBrowseFilter(), second.uiState.value.browseFilter) + second.onTabSelected(TvLibraryTab.Browse) + val restored = second.uiState.value.browseFilter + assertEquals("release_date", restored.sort) + assertEquals("desc", restored.order) + assertEquals(setOf("Drama"), restored.facetSelection.genres) + assertNull(restored.namePrefix) + + // Turning preserve off forgets them: Browse opens on Title A–Z again. + second.onPreserveFiltersChanged(false) + val third = viewModel(prefs) + assertEquals(false, third.uiState.value.preserveFilters) + third.onTabSelected(TvLibraryTab.Browse) + assertEquals("title", third.uiState.value.browseFilter.sort) + assertEquals(TvCatalogFacetSelection(), third.uiState.value.browseFilter.facetSelection) + } + + @Test + fun presetSectionsDoNotOverwriteTheSavedBrowseState() = runPrefsTest { + val prefs = BrowsePrefsStore(ApplicationProvider.getApplicationContext(), FakeServerRegistry(), "androidtv") + val first = viewModel(prefs) + first.onTabSelected(TvLibraryTab.Browse) + first.onSortKeySelected(TvLibrarySortOption.Year) + first.onTabSelected(TvLibraryTab.RecentlyAdded) + first.onTabSelected(TvLibraryTab.Alphabet) + + assertEquals("year", prefs.savedState(LIBRARY_ID)?.sort) + } + + @Test + fun clearingFiltersReplacesARestoredFilterButKeepsTheSort() = runPrefsTest { + val prefs = BrowsePrefsStore(ApplicationProvider.getApplicationContext(), FakeServerRegistry(), "androidtv") + val first = viewModel(prefs) + first.onTabSelected(TvLibraryTab.Browse) + first.onSortKeySelected(TvLibrarySortOption.Year) + first.onFacetSelectionApplied( + TvCatalogFacetSelection().toggled(TvCatalogFacet.WatchStatus, TvWatchStatusFilter.Unwatched.wireValue), + ) + + // The Browse error screen's Clear filters action, after a restart. + val second = viewModel(prefs) + second.onTabSelected(TvLibraryTab.Browse) + second.onFacetSelectionApplied(TvCatalogFacetSelection()) + + val saved = prefs.savedState(LIBRARY_ID) + assertEquals(emptyMap(), saved?.selections) + assertEquals("year", saved?.sort) + } + + @Test + fun mixedLibraryMoviesAndSeriesKeepSeparateBrowseState() = runPrefsTest { + val prefs = BrowsePrefsStore(ApplicationProvider.getApplicationContext(), FakeServerRegistry(), "androidtv") + val movies = viewModel(prefs, libraryType = "mixed", mediaScope = "movie") + movies.onTabSelected(TvLibraryTab.Browse) + movies.onSortKeySelected(TvLibrarySortOption.Year) + movies.onFacetSelectionApplied( + TvCatalogFacetSelection().toggled(TvCatalogFacet.Genre, "Drama"), + ) + movies.onPreserveFiltersChanged(false) + + // The Series tab of the same library opens on its own defaults and + // keeps its own preserve toggle. + val series = viewModel(prefs, libraryType = "mixed", mediaScope = "series") + assertEquals(true, series.uiState.value.preserveFilters) + series.onTabSelected(TvLibraryTab.Browse) + assertEquals("title", series.uiState.value.browseFilter.sort) + assertEquals(TvCatalogFacetSelection(), series.uiState.value.browseFilter.facetSelection) + series.onSortKeySelected(TvLibrarySortOption.Rating) + + assertEquals("rating_imdb", prefs.savedState(LIBRARY_ID, "series")?.sort) + assertNull(prefs.savedState(LIBRARY_ID, "movie")) + assertNull(prefs.savedState(LIBRARY_ID)) + assertEquals(false, viewModel(prefs, libraryType = "mixed", mediaScope = "movie").uiState.value.preserveFilters) + } + + private val createdViewModels = mutableListOf<TvLibraryDetailViewModel>() + + private fun runPrefsTest(block: suspend () -> Unit) = runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + try { + block() + } finally { + createdViewModels.forEach { it.viewModelScope.coroutineContext[Job]?.cancelAndJoin() } + createdViewModels.clear() + Dispatchers.resetMain() + } + } + + private suspend fun viewModel( + prefs: BrowsePrefsStore, + libraryType: String = "movies", + mediaScope: String? = null, + ): TvLibraryDetailViewModel { + val client = HttpClient( + MockEngine { request -> + val body = when (request.url.encodedPath) { + "/api/v1/library/$LIBRARY_ID/sections" -> """{"sections":[]}""" + "/api/v2/catalog/filters" -> """{"genres":["Drama"]}""" + else -> """{"total":0,"total_exact":true,"page":{"has_more":false},"items":[]}""" + } + respond(body, HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + } + val viewModel = TvLibraryDetailViewModel( + sectionRepository = SectionRepository(SectionApi(client)), + catalogRepository = CatalogRepository(CatalogApi(client)), + libraryId = LIBRARY_ID, + libraryTitle = "Movies", + libraryType = libraryType, + mediaScope = mediaScope, + browsePrefs = prefs, + ).also { createdViewModels += it } + // Let the eager Recommended load finish before the test drives tabs. + withContext(Dispatchers.IO) { + withTimeout(30_000) { + while (viewModel.uiState.value.recommendedLoading) delay(10) + } + } + return viewModel + } + + /** BrowsePrefsStore persists nothing without an active server + profile. */ + private class FakeServerRegistry : ServerRegistry { + private val entry = ServerEntry(id = "server-1", url = "https://prairie.test", profileId = "profile-1") + override val entries: StateFlow<List<ServerEntry>> = MutableStateFlow(listOf(entry)) + override val activeServerId: StateFlow<String?> = MutableStateFlow(entry.id) + override val activeEntry: StateFlow<ServerEntry?> = MutableStateFlow(entry) + override suspend fun addOrUpdate(url: String, fetchedName: String?): String = entry.id + override suspend fun rename(serverId: String, userOverrideName: String?) = Unit + override suspend fun setFetchedName(serverId: String, fetchedName: String?) = Unit + override suspend fun setProfileId(serverId: String, profileId: String?) = Unit + override suspend fun remove(serverId: String) = Unit + override suspend fun signOut(serverId: String) = Unit + override suspend fun switchTo(serverId: String) = Unit + override suspend fun touchActive() = Unit + } + + private companion object { + const val LIBRARY_ID = 7 + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibrarySubdestinationViewModelTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibrarySubdestinationViewModelTest.kt index ece5cc3082..291ddf8d3d 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibrarySubdestinationViewModelTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibrarySubdestinationViewModelTest.kt @@ -12,6 +12,7 @@ import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf import io.ktor.serialization.kotlinx.json.json import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.cancelAndJoin @@ -106,7 +107,7 @@ class TvLibrarySubdestinationViewModelTest { viewModel.onTabSelected(TvLibraryTab.Browse) awaitState { requests.catalogRequestCount() >= 1 } viewModel.onSortKeySelected(TvLibrarySortOption.ReleaseDate) - awaitState { requests.lastCatalogRequestOrNull()?.query?.get("sort") == "-year" } + awaitState { requests.lastCatalogRequestOrNull()?.query?.get("sort") == "-release_date" } val requestsBeforeReentry = requests.catalogRequestCount() // Re-entering the screen (back out of item detail) re-issues the @@ -115,11 +116,41 @@ class TvLibrarySubdestinationViewModelTest { viewModel.onTabSelected(TvLibraryTab.Browse) settle() - assertEquals("year", viewModel.uiState.value.browseFilter.sort) + assertEquals("release_date", viewModel.uiState.value.browseFilter.sort) assertEquals("desc", viewModel.uiState.value.browseFilter.order) assertEquals(requestsBeforeReentry, requests.catalogRequestCount()) } + @Test + fun anEarlierFiltersLoadDoesNotOverwriteTheAccessChangeReload() = runLibraryTest { + val requests = mutableListOf<RequestRecord>() + val firstFilters = CompletableDeferred<Unit>() + val viewModel = viewModelFor(requests, libraryType = "movies") { request -> + if (request == 1) { + firstFilters.await() + filtersJson("Before") + } else { + filtersJson("After") + } + } + + viewModel.onTabSelected(TvLibraryTab.Browse) + awaitState { requests.filtersRequestCount() == 1 } + viewModel.refreshAfterAccessChange() + awaitState { viewModel.uiState.value.genres == listOf("After") } + // The pre-change answer arrives last. + firstFilters.complete(Unit) + settle() + + assertEquals(listOf("After"), viewModel.uiState.value.genres) + } + + private fun filtersJson(genre: String) = + """{"genres":["$genre"],"studios":[],"networks":[],"countries":[],"content_ratings":[],"original_languages":[],"authors":[],"narrators":[],"series":[]}""" + + private fun MutableList<RequestRecord>.filtersRequestCount(): Int = + synchronized(this) { count { it.path == "/api/v2/catalog/filters" } } + private val createdViewModels = mutableListOf<androidx.lifecycle.ViewModel>() private fun runLibraryTest(block: suspend () -> Unit) = runTest { @@ -183,10 +214,13 @@ class TvLibrarySubdestinationViewModelTest { lastOrNull { it.path == "/api/v2/catalog/audiobook-groups" } } + /** [filters] answers the Nth filters request (1-based); null serves one genre. */ private fun viewModelFor( requests: MutableList<RequestRecord>, libraryType: String, + filters: (suspend (request: Int) -> String)? = null, ): TvLibraryDetailViewModel { + val filterRequests = java.util.concurrent.atomic.AtomicInteger() val client = HttpClient( MockEngine { request -> val record = RequestRecord( @@ -201,7 +235,7 @@ class TvLibrarySubdestinationViewModelTest { "/api/v1/library/7/sections" -> respondJson("""{"sections":[]}""") "/api/v2/library/7/collections" -> respondJson("""{"library_id":"7","collections":[],"groups":[]}""") "/api/v2/catalog/filters" -> respondJson( - """{"genres":["Drama"],"studios":[],"networks":[],"countries":[],"content_ratings":[],"original_languages":[],"authors":[],"narrators":[],"series":[]}""", + filters?.invoke(filterRequests.incrementAndGet()) ?: """{"genres":["Drama"],"studios":[],"networks":[],"countries":[],"content_ratings":[],"original_languages":[],"authors":[],"narrators":[],"series":[]}""", ) "/api/v2/catalog/audiobook-groups" -> respondJson( """ diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvMixedLibraryScopeTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvMixedLibraryScopeTest.kt new file mode 100644 index 0000000000..d975f55f73 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvMixedLibraryScopeTest.kt @@ -0,0 +1,273 @@ +package org.prairieserver.prairie.tv.ui.screens.library + +import androidx.lifecycle.ViewModelStore +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.* +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.* +import kotlinx.coroutines.test.* +import org.prairieserver.prairie.network.* +import org.prairieserver.prairie.network.api.* +import org.prairieserver.prairie.network.apiv2.* +import org.prairieserver.prairie.repository.* +import kotlin.test.* + +@OptIn(ExperimentalCoroutinesApi::class) +class TvMixedLibraryScopeTest { + @Test fun refillsThreeShelvesAtATimeWithoutReorderingShelvesOrCursors() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + Dispatchers.setMain(dispatcher) + val owner = AuthScopeSnapshot("server", "profile", "https://example.invalid", "pin", identityGeneration = 1) + val tokens = object : TokenManager by TokenManagerImpl() { + override suspend fun snapshotCurrentScope() = owner + } + val release = CompletableDeferred<Unit>() + var active = 0 + var peak = 0 + val cursors = mutableMapOf<String, MutableList<String?>>() + val rows = (0..6).joinToString(",") { id -> + """{"id":"$id","title":"Shelf $id","featured":${id == 6},"section_type":"recently_added","item_limit":1,"total_count":2,"items":[{"content_id":"film","type":"movie","title":"Film"}]}""" + } + val client = HttpClient(MockEngine(MockEngineConfig().apply { + this.dispatcher = dispatcher + addHandler { request -> + val body = when { + request.url.encodedPath.endsWith("/sections") -> """{"sections":[$rows]}""" + request.url.encodedPath.endsWith("/filters") -> """{"genres":[],"studios":[],"networks":[],"countries":[],"original_languages":[],"content_ratings":[],"authors":[],"narrators":[],"series":[]}""" + else -> { + val id = request.url.parameters["section_id"]!! + val cursor = request.url.parameters["cursor"] + cursors.getOrPut(id) { mutableListOf() } += cursor + active++; peak = maxOf(peak, active) + try { + release.await() + delay((6 - id.toInt()) * 10L) + if (cursor == null) page("film", "movie", "next") else page("show-$id", "series") + } finally { active-- } + } + } + respond(body, headers = headersOf(HttpHeaders.ContentType, "application/json")) + } + })) { install(ContentNegotiation) { json(PrairieJson) } } + val store = ViewModelStore() + try { + val sections = SectionRepository(SectionApi(client, sectionItems = LibrarySectionItemsV2Api(client, tokens, ApiV2Gate.Unrestricted))) + val vm = TvLibraryDetailViewModel(sections, CatalogRepository(CatalogApi(client)), 7, "Anime", "mixed", "series") + store.put("series", vm) + runCurrent() + assertEquals(3, active, "Three independent shelves should load without waiting on the first") + release.complete(Unit) + advanceUntilIdle() + assertEquals(3, peak, "Do not flood the server with every shelf at once") + assertEquals((0..5).map(Int::toString), vm.uiState.value.sections.map { it.id }) + assertEquals((0..5).map { "show-$it" }, vm.uiState.value.sections.flatMap { it.items }.map { it.contentId }) + assertEquals(6, cursors.size) + cursors.values.forEach { assertEquals(listOf(null, "next"), it) } + } finally { store.clear(); client.close(); Dispatchers.resetMain() } + } + + @Test fun seriesShelfRefillsPastMoviesAndBrowseKeepsItsScope() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + Dispatchers.setMain(dispatcher) + val owner = AuthScopeSnapshot("server", "profile", "https://example.invalid", "pin", identityGeneration = 1) + val tokens = object : TokenManager by TokenManagerImpl() { + override suspend fun snapshotCurrentScope() = owner + } + val sectionCursors = mutableListOf<String?>() + val browseTypes = mutableListOf<String?>() + val client = HttpClient(MockEngine(MockEngineConfig().apply { + this.dispatcher = dispatcher + addHandler { request -> + val body = when { + request.url.encodedPath.endsWith("/sections") -> """{"sections":[{"id":"recent","title":"Recent","section_type":"recently_added","item_limit":1,"total_count":2,"items":[{"content_id":"film","type":"movie","title":"Film"}]}]}""" + request.url.encodedPath.endsWith("/filters") -> """{"genres":[],"studios":[],"networks":[],"countries":[],"original_languages":[],"content_ratings":[],"authors":[],"narrators":[],"series":[]}""" + request.url.parameters["source"] == "section" -> { + assertNull(request.url.parameters["type"], "Sections reject a type overlay") + assertNull(request.url.parameters["match"], "Sections reject even the default match overlay") + assertEquals("library", request.url.parameters["scope"]) + assertEquals("7", request.url.parameters["library_id"]) + assertEquals("recent", request.url.parameters["section_id"]) + val cursor = request.url.parameters["cursor"]; sectionCursors += cursor + if (cursor == null) page("film", "movie", "next") else page("show", "series") + } + else -> { + browseTypes += request.url.parameters["type"] + assertEquals("7", request.url.parameters["library_id"]) + page("show", "series") + } + } + respond(body, headers = headersOf(HttpHeaders.ContentType, "application/json")) + } + })) { install(ContentNegotiation) { json(PrairieJson) } } + val store = ViewModelStore() + try { + val sections = SectionRepository(SectionApi(client, sectionItems = LibrarySectionItemsV2Api(client, tokens, ApiV2Gate.Unrestricted))) + val vm = TvLibraryDetailViewModel(sections, CatalogRepository(CatalogApi(client)), 7, "Anime", "mixed", "series") + store.put("series", vm) + advanceUntilIdle() + assertEquals(listOf(null, "next"), sectionCursors) + assertEquals(listOf("show"), vm.uiState.value.sections.single().items.map { it.contentId }) + vm.onTabSelected(TvLibraryTab.Browse); advanceUntilIdle() + vm.onSortKeySelected(TvLibrarySortOption.Title); advanceUntilIdle() + assertEquals(listOf<String?>("series", "series"), browseTypes) + assertEquals(listOf("show"), vm.uiState.value.browseItems.map { it.contentId }) + } finally { store.clear(); client.close(); Dispatchers.resetMain() } + } + + @Test fun scopedPersonalCollectionKeepsLibraryAndTypeWhenSorting() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler); Dispatchers.setMain(dispatcher) + var requests = 0 + val client = HttpClient(MockEngine(MockEngineConfig().apply { + this.dispatcher = dispatcher + addHandler { request -> + if (request.url.encodedPath.endsWith("/filters")) { + respond("""{"genres":[],"studios":[],"networks":[],"countries":[],"original_languages":[],"content_ratings":[],"authors":[],"narrators":[],"series":[]}""", headers = headersOf(HttpHeaders.ContentType, "application/json")) + } else { + requests++ + assertEquals("user_collection", request.url.parameters["source"]) + assertEquals("series", request.url.parameters["type"]) + assertEquals("7", request.url.parameters["library_id"]) + respond(page("show", "series"), headers = headersOf(HttpHeaders.ContentType, "application/json")) + } + } + })) + val store = ViewModelStore() + try { + val vm = TvLibraryCollectionDetailViewModel(SectionRepository(SectionApi(client)), CatalogRepository(CatalogApi(client)), 7, "c1", "Collection", "series", "user_collection") + store.put("collection", vm); advanceUntilIdle() + assertEquals(listOf("show"), vm.uiState.value.items.map { it.contentId }) + vm.onSortSelected(TvLibrarySortOption.Title); advanceUntilIdle() + assertEquals(2, requests) + } finally { store.clear(); client.close(); Dispatchers.resetMain() } + } + + @Test fun retryCancelsTheSupersededRefillChain() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + Dispatchers.setMain(dispatcher) + val owner = AuthScopeSnapshot("server", "profile", "https://example.invalid", "pin", identityGeneration = 1) + val tokens = object : TokenManager by TokenManagerImpl() { + override suspend fun snapshotCurrentScope() = owner + } + val firstPageHeld = CompletableDeferred<Unit>() + val cursors = mutableListOf<String?>() + val client = HttpClient(MockEngine(MockEngineConfig().apply { + this.dispatcher = dispatcher + addHandler { request -> + val body = when { + request.url.encodedPath.endsWith("/sections") -> """{"sections":[{"id":"recent","title":"Recent","section_type":"recently_added","item_limit":1,"total_count":2,"items":[{"content_id":"film","type":"movie","title":"Film"}]}]}""" + request.url.encodedPath.endsWith("/filters") -> """{"genres":[],"studios":[],"networks":[],"countries":[],"original_languages":[],"content_ratings":[],"authors":[],"narrators":[],"series":[]}""" + else -> { + val cursor = request.url.parameters["cursor"]; cursors += cursor + // The first load's first page stays in flight until after the retry. + if (cursors.size == 1) firstPageHeld.await() + if (cursor == null) page("film", "movie", "next") else page("show", "series") + } + } + respond(body, headers = headersOf(HttpHeaders.ContentType, "application/json")) + } + })) { install(ContentNegotiation) { json(PrairieJson) } } + val store = ViewModelStore() + try { + val sections = SectionRepository(SectionApi(client, sectionItems = LibrarySectionItemsV2Api(client, tokens, ApiV2Gate.Unrestricted))) + val vm = TvLibraryDetailViewModel(sections, CatalogRepository(CatalogApi(client)), 7, "Anime", "mixed", "series") + store.put("series", vm) + runCurrent() + assertEquals(listOf<String?>(null), cursors) + vm.retryRecommended() + firstPageHeld.complete(Unit) + advanceUntilIdle() + // Only the retry pages on; the superseded load never asks for "next". + assertEquals(listOf(null, null, "next"), cursors) + assertEquals(listOf("show"), vm.uiState.value.sections.single().items.map { it.contentId }) + } finally { store.clear(); client.close(); Dispatchers.resetMain() } + } + + @Test fun profileCustomizedShelvesNeverRefillFromTheStoredDefinition() = runTest { + val film = org.prairieserver.prairie.model.section.SectionItem("film", "movie", "Film") + for (row in listOf( + org.prairieserver.prairie.model.section.ResolvedSection("random", "random", "Random", itemLimit = 1, totalCount = 21, customized = true, items = listOf(film)), + org.prairieserver.prairie.model.section.ResolvedSection("mine", "random", "Mine", itemLimit = 1, totalCount = 21, isCustom = true, items = listOf(film)), + )) { + // The refill would page the admin's unfiltered definition, not the profile's override. + val result = scopeTvLibrarySection(row, "series") { error("Customized shelf must not refill") } + assertEquals(emptyList(), result.section.items) + assertTrue(result.incomplete) + } + } + + @Test fun personalCollectionsShuffleInTheUserCollectionScope() { + assertEquals(org.prairieserver.prairie.model.shuffle.ShuffleScopeKind.USER_COLLECTION, collectionShuffleKind("user_collection", null)) + assertEquals(org.prairieserver.prairie.model.shuffle.ShuffleScopeKind.LIBRARY_COLLECTION, collectionShuffleKind("library_collection", null)) + } + + @Test fun scopedCollectionViewsOfferNoShuffle() { + // A shuffle request can't carry the Movies/Series scope, so it would draw from both. + assertNull(collectionShuffleKind("user_collection", "series")) + assertNull(collectionShuffleKind("library_collection", "movie")) + } + + @Test fun seriesShelvesKeepEpisodeProgressAndHideMovies() = runTest { + val episode = org.prairieserver.prairie.model.section.SectionItem("episode", "episode", "Episode", positionSeconds = 73.0) + val row = org.prairieserver.prairie.model.section.ResolvedSection("continue", "continue_watching", "Continue", itemLimit = 20, totalCount = 2, + items = listOf(episode, org.prairieserver.prairie.model.section.SectionItem("film", "movie", "Film"))) + val result = scopeTvLibrarySection(row, "series") { + ApiResult.Success(org.prairieserver.prairie.model.catalog.CatalogResponse(items = listOf( + PrairieJson.decodeFromString("""{"content_id":"episode","type":"episode","title":"Episode"}"""), + PrairieJson.decodeFromString("""{"content_id":"film","type":"movie","title":"Film"}"""), + ))) + } + assertEquals(listOf(episode), result.section.items, "Inline items keep their richer section payload") + assertFalse(result.incomplete) + } + + @Test fun boundedInlineCountStillRefillsScopedShelf() = runTest { + // Server recently-added shelves report total_count = len(items) after the limit, so 20 inline + // movies with total_count 20 says nothing about older series further down the source. + val movies = (1..20).map { org.prairieserver.prairie.model.section.SectionItem("film-$it", "movie", "Film $it") } + val row = org.prairieserver.prairie.model.section.ResolvedSection("recent", "recently_added", "Recent", + itemLimit = 20, totalCount = 20, items = movies) + var refills = 0 + val result = scopeTvLibrarySection(row, "series") { + refills++ + // The source continues past the inline window: the same 20 movies, then an older series. + val json = movies.map { """{"content_id":"${it.contentId}","type":"movie","title":"${it.title}"}""" } + + """{"content_id":"show","type":"series","title":"Show"}""" + ApiResult.Success(org.prairieserver.prairie.model.catalog.CatalogResponse(items = json.map { PrairieJson.decodeFromString(it) })) + } + assertEquals(1, refills, "A short scoped slice must ask the catalog instead of trusting total_count") + assertEquals(listOf("show"), result.section.items.map { it.contentId }) + assertFalse(result.incomplete) + } + + @Test fun pagedEpisodeKeepsItsPlaybackContext() = runTest { + val row = org.prairieserver.prairie.model.section.ResolvedSection("continue", "continue_watching", "Continue", totalCount = 1) + val item = PrairieJson.decodeFromString<org.prairieserver.prairie.model.catalog.BrowseItem>(""" + {"content_id":"episode","type":"episode","title":"Episode","series_id":"show","series_title":"Show", + "season_number":2,"episode_number":3,"position_seconds":73,"duration_seconds":1200,"item_source":"continue_watching"} + """) + val result = scopeTvLibrarySection(row, "series") { + ApiResult.Success(org.prairieserver.prairie.model.catalog.CatalogResponse(items = listOf(item))) + } + val episode = result.section.items.single() + assertEquals("show", episode.seriesId) + assertEquals(2, episode.seasonNumber) + assertEquals(3, episode.episodeNumber) + assertEquals(73.0, episode.positionSeconds) + assertEquals(1200.0, episode.durationSeconds) + assertEquals("continue_watching", episode.itemSource) + assertFalse(result.incomplete) + } + + @Test fun failedRefillRetainsMatchingInlineItemsAndReportsIncomplete() = runTest { + val episode = org.prairieserver.prairie.model.section.SectionItem("episode", "episode", "Episode") + val row = org.prairieserver.prairie.model.section.ResolvedSection("continue", "continue_watching", "Continue", totalCount = 100, items = listOf(episode)) + val result = scopeTvLibrarySection(row, "series") { ApiResult.Error(503, "unavailable", "Unavailable") } + assertEquals(listOf(episode), result.section.items) + assertTrue(result.incomplete) + } + + private fun page(id: String, type: String, next: String? = null) = + """{"items":[{"content_id":"$id","type":"$type","title":"$id"}],"page":{"has_more":${next != null},"next_cursor":${next?.let { "\"$it\"" } ?: "null"}},"total":2,"total_exact":true,"window_cursor":"window"}""" +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/PlayerTrackEntriesTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/PlayerTrackEntriesTest.kt index 31b5c08e65..fca15e13a8 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/PlayerTrackEntriesTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/PlayerTrackEntriesTest.kt @@ -8,7 +8,10 @@ import androidx.media3.common.TrackGroup import androidx.media3.common.Tracks import androidx.media3.common.util.UnstableApi import org.prairieserver.prairie.model.catalog.AudioTrack +import org.prairieserver.prairie.model.playback.AutoSubtitleContext import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo +import org.prairieserver.prairie.model.playback.resolveAutoSubtitle +import org.prairieserver.prairie.model.playback.selectedCandidate import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -85,7 +88,11 @@ class PlayerTrackEntriesTest { } @Test - fun autoSubtitlePreferenceMovesSelectedPgsToMatchingTextSidecar() { + fun autoSubtitlePreferenceKeepsTheMountedPgsTrackOverAnEquallySdhTextSidecar() { + // Both tracks are SDH (the sidecar's file name says so). This used to + // move to the sidecar only because PGS was demoted as a bitmap, but + // Media3 renders the mounted PGS track itself and no burn-in is + // involved, so the tie stays on the file's own track. val tracks = listOf( PlayerTrackEntry( index = 2, @@ -103,11 +110,11 @@ class PlayerTrackEntriesTest { ), ) - assertEquals(6, preferredAutoTextSubtitleIndex(tracks, preferredLanguage = "en")) + assertEquals(2, autoPick(tracks)) } @Test - fun autoSubtitlePreferenceLeavesSelectedTextTrackAlone() { + fun autoSubtitlePreferenceKeepsTheSelectedTextTrack() { val tracks = listOf( PlayerTrackEntry( index = 6, @@ -118,7 +125,7 @@ class PlayerTrackEntriesTest { ), ) - assertEquals(null, preferredAutoTextSubtitleIndex(tracks, preferredLanguage = "en")) + assertEquals(6, autoPick(tracks)) } @Test @@ -140,7 +147,7 @@ class PlayerTrackEntriesTest { ), ) - assertEquals(7, preferredAutoTextSubtitleIndex(tracks, preferredLanguage = "en")) + assertEquals(7, autoPick(tracks)) } @Test @@ -178,8 +185,8 @@ class PlayerTrackEntriesTest { ), ) - assertEquals(7, preferredAutoTextSubtitleIndex(ccFirst, preferredLanguage = "en")) - assertEquals(8, preferredAutoTextSubtitleIndex(closedCaptionFirst, preferredLanguage = "en")) + assertEquals(7, autoPick(ccFirst)) + assertEquals(8, autoPick(closedCaptionFirst)) } @Test @@ -194,7 +201,7 @@ class PlayerTrackEntriesTest { ), ) - assertEquals(4, preferredAutoTextSubtitleIndex(tracks, preferredLanguage = "en")) + assertEquals(4, autoPick(tracks)) } @Test @@ -216,7 +223,7 @@ class PlayerTrackEntriesTest { ), ) - assertEquals(4, preferredAutoTextSubtitleIndex(tracks, preferredLanguage = "en")) + assertEquals(4, autoPick(tracks)) } @Test @@ -239,16 +246,7 @@ class PlayerTrackEntriesTest { ), ) - assertEquals( - SubtitleAutoSelection.Disable, - resolveAutoSubtitleSelection( - audioTracks = audio, - subtitleTracks = subtitles, - preferredLanguage = "en", - subtitleMode = "auto", - showForced = true, - ), - ) + assertEquals(null, autoPick(subtitles, audio = audio)) } @Test @@ -279,16 +277,7 @@ class PlayerTrackEntriesTest { ), ) - assertEquals( - SubtitleAutoSelection.Select(2), - resolveAutoSubtitleSelection( - audioTracks = audio, - subtitleTracks = subtitles, - preferredLanguage = "en", - subtitleMode = "auto", - showForced = true, - ), - ) + assertEquals(2, autoPick(subtitles, audio = audio)) } @Test @@ -310,23 +299,15 @@ class PlayerTrackEntriesTest { ), ) - assertEquals( - SubtitleAutoSelection.Select(1), - resolveAutoSubtitleSelection( - audioTracks = listOf( - PlayerTrackEntry( - index = 0, - label = "Japanese AAC", - language = "ja", - isSelected = true, - ), - ), - subtitleTracks = subtitles, - preferredLanguage = "en", - subtitleMode = "auto", - showForced = true, + val japaneseAudio = listOf( + PlayerTrackEntry( + index = 0, + label = "Japanese AAC", + language = "ja", + isSelected = true, ), ) + assertEquals(1, autoPick(subtitles, audio = japaneseAudio)) } @Test @@ -489,80 +470,6 @@ class PlayerTrackEntriesTest { assertEquals(listOf(MimeTypes.APPLICATION_PGS, MimeTypes.APPLICATION_SUBRIP), entries.map { it.codecOrMime }) } - @Test - fun videoQualityOptionsFlattenPerFormatVariantsWithAutoFirst() { - // A single video group carrying three resolution variants must surface - // three real options, not collapse to one — plus a synthetic "Auto". - val group = TrackGroup( - videoFormat(width = 1920, height = 1080, bitrate = 8_000_000), - videoFormat(width = 1280, height = 720, bitrate = 4_000_000), - videoFormat(width = 640, height = 360, bitrate = 1_000_000), - ) - val tracks = Tracks( - listOf( - Tracks.Group( - group, - true, - intArrayOf(C.FORMAT_HANDLED, C.FORMAT_HANDLED, C.FORMAT_HANDLED), - // 720p explicitly selected = an override is active. - booleanArrayOf(false, true, false), - ), - ), - ) - - val options = extractVideoQualityOptions(tracks) - - // Auto + three variants. - assertEquals(4, options.size) - assertEquals(VIDEO_QUALITY_AUTO_ID, options.first().id) - assertEquals("Auto", options.first().label) - assertTrue(options[1].label.contains("1080p")) - assertTrue(options[2].label.contains("720p")) - assertTrue(options[3].label.contains("360p")) - // The explicitly-selected variant (720p) is selected, not Auto. - assertEquals(options[2].id, options.first { it.isSelected }.id, "720p variant should be selected") - } - - @Test - fun videoQualityAutoSelectedWhenNoOverrideAndDisabledWhenSingleVariant() { - val adaptiveGroup = TrackGroup( - videoFormat(width = 1920, height = 1080, bitrate = 8_000_000), - videoFormat(width = 1280, height = 720, bitrate = 4_000_000), - ) - val adaptive = Tracks( - listOf( - Tracks.Group( - adaptiveGroup, - true, - intArrayOf(C.FORMAT_HANDLED, C.FORMAT_HANDLED), - // Adaptive: both selectable, none pinned as an override. - booleanArrayOf(true, true), - ), - ), - ) - val adaptiveOptions = extractVideoQualityOptions(adaptive) - // With more than one selected variant there is no single override, so - // Auto is the selected option. - assertTrue(adaptiveOptions.first().isSelected) - - // A single-variant group offers no genuine quality choice: Auto + one - // variant = size 2, so the HUD row disables (hasQualityChoice = size>2). - val singleGroup = TrackGroup(videoFormat(width = 1920, height = 1080, bitrate = 8_000_000)) - val single = Tracks( - listOf( - Tracks.Group( - singleGroup, - true, - intArrayOf(C.FORMAT_HANDLED), - booleanArrayOf(true), - ), - ), - ) - val singleOptions = extractVideoQualityOptions(single) - assertEquals(2, singleOptions.size) - assertTrue(singleOptions.first().isSelected) - } - private fun videoFormat(width: Int, height: Int, bitrate: Int): Format = Format.Builder() .setSampleMimeType(MimeTypes.VIDEO_H264) @@ -581,4 +488,18 @@ class PlayerTrackEntriesTest { .setSampleMimeType(MimeTypes.APPLICATION_SUBRIP) .setSelectionFlags(if (forced) C.SELECTION_FLAG_FORCED else 0) .build() + + /** Auto's pick over mounted tracks: the ranking `resolveTvAutoSubtitleIdentity` runs without a server inventory. */ + private fun autoPick( + subtitles: List<PlayerTrackEntry>, + audio: List<PlayerTrackEntry> = emptyList(), + ): Int? = resolveAutoSubtitle( + candidates = playerTrackAutoSubtitleCandidates(subtitles), + context = AutoSubtitleContext( + preferredLanguage = "en", + mode = "auto", + showForced = true, + audioLanguage = audio.firstOrNull { it.isSelected }?.language, + ), + ).selectedCandidate()?.selectionIndex } diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvAutoSubtitleFallbackTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvAutoSubtitleFallbackTest.kt index 0105608cef..22386cc27a 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvAutoSubtitleFallbackTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvAutoSubtitleFallbackTest.kt @@ -52,7 +52,9 @@ class TvAutoSubtitleFallbackTest { ) @Test - fun theFallbackPrefersAnUnmountedExternalTextTrackOverTheMountedBitmapOne() { + fun theFallbackPrefersAnUnmountedPlainExternalTrackOverTheMountedSdhOne() { + // The SRT wins because the mounted PGS track is SDH. Being a bitmap + // alone no longer demotes it: see the plain-PGS case below. val identity = resolveTvAutoSubtitleIdentity( audioTracks = emptyList(), subtitleTracks = mountedPgsOnly, @@ -65,6 +67,63 @@ class TvAutoSubtitleFallbackTest { assertEquals(tvSubtitleIdentity(sidecarRow), identity) } + @Test + fun theFallbackPrefersAPlainEmbeddedPgsTrackOverAnExternalSrt() { + // Android renders embedded PGS itself, so with nothing else to tell + // them apart the file's own track beats the sidecar. + val plainPgs = pgsRow.copy(label = "English", catalogLabel = "English") + val identity = resolveTvAutoSubtitleIdentity( + audioTracks = emptyList(), + subtitleTracks = mountedPgsOnly, + subtitleRows = listOf(sidecarRow, plainPgs), + preferredLanguage = "en", + subtitleMode = "always", + showForced = true, + ) + + assertEquals(tvSubtitleIdentity(plainPgs), identity) + } + + @Test + fun theFallbackPrefersAnEmbeddedTextTrackOverAnExternalOne() { + // The inventory lists externals first; source, not that order, decides. + val embeddedText = PlayerSubtitleInfo( + index = 1, + language = "eng", + codec = "subrip", + label = "English", + source = "embedded", + url = "https://prairie.example/stream/s1/subtitles/1.vtt", + catalogLabel = "English", + catalogSource = "embedded", + ) + val identity = resolveTvAutoSubtitleIdentity( + audioTracks = emptyList(), + subtitleTracks = emptyList(), + subtitleRows = listOf(sidecarRow, embeddedText), + preferredLanguage = "en", + subtitleMode = "always", + showForced = false, + ) + + assertEquals(tvSubtitleIdentity(embeddedText), identity) + } + + @Test + fun theFallbackPrefersAnExternalSrtOverAnEmbeddedTrackThatNeedsABurnIn() { + val vobSub = pgsRow.copy(codec = "dvd_subtitle", label = "English", catalogLabel = "English") + val identity = resolveTvAutoSubtitleIdentity( + audioTracks = emptyList(), + subtitleTracks = emptyList(), + subtitleRows = listOf(sidecarRow, vobSub), + preferredLanguage = "en", + subtitleMode = "always", + showForced = false, + ) + + assertEquals(tvSubtitleIdentity(sidecarRow), identity) + } + @Test fun theFallbackStillTakesTheBitmapTrackWhenItIsTheOnlyCandidate() { val identity = resolveTvAutoSubtitleIdentity( diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvEpisodeHandoffPlaybackStartTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvEpisodeHandoffPlaybackStartTest.kt index 3ac303df74..c7cc9485b9 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvEpisodeHandoffPlaybackStartTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvEpisodeHandoffPlaybackStartTest.kt @@ -15,42 +15,6 @@ import org.prairieserver.prairie.model.playback.ClientCodecCapabilities import org.prairieserver.prairie.playback.audioTrackFingerprint class TvEpisodeHandoffPlaybackStartTest { - @Test - fun manualTitleAudioChoiceWinsOverHandoffAndPlaybackSettings() { - assertEquals( - 2, - resolveTvStartAudioTrackIndex( - requestedTitleTrackIndex = 2, - episodeHandoffTrackIndex = 1, - automaticPreferenceTrackIndex = 0, - ), - ) - } - - @Test - fun playbackAudioSettingIsOnlyTheAutomaticFallback() { - assertEquals( - 0, - resolveTvStartAudioTrackIndex( - requestedTitleTrackIndex = null, - episodeHandoffTrackIndex = null, - automaticPreferenceTrackIndex = 0, - ), - ) - } - - @Test - fun episodeHandoffAudioWinsOverAutomaticPlaybackSetting() { - assertEquals( - 1, - resolveTvStartAudioTrackIndex( - requestedTitleTrackIndex = null, - episodeHandoffTrackIndex = 1, - automaticPreferenceTrackIndex = 0, - ), - ) - } - @Test fun explicitDetailFileIdWinsOverEpisodeHandoff() { val resolved = resolveTvPlaybackStartSelection( diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvHudPickerFocusWiringSourceTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvHudPickerFocusWiringSourceTest.kt index 3756fffdb4..4440c6f8a2 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvHudPickerFocusWiringSourceTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvHudPickerFocusWiringSourceTest.kt @@ -35,7 +35,7 @@ class TvHudPickerFocusWiringSourceTest { @Test fun pickerKeepsTheEagerFocusGraph() { assertContains(pickerDialog, "Column(") - assertContains(pickerDialog, ".verticalScroll(rememberScrollState())") + assertContains(pickerDialog, ".verticalScroll(") assertFalse(Regex("\\bLazyColumn\\s*\\(").containsMatchIn(pickerDialog.withoutComments())) } diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayNextSelectionHandoffTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayNextSelectionHandoffTest.kt index 52355a2ac1..b103b35a03 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayNextSelectionHandoffTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayNextSelectionHandoffTest.kt @@ -2,7 +2,6 @@ package org.prairieserver.prairie.tv.ui.screens.player import org.prairieserver.prairie.common.player.video.EpisodeSubtitleMode import org.prairieserver.prairie.common.player.video.ResolvedEpisodeSelection -import org.prairieserver.prairie.common.player.video.encodeEpisodeSelectionHandoff import org.prairieserver.prairie.model.catalog.FileVersion import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo import org.prairieserver.prairie.model.playback.SubtitleIdentity @@ -197,13 +196,6 @@ class TvPlayNextSelectionHandoffTest { assertEquals(true, handoff.subtitle.forced) assertEquals(true, handoff.subtitle.hearingImpaired) assertEquals(true, handoff.subtitle.external) - val encoded = encodeEpisodeSelectionHandoff(handoff) - assertTrue(encoded.contains("777").not(), "download identity is episode-local") - assertTrue(encoded.contains("download-track").not(), "Media3 identity is episode-local") - assertTrue(encoded.contains("fileId").not(), "file identity is episode-local") - assertTrue(encoded.contains("index").not(), "subtitle indexes are episode-local") - assertTrue(encoded.contains("source-42").not(), "file name and path are episode-local") - assertTrue(encoded.contains("example.test").not(), "subtitle URLs are episode-local") } @Test @@ -229,7 +221,6 @@ class TvPlayNextSelectionHandoffTest { assertEquals(false, handoff.subtitle.forced) assertEquals(true, handoff.subtitle.hearingImpaired) assertNull(handoff.subtitle.external) - assertTrue(encodeEpisodeSelectionHandoff(handoff).contains("media3-opaque-id").not()) } @Test diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlaybackQualityOptionsTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlaybackQualityOptionsTest.kt index db71bded22..13ef6c1ded 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlaybackQualityOptionsTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlaybackQualityOptionsTest.kt @@ -1,9 +1,9 @@ package org.prairieserver.prairie.tv.ui.screens.player import org.prairieserver.prairie.model.playback.PlaybackAvailableQualityV3 +import org.prairieserver.prairie.model.playback.PlaybackEffectiveRecipeV3 import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFalse import kotlin.test.assertTrue class TvPlaybackQualityOptionsTest { @@ -12,41 +12,76 @@ class TvPlaybackQualityOptionsTest { val options = authoritativePlaybackQualityOptions( listOf( PlaybackAvailableQualityV3("1080p-medium", displayName = "1080p Medium"), - PlaybackAvailableQualityV3("original", displayName = " "), + PlaybackAvailableQualityV3("original", displayName = " ", preservesSource = true), ), selectedLabel = "1080p-medium", ) - assertEquals(listOf("1080p-medium", "original"), options.map { it.id }) - assertEquals(listOf("1080p Medium", "original"), options.map { it.label }) - assertTrue(options.first().isSelected) + assertEquals(listOf("auto", "1080p-medium", "original"), options.map { it.id }) + assertEquals(listOf("Auto", "1080p Medium", "Original"), options.map { it.label }) + assertEquals(listOf("1080p-medium"), options.filter { it.isSelected }.map { it.id }) } @Test - fun authoritativeMenuPreservesServerMembershipOrderAndLabels() { + fun menuPutsAutoFirstThenServerEntriesWithBitrates() { val options = authoritativePlaybackQualityOptions( available = listOf( - PlaybackAvailableQualityV3("original", 1080, 8_000, preservesSource = true), - PlaybackAvailableQualityV3("720p", 720, 4_000), + PlaybackAvailableQualityV3("original", 2160, 50_700, preservesSource = true), + PlaybackAvailableQualityV3("1080p", 1080, 12_000, displayName = "1080p"), + PlaybackAvailableQualityV3("720p-low", 720, 1_500, displayName = "720p Low"), ), - selectedLabel = "720p", + selectedLabel = "1080p", ) - assertEquals(listOf("original", "720p"), options.map { it.id }) - assertEquals(listOf("original", "720p"), options.map { it.label }) - assertFalse(options.first().isSelected) - assertTrue(options.last().isSelected) + assertEquals(listOf("auto", "original", "1080p", "720p-low"), options.map { it.id }) + assertEquals(listOf("Auto", "Original", "1080p", "720p Low"), options.map { it.label }) + assertEquals(listOf(null, "50.7 Mbps", "12 Mbps", "1.5 Mbps"), options.map { it.bitrateLabel }) + assertEquals(listOf(null, "2160p", "1080p", "720p"), options.map { it.resolution }) + assertEquals(listOf("1080p"), options.filter { it.isSelected }.map { it.id }) } @Test - fun autoIntentDoesNotInventASelectedServerRow() { + fun autoIntentSelectsTheAutoRowNotAServerEntry() { val options = authoritativePlaybackQualityOptions( available = listOf( PlaybackAvailableQualityV3("original", 1080, 8_000, preservesSource = true), + PlaybackAvailableQualityV3("720p-medium", 720, 4_000), ), selectedLabel = "auto", ) - assertEquals(listOf("original"), options.map { it.id }) - assertFalse(options.single().isSelected) + assertEquals(listOf("auto"), options.filter { it.isSelected }.map { it.id }) + } + + @Test + fun storedResolutionPreferenceMarksTheEntryThePlannerUsed() { + val options = authoritativePlaybackQualityOptions( + available = listOf( + PlaybackAvailableQualityV3("original", 2160, 40_000, preservesSource = true), + PlaybackAvailableQualityV3("1080p-medium", 1080, 6_000), + PlaybackAvailableQualityV3("720p-medium", 720, 4_000), + ), + selectedLabel = "1080p", + delivered = PlaybackEffectiveRecipeV3(width = 1920, height = 1080, bitrateKbps = 6_000), + ) + + assertEquals(listOf("1080p-medium"), options.filter { it.isSelected }.map { it.id }) + } + + @Test + fun aSingleEntryListsAutoAndOriginal() { + val available = listOf(PlaybackAvailableQualityV3("original", 1080, 8_000, preservesSource = true)) + + val auto = authoritativePlaybackQualityOptions(available, selectedLabel = "auto") + assertEquals(listOf("auto", "original"), auto.map { it.id }) + assertEquals(listOf("Auto", "Original"), auto.map { it.label }) + assertEquals(listOf("auto"), auto.filter { it.isSelected }.map { it.id }) + + val pinned = authoritativePlaybackQualityOptions(available, selectedLabel = "720p-medium") + assertEquals(listOf("original"), pinned.filter { it.isSelected }.map { it.id }) + } + + @Test + fun noPlanEntriesMeansNoMenu() { + assertTrue(authoritativePlaybackQualityOptions(emptyList(), selectedLabel = "auto").isEmpty()) } } diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerSubtitleIntegrationPolicyTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerSubtitleIntegrationPolicyTest.kt index ea7638777b..eb2f1f9289 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerSubtitleIntegrationPolicyTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerSubtitleIntegrationPolicyTest.kt @@ -302,7 +302,7 @@ class TvPlayerSubtitleIntegrationPolicyTest { val row = embeddedPgsRow() val track = embeddedPgsTrack() - val selection = resolveAutoSubtitleSelection( + val identity = resolveTvAutoSubtitleIdentity( audioTracks = listOf( PlayerTrackEntry( index = 0, @@ -312,6 +312,7 @@ class TvPlayerSubtitleIntegrationPolicyTest { ), ), subtitleTracks = listOf(track), + subtitleRows = listOf(row), preferredLanguage = "en", subtitleMode = "always", showForced = true, @@ -319,12 +320,9 @@ class TvPlayerSubtitleIntegrationPolicyTest { // Bitmap tracks stay deprioritised-but-allowed: it is the only English // candidate, so Always must still pick it. - val selected = assertIs<SubtitleAutoSelection.Select>(selection) - assertEquals(track.index, selected.index) - - val identity = tvMountedSubtitleIdentity(track, listOf(track), listOf(row)) assertEquals(tvSubtitleIdentity(row), identity) assertIs<SubtitleIdentity.Embedded>(identity) + assertEquals(identity, tvMountedSubtitleIdentity(track, listOf(track), listOf(row))) // The identity the auto path commits is the identity the HUD ticks. val presentation = buildTvSubtitleHudPresentation( diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvRoomDeliveryKeySourceTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvRoomDeliveryKeySourceTest.kt deleted file mode 100644 index a5681b2ea8..0000000000 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvRoomDeliveryKeySourceTest.kt +++ /dev/null @@ -1,47 +0,0 @@ -package org.prairieserver.prairie.tv.ui.screens.player - -import java.io.File -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertFailsWith -import kotlin.test.assertTrue - -class TvRoomDeliveryKeySourceTest { - private val reportingStartAnchor = "// Drift reporting loop" - private val reportingEndAnchor = "// ready / buffering during the waiting barrier." - - private val controllerSource = File( - "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvRoomSyncController.kt", - ).readText() - - @Test - fun stateReportRequiresAnExplicitNonNullDeliveryKey() { - val reportingBlock = reportingBlock(controllerSource) - val guardIndex = reportingBlock.indexOf("deliveryKey != null") - val stateReportIndex = reportingBlock.indexOf("repository.stateReport(") - - assertFalse(reportingBlock.contains("deliveryKey!!")) - assertTrue(guardIndex >= 0, "Drift reporting block must guard deliveryKey explicitly.") - assertTrue(stateReportIndex >= 0, "Drift reporting block must report state.") - assertTrue(guardIndex < stateReportIndex) - } - - @Test - fun reportingBlockFailsClosedWhenEitherAnchorIsMissing() { - assertFailsWith<AssertionError> { - reportingBlock(controllerSource.replace(reportingStartAnchor, "")) - } - assertFailsWith<AssertionError> { - reportingBlock(controllerSource.replace(reportingEndAnchor, "")) - } - } - - private fun reportingBlock(source: String): String { - val startAnchorIndex = source.indexOf(reportingStartAnchor) - assertTrue(startAnchorIndex >= 0, "Missing drift-reporting start anchor.") - val blockStart = startAnchorIndex + reportingStartAnchor.length - val endAnchorIndex = source.indexOf(reportingEndAnchor, startIndex = blockStart) - assertTrue(endAnchorIndex >= blockStart, "Missing drift-reporting end anchor.") - return source.substring(blockStart, endAnchorIndex) - } -} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvRoomTransportGateTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvRoomTransportGateTest.kt deleted file mode 100644 index 1ba0bd4c28..0000000000 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvRoomTransportGateTest.kt +++ /dev/null @@ -1,67 +0,0 @@ -package org.prairieserver.prairie.tv.ui.screens.player - -import org.prairieserver.prairie.model.watchtogether.GuestControlPolicy -import org.prairieserver.prairie.model.watchtogether.MemberRole -import org.prairieserver.prairie.model.watchtogether.RoomPhase -import org.prairieserver.prairie.model.watchtogether.RoomSnapshot -import kotlin.test.Test -import kotlin.test.assertEquals - -/** - * Pure transport-authority gate tests. The gate decides whether a local - * transport intent (play/pause vs seek) is allowed to be broadcast as a - * `transport_request`. It now delegates to the shared `roomTransportAuthorized` - * (the single source of truth mirroring prairie-server): seek is host-only, - * play/pause follows `self_can_control_transport`, nothing outside Playing. - * - * Fixtures mirror server truth: `self_can_control_transport` = isHost OR - * (Playing && guest_play_pause), so a guest_play_pause guest in the Playing - * phase has canControl=true (and is still blocked from seek by role). - */ -class TvRoomTransportGateTest { - - private fun fixture( - policy: GuestControlPolicy, - role: MemberRole, - canControl: Boolean, - phase: RoomPhase = RoomPhase.Playing, - ): RoomSnapshot = RoomSnapshot( - roomId = "room-1", - phase = phase, - guestControlPolicy = policy, - selfRole = role, - selfCanControlTransport = canControl, - ) - - @Test - fun hostMayPlayPauseAndSeek() { - val s = fixture(GuestControlPolicy.HostOnly, MemberRole.Host, canControl = true) - assertEquals(TransportGate.Send, tvRoomTransportGate(s, TvTransportIntent.PlayPause)) - assertEquals(TransportGate.Send, tvRoomTransportGate(s, TvTransportIntent.Seek)) - } - - @Test - fun guestHostOnlyBlocked() { - val s = fixture(GuestControlPolicy.HostOnly, MemberRole.Guest, canControl = false) - assertEquals(TransportGate.Blocked, tvRoomTransportGate(s, TvTransportIntent.PlayPause)) - assertEquals(TransportGate.Blocked, tvRoomTransportGate(s, TvTransportIntent.Seek)) - } - - @Test - fun guestPlayPausePolicy() { - val s = fixture(GuestControlPolicy.GuestPlayPause, MemberRole.Guest, canControl = true) - assertEquals(TransportGate.Send, tvRoomTransportGate(s, TvTransportIntent.PlayPause)) - assertEquals(TransportGate.Blocked, tvRoomTransportGate(s, TvTransportIntent.Seek)) - } - - @Test - fun noTransportOutsidePlaying() { - val s = fixture(GuestControlPolicy.GuestPlayPause, MemberRole.Host, canControl = true, phase = RoomPhase.Lobby) - assertEquals(TransportGate.Blocked, tvRoomTransportGate(s, TvTransportIntent.PlayPause)) - } - - @Test - fun nullBlocked() { - assertEquals(TransportGate.Blocked, tvRoomTransportGate(null, TvTransportIntent.Seek)) - } -} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvStartupMetadataOwnerTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvStartupMetadataOwnerTest.kt index 27b5c6bd9d..5db228de90 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvStartupMetadataOwnerTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvStartupMetadataOwnerTest.kt @@ -99,9 +99,13 @@ class TvStartupMetadataOwnerTest { fun field(name: String): Any? = PlaybackSessionLifecycle::class.java.getDeclaredField(name).let { it.isAccessible = true; it.get(lifecycle) } + val startupCoroutine = CoroutineName("tv-startup-metadata-owner-test") var held = false tokens.beforeSnapshot = { - if (stage in listOf("final", "cancel", "newer", "predecessor") && !held && field("lastAdoptedSessionId") == "allocated") { + // Route telemetry also snapshots tokens; pause only the startup transaction. + if (currentCoroutineContext()[CoroutineName] === startupCoroutine && + stage in listOf("final", "cancel", "newer", "predecessor") && + !held && field("lastAdoptedSessionId") == "allocated") { held = true; entered.complete(Unit); release.await() } } @@ -130,7 +134,7 @@ class TvStartupMetadataOwnerTest { FakePlayerSettingsStore(), lifecycle, ServerReachabilityMonitor(ApiV2Probe(client)::probeFresh, backgroundScope, { null }), localState, ) - val start = async { starter.start(VideoPlaybackStartRequest(contentId = "item", preferredFileId = 41, roomId = null, resumePositionOverride = null)) } + val start = async(startupCoroutine) { starter.start(VideoPlaybackStartRequest(contentId = "item", preferredFileId = 41, roomId = null, resumePositionOverride = null)) } if (stage in listOf("final", "cancel", "newer", "predecessor")) { entered.await() val active = lifecycle.state.value as SessionState.Active diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleHudStateTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleHudStateTest.kt index a506a6231c..121e76c3dd 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleHudStateTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleHudStateTest.kt @@ -9,6 +9,18 @@ import kotlin.test.assertNotEquals import kotlin.test.assertTrue class TvSubtitleHudStateTest { + @Test + fun `sync status resolves server tracks by ordinal and merged stored rows by download id`() { + val sync = TvHudSubtitleSync( + statusByServerIndex = mapOf(0 to "Synced −3.0 s"), + statusByDownloadId = mapOf(9 to "Syncing… 40%"), + ) + assertEquals("Synced −3.0 s", sync.statusFor(SubtitleIdentity.ServerSidecar(0))) + assertEquals("Syncing… 40%", sync.statusFor(SubtitleIdentity.Downloaded(9, SubtitleMediaIdentity()))) + assertEquals(null, sync.statusFor(SubtitleIdentity.ServerSidecar(1))) + assertEquals(null, sync.statusFor(SubtitleIdentity.Off)) + } + @Test fun `pending HUD state keeps committed row checked and labels pending row Applying`() { val presentation = present( diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleLanguageOptionsTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleLanguageOptionsTest.kt new file mode 100644 index 0000000000..df61909e08 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleLanguageOptionsTest.kt @@ -0,0 +1,57 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import org.prairieserver.prairie.common.ui.LanguageNames +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TvSubtitleLanguageOptionsTest { + + @Test + fun tvOffersTheSharedVocabulary() { + assertEquals(LanguageNames.dropdownOptions.map { it.first }, TvSubtitleLanguageOptions) + } + + @Test + fun everySupportedLanguageSelectsItself() { + for ((code, _) in LanguageNames.dropdownOptions) { + assertEquals(code, TvSubtitleLanguageOptions[tvSubtitleLanguageIndex(code)]) + } + } + + @Test + fun croatianPreferenceSelectsCroatian() { + val index = tvSubtitleLanguageIndex("hr") + assertEquals("hr", TvSubtitleLanguageOptions[index]) + assertEquals("Croatian", tvLanguageDisplayName(TvSubtitleLanguageOptions[index])) + assertEquals("hr", TvSubtitleLanguageOptions[tvSubtitleLanguageIndex("hrv")]) + } + + @Test + fun threeLetterCodesNormalize() { + assertEquals("de", TvSubtitleLanguageOptions[tvSubtitleLanguageIndex("ger")]) + } + + @Test + fun unsupportedOrMissingCodesFallBackToEnglish() { + assertEquals("en", TvSubtitleLanguageOptions[tvSubtitleLanguageIndex("zz")]) + assertEquals("en", TvSubtitleLanguageOptions[tvSubtitleLanguageIndex(null)]) + } + + @Test + fun sameLanguageMatchesAcrossCodeLengths() { + assertTrue(tvIsSameLanguage("bul", "bg")) + assertTrue(tvIsSameLanguage("hrv", "hr")) + assertTrue(tvIsSameLanguage("eng", "en")) + assertTrue(tvIsSameLanguage("DE", "de")) + assertFalse(tvIsSameLanguage("ger", "fr")) + } + + @Test + fun unknownSourceIsNotEnglish() { + assertFalse(tvIsSameLanguage("xyz", "en")) + assertFalse(tvIsSameLanguage(null, "en")) + assertFalse(tvIsSameLanguage(" ", "en")) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleSettlementOwnershipTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleSettlementOwnershipTest.kt index 8d9ee7a94f..5b8f35b95e 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleSettlementOwnershipTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleSettlementOwnershipTest.kt @@ -785,27 +785,14 @@ class TvSubtitleSettlementOwnershipTest { } @Test - fun `real exit and clear wire settlement before invalidation and lifecycle stop`() { + fun `clear wires settlement before invalidation and lifecycle stop`() { val source = File( "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerViewModel.kt", ).readText() - val exitBody = source - .substringAfter("suspend fun stopSessionForExit()") - .substringBefore("fun onExit()") val clearBody = source .substringAfter("override fun onCleared()") .substringBefore("\n }\n\n}") - assertBefore( - exitBody, - "subtitleTransactions.invalidateAndAwaitSettlement()", - "playbackMutationFence.invalidateAll()", - ) - assertBefore( - exitBody, - "subtitleTransactions.invalidateAndAwaitSettlement()", - "lifecycleTeardown.stopOrdered(", - ) // Every exit path runs prepareSessionExit, which blanks uiState.sessionId. // If the id is not latched BEFORE that write, each of the three stops // below passes null and the ownership guard they exist for never engages @@ -818,7 +805,6 @@ class TvSubtitleSettlementOwnershipTest { "lastAdoptedSessionId = it", "sessionId = null", ) - assertTrue(exitBody.contains("lifecycleTeardown.stopOrdered(expectedSessionId = exitSessionId)")) assertBefore( clearBody, "subtitleTransactions.reserveDurableFinalPersistence()", @@ -846,10 +832,6 @@ class TvSubtitleSettlementOwnershipTest { // and supersedes it, which is how auto-advance broke. assertTrue(clearBody.contains("lifecycleTeardown.stopDetached(expectedSessionId")) assertFalse(clearBody.contains("sessionLifecycle.stop")) - val adoptionBody = source - .substringAfter("private suspend fun adoptSubtitlePlayback(") - .substringBefore("private suspend fun confirmSubtitlePlaybackPublication(") - assertFalse(adoptionBody.contains("rollbackUnpublishedActiveSession(")) } private suspend fun TestScope.mountUnpublished( diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvWatchPartyPlayerTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvWatchPartyPlayerTest.kt new file mode 100644 index 0000000000..f828ff73e6 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvWatchPartyPlayerTest.kt @@ -0,0 +1,250 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import androidx.media3.common.Player +import org.prairieserver.prairie.common.player.video.markerSkipTarget +import org.prairieserver.prairie.model.catalog.TimeRange +import org.prairieserver.prairie.watchtogether.RoomPlayerState +import org.prairieserver.prairie.watchtogether.WatchPartyPlaybackContext +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class TvWatchPartyPlayerTest { + @Test + fun `visible quality offer owns select throughout recap and credits`() { + val recap = TimeRange(80.0, 160.0) + val credits = TimeRange(160.0, 240.0) + for (position in listOf(100.0, 200.0)) { + val target = markerSkipTarget(position, recap, credits) + assertTrue(target != null) + assertNull(tvManualMarkerSkipTarget(target, true, false, false, true)) + // Opening controls hides the quality offer and restores the focused + // marker button; dismissing it restores clean-playback marker OK. + assertEquals(target, tvManualMarkerSkipTarget(target, true, false, false, false)) + assertNull(tvManualMarkerSkipTarget(target, false, false, false, false)) + assertNull(tvManualMarkerSkipTarget(target, true, true, false, false)) + assertNull(tvManualMarkerSkipTarget(target, true, false, true, false)) + } + } + + private val room = WatchPartyPlaybackContext( + roomId = "room-1", + selectionRevision = 7L, + contentId = "movie-1", + fileId = 42, + libraryId = 3, + positionSeconds = 120.0, + paused = true, + ) + + private val viewModelSource = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerViewModel.kt", + ).readText() + + // ---- Room start and restarts ---------------------------------------------- + + @Test + fun `a first room start uses the room position and file`() { + val start = tvRoomStartContext( + context = room, + startPositionOverride = room.positionSeconds, + currentSourcePositionSeconds = 0.0, + preparedRevision = -1L, + ) + + assertEquals(room, start) + } + + @Test + fun `a restart of the prepared revision resumes where the player was, on the same file`() { + val start = tvRoomStartContext( + context = room, + startPositionOverride = null, + currentSourcePositionSeconds = 845.5, + preparedRevision = room.selectionRevision, + ) + + assertEquals(845.5, start?.positionSeconds) + assertEquals(room.fileId, start?.fileId) + assertEquals(room.selectionRevision, start?.selectionRevision) + } + + @Test + fun `a restart before the revision was prepared keeps the room position`() { + val start = tvRoomStartContext( + context = room, + startPositionOverride = null, + currentSourcePositionSeconds = 845.5, + preparedRevision = room.selectionRevision - 1, + ) + + assertEquals(room.positionSeconds, start?.positionSeconds) + } + + @Test + fun `a retry position from the previous title never carries into a new revision`() { + // Retry passes the player's last position, which still belongs to the + // previous revision until the new session publishes. + val start = tvRoomStartContext( + context = room, + startPositionOverride = 1_800.0, + currentSourcePositionSeconds = 1_800.0, + preparedRevision = room.selectionRevision - 1, + ) + + assertEquals(room, start) + } + + @Test + fun `an explicit restart position wins and an invalid one falls back`() { + assertEquals( + 300.0, + tvRoomStartContext(room, 300.0, 845.5, room.selectionRevision)?.positionSeconds, + ) + assertEquals( + 845.5, + tvRoomStartContext(room, Double.NaN, 845.5, room.selectionRevision)?.positionSeconds, + ) + } + + @Test + fun `without a room context there is nothing to start`() { + assertNull(tvRoomStartContext(null, 10.0, 0.0, -1L)) + } + + @Test + fun `a room session is published paused and solo playback is not`() { + assertTrue(tvPausedWhenReady(inWatchParty = true)) + assertFalse(tvPausedWhenReady(inWatchParty = false)) + } + + @Test + fun `every room load reuses the room context and publishes paused`() { + val load = viewModelSource + .substringAfter("private fun loadContent(") + .substringBefore("private fun startIntroAutoSkipObserver") + + assertTrue("tvRoomStartContext(" in load, "loadContent must build its start from the room context") + assertTrue( + "if (roomId != null && requestRoom == null) return" in load, + "a room player must never start without the room's context", + ) + assertTrue("room = requestRoom," in load, "the start request must carry the room context") + assertTrue("isPaused = tvPausedWhenReady(inWatchParty = requestRoom != null)" in load) + } + + @Test + fun `a room player waits for the room instead of starting the route solo`() { + val init = viewModelSource + .substringAfter("sleepTimer.configure {") + .substringBefore("fun onBackendCapabilities(") + val roomBranch = init.indexOf("if (roomId != null) {") + val soloLoad = init.indexOf("loadContent(startPositionOverride = resumePositionOverride)") + + assertTrue(roomBranch >= 0 && soloLoad > roomBranch, "the solo auto-load must sit behind the room check") + } + + @Test + fun `version and dolby vision restarts are refused in a room`() { + val version = viewModelSource + .substringAfter("fun onSelectFileVersion(") + .substringBefore("private fun restartSessionInPlace(") + val restart = viewModelSource + .substringAfter("private fun restartSessionInPlace(") + .substringBefore("fun retryServerReachability(") + val dolbyVision = viewModelSource + .substringAfter("fun onSetDolbyVisionEnabled(") + .substringBefore("restartSessionInPlace(fileId)") + + assertTrue("if (roomId != null) return" in version) + assertTrue("if (roomId != null) return" in restart) + assertTrue("if (roomId != null) return@launch" in dolbyVision) + } + + // ---- Speed --------------------------------------------------------------- + + @Test + fun `a PrairieCast speed change is refused in a room`() { + assertNull(tvPrairieCastPlaybackSpeed(inWatchParty = true, requested = 1.5)) + assertEquals(1.5, tvPrairieCastPlaybackSpeed(inWatchParty = false, requested = 1.5)) + assertNull(tvPrairieCastPlaybackSpeed(inWatchParty = false, requested = 0.0)) + assertNull(tvPrairieCastPlaybackSpeed(inWatchParty = false, requested = Double.NaN)) + } + + @Test + fun `the saved speed preference is never written from a room`() { + val setter = viewModelSource + .substringAfter("fun onSetPlaybackSpeed(value: Double) {") + .substringBefore("fun onSetIntroSkipMode(") + val guard = setter.indexOf("if (roomId != null) return") + val write = setter.indexOf("setPlaybackSpeed(value)") + + assertTrue(guard >= 0 && write > guard, "the room guard must precede the preference write") + } + + // ---- Buffered positions ------------------------------------------------------ + + @Test + fun `a target inside the buffered window of the mounted item is buffered`() { + assertTrue(tvRoomPositionBuffered(targetPlayerSeconds = 105.0, currentPlayerSeconds = 100.0, bufferedPlayerSeconds = 130.0)) + assertTrue(tvRoomPositionBuffered(targetPlayerSeconds = 99.2, currentPlayerSeconds = 100.0, bufferedPlayerSeconds = 130.0)) + assertTrue(tvRoomPositionBuffered(targetPlayerSeconds = 130.0, currentPlayerSeconds = 100.0, bufferedPlayerSeconds = 130.0)) + } + + @Test + fun `targets behind the playhead or past the buffer need new media`() { + assertFalse(tvRoomPositionBuffered(targetPlayerSeconds = 98.5, currentPlayerSeconds = 100.0, bufferedPlayerSeconds = 130.0)) + assertFalse(tvRoomPositionBuffered(targetPlayerSeconds = 131.0, currentPlayerSeconds = 100.0, bufferedPlayerSeconds = 130.0)) + } + + @Test + fun `inconsistent or unknown positions are never buffered`() { + assertFalse(tvRoomPositionBuffered(targetPlayerSeconds = 100.0, currentPlayerSeconds = 100.0, bufferedPlayerSeconds = 90.0)) + assertFalse(tvRoomPositionBuffered(targetPlayerSeconds = Double.NaN, currentPlayerSeconds = 100.0, bufferedPlayerSeconds = 130.0)) + assertFalse(tvRoomPositionBuffered(targetPlayerSeconds = 100.0, currentPlayerSeconds = Double.NaN, bufferedPlayerSeconds = 130.0)) + } + + // ---- Player state, notices, quality --------------------------------------- + + @Test + fun `media3 states map to room player states`() { + assertEquals(RoomPlayerState.Buffering, tvRoomPlayerState(Player.STATE_BUFFERING, hasMedia = true)) + assertEquals(RoomPlayerState.Ready, tvRoomPlayerState(Player.STATE_READY, hasMedia = true)) + assertEquals(RoomPlayerState.Ended, tvRoomPlayerState(Player.STATE_ENDED, hasMedia = true)) + assertEquals(RoomPlayerState.Idle, tvRoomPlayerState(Player.STATE_IDLE, hasMedia = true)) + assertEquals(RoomPlayerState.Idle, tvRoomPlayerState(Player.STATE_READY, hasMedia = false)) + assertEquals(RoomPlayerState.Idle, tvRoomPlayerState(null, hasMedia = true)) + } + + @Test + fun `the lower quality offer is one rung below the current height`() { + val ladder = listOf( + VideoQualityOption(id = "auto", label = "Auto", isSelected = false), + VideoQualityOption(id = "original", label = "Original", isSelected = true, resolution = "1080p"), + VideoQualityOption(id = "1080p", label = "1080p", isSelected = false, resolution = "1080p"), + VideoQualityOption(id = "720p", label = "720p", isSelected = false, resolution = "720p"), + VideoQualityOption(id = "480p", label = "480p", isSelected = false, resolution = "480p"), + ) + + assertEquals("720p", tvLowerQualityOption(ladder, fallbackCurrentHeight = null)?.id) + val onSeven = ladder.map { it.copy(isSelected = it.id == "720p") } + assertEquals("480p", tvLowerQualityOption(onSeven, fallbackCurrentHeight = null)?.id) + val onBottom = ladder.map { it.copy(isSelected = it.id == "480p") } + assertNull(tvLowerQualityOption(onBottom, fallbackCurrentHeight = null), "no rung below: no offer") + } + + @Test + fun `auto uses the playing resolution to find the next rung`() { + val ladder = listOf( + VideoQualityOption(id = "auto", label = "Auto", isSelected = true), + VideoQualityOption(id = "1080p", label = "1080p", isSelected = false, resolution = "1080p"), + VideoQualityOption(id = "720p", label = "720p", isSelected = false, resolution = "720p"), + ) + + assertEquals("720p", tvLowerQualityOption(ladder, fallbackCurrentHeight = tvQualityHeight("1080p"))?.id) + assertEquals("1080p", tvLowerQualityOption(ladder, fallbackCurrentHeight = tvQualityHeight("4K"))?.id) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/InMemoryPrefs.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/InMemoryPrefs.kt new file mode 100644 index 0000000000..a226e7441b --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/InMemoryPrefs.kt @@ -0,0 +1,58 @@ +package org.prairieserver.prairie.tv.ui.screens.profiles + +import android.content.SharedPreferences + +/** Just enough SharedPreferences for the store: strings, longs and booleans. */ +internal class InMemoryPrefs : SharedPreferences { + private val values = mutableMapOf<String, Any?>() + + override fun getAll(): MutableMap<String, *> = values.toMutableMap() + override fun getString(key: String?, defValue: String?): String? = values[key] as String? ?: defValue + override fun getStringSet(key: String?, defValues: MutableSet<String>?): MutableSet<String>? = defValues + override fun getInt(key: String?, defValue: Int): Int = defValue + override fun getLong(key: String?, defValue: Long): Long = values[key] as Long? ?: defValue + override fun getFloat(key: String?, defValue: Float): Float = defValue + override fun getBoolean(key: String?, defValue: Boolean): Boolean = values[key] as Boolean? ?: defValue + override fun contains(key: String?): Boolean = key in values + override fun edit(): SharedPreferences.Editor = Editor() + override fun registerOnSharedPreferenceChangeListener(l: SharedPreferences.OnSharedPreferenceChangeListener?) {} + override fun unregisterOnSharedPreferenceChangeListener(l: SharedPreferences.OnSharedPreferenceChangeListener?) {} + + private inner class Editor : SharedPreferences.Editor { + private val pending = mutableMapOf<String, Any?>() + private val removed = mutableSetOf<String>() + + override fun putString(key: String, value: String?): SharedPreferences.Editor { + pending[key] = value + return this + } + + override fun putLong(key: String, value: Long): SharedPreferences.Editor { + pending[key] = value + return this + } + + override fun remove(key: String): SharedPreferences.Editor { + removed += key + return this + } + + override fun putStringSet(key: String, values: MutableSet<String>?): SharedPreferences.Editor = this + override fun putInt(key: String, value: Int): SharedPreferences.Editor = this + override fun putFloat(key: String, value: Float): SharedPreferences.Editor = this + override fun putBoolean(key: String, value: Boolean): SharedPreferences.Editor { + pending[key] = value + return this + } + override fun clear(): SharedPreferences.Editor = this + override fun commit(): Boolean { + apply() + return true + } + + override fun apply() { + removed.forEach(values::remove) + values.putAll(pending) + } + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvProfileRememberOfferTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvProfileRememberOfferTest.kt new file mode 100644 index 0000000000..563b35d4db --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvProfileRememberOfferTest.kt @@ -0,0 +1,155 @@ +package org.prairieserver.prairie.tv.ui.screens.profiles + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.prairieserver.prairie.model.profile.Profile +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.TokenManager +import org.prairieserver.prairie.network.TokenManagerImpl +import org.prairieserver.prairie.network.api.ProfileApi +import org.prairieserver.prairie.network.apiv2.ApiV2Gate +import org.prairieserver.prairie.repository.ProfileRepository +import org.prairieserver.prairie.common.profiles.ProfileLaunchBehavior +import org.prairieserver.prairie.common.profiles.ProfileLaunchPreferences +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class TvProfileRememberOfferTest { + private val profile = Profile(id = "profile-a", name = "Ana") + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `an offer for a profile cleared while it was open neither saves nor opens Home`() = runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val barrier = DefaultIdentityTransitionBarrier() + val repository = OneProfileRepository(barrier, profile) + val preferences = ProfileLaunchPreferences(InMemoryPrefs()).apply { + settleFirstLaunch(hasRememberedProfile = false) + } + val viewModel = TvProfileSelectionViewModel( + repository, + consumeSkipsSingleProfile = { false }, + launchPreferences = preferences, + ) + advanceUntilIdle() + + viewModel.onProfileSelected(profile) + advanceUntilIdle() + assertEquals(profile, viewModel.uiState.value.rememberOffer) + + // What profile-verification recovery does while the dialog is up. + repository.clearProfile() + viewModel.answerRememberOffer(remember = true) + advanceUntilIdle() + + assertNull(viewModel.uiState.value.selectedProfileId) + assertNull(viewModel.uiState.value.rememberOffer) + assertTrue(preferences.isRememberOfferPending) + assertEquals(ProfileLaunchBehavior.EveryTime, preferences.state.value.behavior) + } + + @Test + fun `answering the offer for the active profile saves it and opens Home`() = runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val barrier = DefaultIdentityTransitionBarrier() + val repository = OneProfileRepository(barrier, profile) + val preferences = ProfileLaunchPreferences(InMemoryPrefs()).apply { + settleFirstLaunch(hasRememberedProfile = false) + } + val viewModel = TvProfileSelectionViewModel( + repository, + consumeSkipsSingleProfile = { false }, + launchPreferences = preferences, + ) + advanceUntilIdle() + + viewModel.onProfileSelected(profile) + viewModel.answerRememberOffer(remember = true) + advanceUntilIdle() + + assertEquals(profile.id, viewModel.uiState.value.selectedProfileId) + assertEquals(ProfileLaunchBehavior.Automatic, preferences.state.value.behavior) + } + + @Test + fun `a reload outdated by the offer's own commit keeps the offer`() = runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val barrier = DefaultIdentityTransitionBarrier() + val reloadInFlight = CompletableDeferred<Unit>() + val repository = OneProfileRepository(barrier, profile) { call -> + if (call == 2) reloadInFlight.await() + } + val preferences = ProfileLaunchPreferences(InMemoryPrefs()).apply { + settleFirstLaunch(hasRememberedProfile = false) + } + val viewModel = TvProfileSelectionViewModel( + repository, + consumeSkipsSingleProfile = { false }, + launchPreferences = preferences, + ) + advanceUntilIdle() + + // The viewer picks while a reload is in flight; the pick's commit + // moves the identity that reload was fetched under. + viewModel.loadProfiles() + viewModel.onProfileSelected(profile) + advanceUntilIdle() + assertEquals(profile, viewModel.uiState.value.rememberOffer) + reloadInFlight.complete(Unit) + advanceUntilIdle() + assertEquals(profile, viewModel.uiState.value.rememberOffer) + + viewModel.answerRememberOffer(remember = true) + advanceUntilIdle() + assertEquals(profile.id, viewModel.uiState.value.selectedProfileId) + } +} + +private class OneProfileRepository( + barrier: DefaultIdentityTransitionBarrier, + private val profile: Profile, + private val beforeList: suspend (Int) -> Unit = {}, +) : ProfileRepository( + profileApi = ProfileApi(HttpClient(MockEngine { respond("{}") }), ApiV2Gate.Unrestricted), + tokenManager = ScopedTokenManager(barrier), + identityTransitions = barrier, +) { + private var calls = 0 + + override suspend fun listProfiles(): ApiResult<List<Profile>> { + beforeList(++calls) + return ApiResult.Success(listOf(profile)) + } +} + +/** Reports the barrier generation as its scope, so identity changes are visible. */ +private class ScopedTokenManager( + private val barrier: DefaultIdentityTransitionBarrier, +) : TokenManager by TokenManagerImpl(barrier) { + override suspend fun snapshotCurrentScope(): AuthScopeSnapshot = AuthScopeSnapshot( + serverId = "server-a", + profileId = null, + serverUrl = "https://server-a", + profileToken = null, + identityGeneration = barrier.generation.value, + ) +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/search/TvSearchAccessChangeTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/search/TvSearchAccessChangeTest.kt new file mode 100644 index 0000000000..82d4cc3061 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/search/TvSearchAccessChangeTest.kt @@ -0,0 +1,92 @@ +package org.prairieserver.prairie.tv.ui.screens.search + +import android.app.Application +import androidx.lifecycle.viewModelScope +import androidx.test.core.app.ApplicationProvider +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.prairieserver.prairie.network.PrairieJson +import org.prairieserver.prairie.network.TokenManagerImpl +import org.prairieserver.prairie.network.api.CatalogApi +import org.prairieserver.prairie.network.api.PersonalDataApi +import org.prairieserver.prairie.repository.CatalogRepository +import org.prairieserver.prairie.repository.PersonalDataRepository +import org.prairieserver.prairie.tv.data.preferences.TvLibraryScopeStore +import kotlin.test.Test +import kotlin.test.assertEquals + +/** An access change reloads the media-type chips; the first load must not land after it. */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class) +class TvSearchAccessChangeTest { + + @Test + fun `the first library load does not overwrite the access change reload`() = runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val firstLoad = CompletableDeferred<Unit>() + val requests = AtomicInteger() + val client = HttpClient( + MockEngine { request -> + check(request.url.encodedPath == "/api/v2/user/libraries") { "Unexpected ${request.url}" } + // Before the change the viewer only had an ebook library; after it, movies too. + val body = if (requests.incrementAndGet() == 1) { + firstLoad.await() + """{"items":[{"id":"1","name":"Books","type":"ebooks","sort_order":0}],"page":{"has_more":false}}""" + } else { + """{"items":[{"id":"2","name":"Movies","type":"movies","sort_order":0}],"page":{"has_more":false}}""" + } + respond(body, HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + }, + ) { install(ContentNegotiation) { json(PrairieJson) } } + val tokens = TokenManagerImpl() + val viewModel = TvSearchViewModel( + catalogRepository = CatalogRepository(CatalogApi(client)), + personalDataRepository = PersonalDataRepository(PersonalDataApi(client)), + libraryScopeStore = TvLibraryScopeStore(ApplicationProvider.getApplicationContext(), tokens), + ) + try { + awaitState { requests.get() == 1 } + viewModel.refreshForAccessChange() + awaitState { TvSearchMediaType.Movies in viewModel.uiState.value.availableMediaTypes } + firstLoad.complete(Unit) + withContext(Dispatchers.IO) { delay(200) } + + assertEquals( + listOf(TvSearchMediaType.All, TvSearchMediaType.Movies, TvSearchMediaType.Series), + viewModel.uiState.value.availableMediaTypes, + ) + } finally { + viewModel.viewModelScope.coroutineContext[Job]?.cancelAndJoin() + Dispatchers.resetMain() + } + } + + private suspend fun awaitState(predicate: () -> Boolean) { + withContext(Dispatchers.IO) { + withTimeout(30_000) { while (!predicate()) delay(10) } + } + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvSettingsCategoryTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvSettingsCategoryTest.kt index 5489ebfcae..8583b8c406 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvSettingsCategoryTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvSettingsCategoryTest.kt @@ -18,7 +18,6 @@ class TvSettingsCategoryTest { ), TvSettingsCategory.entries, ) - assertEquals("SUPPORT", TvSettingsCategory.Diagnostics.eyebrow) } @Test diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvJoinCodeStateTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchparty/TvJoinCodeStateTest.kt similarity index 59% rename from androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvJoinCodeStateTest.kt rename to androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchparty/TvJoinCodeStateTest.kt index 2f5e606e45..ceb95c55b5 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvJoinCodeStateTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchparty/TvJoinCodeStateTest.kt @@ -1,17 +1,19 @@ -package org.prairieserver.prairie.tv.ui.screens.watchtogether +package org.prairieserver.prairie.tv.ui.screens.watchparty import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue class TvJoinCodeStateTest { @Test fun appendsUppercaseAndCapsAtEight() { - var s = JoinCodeState(); "abcd1234ef".forEach { s = s.append(it) } - assertEquals("ABCD1234", s.code); assertTrue(s.isComplete) + var s = JoinCodeState(); "abcd2345ef".forEach { s = s.append(it) } + assertEquals("ABCD2345", s.code); assertTrue(s.isComplete) + assertEquals("ABCD2345", s.normalized) } - @Test fun rejectsNonAlphanumeric() { - val s = JoinCodeState().append('-').append(' ').append('A') + @Test fun rejectsCharactersOutsideTheCodeAlphabet() { + val s = JoinCodeState().append('-').append(' ').append('0').append('1').append('I').append('O').append('A') assertEquals("A", s.code); assertFalse(s.isComplete) } @Test fun backspaceRemovesLast() { @@ -22,6 +24,7 @@ class TvJoinCodeStateTest { } @Test fun incompleteUntilEight() { assertFalse(JoinCodeState().append('A').isComplete) - var t = JoinCodeState(); "ABCD1234".forEach { t = t.append(it) }; assertTrue(t.isComplete) + assertNull(JoinCodeState().append('A').normalized) + var t = JoinCodeState(); "ABCD2345".forEach { t = t.append(it) }; assertTrue(t.isComplete) } } diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchparty/TvWatchPartyTextTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchparty/TvWatchPartyTextTest.kt new file mode 100644 index 0000000000..16855be0a1 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchparty/TvWatchPartyTextTest.kt @@ -0,0 +1,89 @@ +package org.prairieserver.prairie.tv.ui.screens.watchparty + +import org.prairieserver.prairie.model.watchtogether.RoomMember +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.RoomPlaybackState +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class TvWatchPartyTextTest { + private val self = RoomMember(userId = "1", profileId = "a", displayName = "Me", isSelf = true, connected = true) + private val ana = RoomMember(userId = "2", profileId = "b", displayName = "Ana", connected = true) + private val ben = RoomMember(userId = "3", profileId = "c", displayName = "Ben", connected = true) + + private fun room( + state: RoomPlaybackState, + members: List<RoomMember>, + revision: Long = 4L, + phase: RoomPhase = RoomPhase.Playing, + ) = RoomSnapshot( + roomId = "room", + phase = phase, + playbackState = state, + selectionRevision = revision, + members = members, + ) + + @Test + fun `members still not ready when the room plays again were left behind`() { + val waiting = room(RoomPlaybackState.Waiting, listOf(self, ana.copy(isSyncing = true), ben.copy(isSyncing = true))) + val playing = room(RoomPlaybackState.Playing, listOf(self, ana, ben.copy(isReady = true))) + assertEquals(listOf("Ana"), tvWatchPartyLeftBehind(waiting, playing)) + } + + @Test + fun `this viewer is never listed as left behind`() { + val waiting = room(RoomPlaybackState.Waiting, listOf(self.copy(isSyncing = true), ana)) + val playing = room(RoomPlaybackState.Playing, listOf(self, ana)) + assertTrue(tvWatchPartyLeftBehind(waiting, playing).isEmpty()) + } + + @Test + fun `a new selection, a room that is still waiting, or a member who left is not left behind`() { + val waiting = room(RoomPlaybackState.Waiting, listOf(self, ana.copy(isSyncing = true))) + assertTrue(tvWatchPartyLeftBehind(waiting, room(RoomPlaybackState.Playing, listOf(self, ana), revision = 5L)).isEmpty()) + assertTrue(tvWatchPartyLeftBehind(waiting, room(RoomPlaybackState.Waiting, listOf(self, ana))).isEmpty()) + assertTrue(tvWatchPartyLeftBehind(waiting, room(RoomPlaybackState.Playing, listOf(self))).isEmpty()) + assertTrue(tvWatchPartyLeftBehind(null, room(RoomPlaybackState.Playing, listOf(self, ana))).isEmpty()) + assertTrue( + tvWatchPartyLeftBehind( + waiting, + room(RoomPlaybackState.Idle, listOf(self, ana), phase = RoomPhase.Lobby), + ).isEmpty(), + ) + } + + @Test + fun `the waiting overlay names other syncing members only while the room waits`() { + assertEquals( + listOf("Ana"), + tvWatchPartyWaitingFor(room(RoomPlaybackState.Waiting, listOf(self.copy(isSyncing = true), ana.copy(isSyncing = true), ben))), + ) + assertEquals(emptyList(), tvWatchPartyWaitingFor(room(RoomPlaybackState.Waiting, listOf(self.copy(isSyncing = true), ana)))) + assertNull(tvWatchPartyWaitingFor(room(RoomPlaybackState.Playing, listOf(self, ana.copy(isSyncing = true))))) + } + + @Test + fun `name lists read naturally`() { + assertEquals("", tvWatchPartyNameList(emptyList())) + assertEquals("Ana", tvWatchPartyNameList(listOf("Ana"))) + assertEquals("Ana and Ben", tvWatchPartyNameList(listOf("Ana", "Ben"))) + assertEquals("Ana, Ben, and Cy", tvWatchPartyNameList(listOf("Ana", "Ben", "Cy"))) + assertEquals("Ana, Ben, and 3 others", tvWatchPartyNameList(listOf("Ana", "Ben", "Cy", "Di", "Ed"))) + } + + @Test + fun `an ended party explains itself`() { + assertEquals("The Watch Party has ended.", tvWatchPartyEndedMessage("host_left")) + assertEquals("The Watch Party has ended.", tvWatchPartyEndedMessage("not_found")) + assertEquals("The Watch Party has ended.", tvWatchPartyEndedMessage("ended")) + assertEquals( + "This profile joined the Watch Party on another device.", + tvWatchPartyEndedMessage("connection_replaced"), + ) + assertEquals("Lost connection to the party.", tvWatchPartyEndedMessage("connection_lost")) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/LobbyNavigationDecisionTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/LobbyNavigationDecisionTest.kt deleted file mode 100644 index 74eaec903b..0000000000 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/LobbyNavigationDecisionTest.kt +++ /dev/null @@ -1,80 +0,0 @@ -package org.prairieserver.prairie.tv.ui.screens.watchtogether - -import org.prairieserver.prairie.model.watchtogether.MemberRole -import org.prairieserver.prairie.model.watchtogether.RoomPhase -import org.prairieserver.prairie.model.watchtogether.RoomSnapshot -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -/** - * Pure decision test for the lobby → synced-player hand-off. Mirrors the mobile - * lobby's `lobbyPlayerDestinationOrNull` semantics: enter only when the room is - * actually [RoomPhase.Playing] AND a (non-blank) selection has landed. - * - * Route-independent — [shouldEnterSyncedPlayer] returns a Boolean over the - * landed [RoomSnapshot] (no Android types), so this stays a plain JVM unit test. - */ -class LobbyNavigationDecisionTest { - - private fun fixture(phase: RoomPhase, selectedContentId: String?): RoomSnapshot = - RoomSnapshot( - roomId = "room-1", - phase = phase, - selectedContentId = selectedContentId, - ) - - @Test - fun entersWhenSelectionSetAndPlaying() { - assertTrue( - shouldEnterSyncedPlayer( - fixture(phase = RoomPhase.Playing, selectedContentId = "m1"), - ), - ) - } - - @Test - fun staysInLobbyWhenNoSelection() { - assertFalse( - shouldEnterSyncedPlayer( - fixture(phase = RoomPhase.Lobby, selectedContentId = null), - ), - ) - } - - @Test - fun staysInLobbyWhenSelectionButNotPlaying() { - assertFalse( - shouldEnterSyncedPlayer( - fixture(phase = RoomPhase.Lobby, selectedContentId = "m1"), - ), - ) - } - - @Test - fun nullSnapshotDoesNotEnter() { - assertFalse(shouldEnterSyncedPlayer(null)) - } - - @Test - fun hostAloneHoldsOnTheInviteCodeUntilSomeoneJoins() { - val alone = fixture(RoomPhase.Playing, "m1").copy( - selfRole = MemberRole.Host, - memberCount = 1, - ) - assertFalse(shouldEnterSyncedPlayer(alone)) - assertTrue(shouldEnterSyncedPlayer(alone.copy(memberCount = 2))) - } - - @Test - fun guestEntersAnAlreadyPlayingRoomImmediately() { - assertTrue( - shouldEnterSyncedPlayer( - fixture(RoomPhase.Playing, "m1").copy( - selfRole = MemberRole.Guest, - memberCount = 1, - ), - ), - ) - } -} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherDestinationTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherDestinationTest.kt deleted file mode 100644 index 1639b40719..0000000000 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherDestinationTest.kt +++ /dev/null @@ -1,49 +0,0 @@ -package org.prairieserver.prairie.tv.ui.screens.watchtogether - -import org.prairieserver.prairie.model.watchtogether.MemberRole -import org.prairieserver.prairie.model.watchtogether.RoomSnapshot -import org.prairieserver.prairie.tv.ui.navigation.TvRoute -import kotlin.test.Test -import kotlin.test.assertEquals - -class TvWatchTogetherDestinationTest { - @Test - fun emptyAndSoloHostRoomsUseLobby() { - assertEquals( - TvRoute.WatchTogetherLobby("room-1").route, - tvWatchTogetherDestination(RoomSnapshot(roomId = "room-1")), - ) - assertEquals( - TvRoute.WatchTogetherLobby("room-1").route, - tvWatchTogetherDestination( - RoomSnapshot( - roomId = "room-1", - selectedContentId = "movie-1", - selfRole = MemberRole.Host, - memberCount = 1, - ), - ), - ) - } - - @Test - fun selectedJoinedRoomUsesSyncedPlayer() { - val room = RoomSnapshot( - roomId = "room-1", - selectedContentId = "movie-1", - selectedFileId = 7, - selfRole = MemberRole.Guest, - memberCount = 2, - anchorPositionSeconds = 12.5, - ) - assertEquals( - TvRoute.Player( - contentId = "movie-1", - fileId = 7, - roomId = "room-1", - resumePositionSeconds = 12.5, - ).route, - tvWatchTogetherDestination(room), - ) - } -} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyErrorTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyErrorTest.kt deleted file mode 100644 index f225377f4f..0000000000 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyErrorTest.kt +++ /dev/null @@ -1,172 +0,0 @@ -package org.prairieserver.prairie.tv.ui.screens.watchtogether - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.prairieserver.prairie.model.watchtogether.AddSuggestionRequest -import org.prairieserver.prairie.model.watchtogether.CreateRoomRequest -import org.prairieserver.prairie.model.watchtogether.JoinRoomRequest -import org.prairieserver.prairie.model.watchtogether.PromoteSuggestionRequest -import org.prairieserver.prairie.model.watchtogether.RoomResponse -import org.prairieserver.prairie.model.watchtogether.RoomSnapshot -import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest -import org.prairieserver.prairie.model.watchtogether.SuggestionsResponse -import org.prairieserver.prairie.model.watchtogether.UpdatePolicyRequest -import org.prairieserver.prairie.network.ApiResult -import org.prairieserver.prairie.network.AuthScopeSnapshot -import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier -import org.prairieserver.prairie.network.api.WatchTogetherApi -import org.prairieserver.prairie.repository.WatchTogetherRepository -import org.prairieserver.prairie.watchtogether.RoomSession -import kotlin.test.AfterTest -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals - -@OptIn(ExperimentalCoroutinesApi::class) -class TvWatchTogetherLobbyErrorTest { - private val dispatcher = UnconfinedTestDispatcher() - - @BeforeTest - fun setUp() { - Dispatchers.setMain(dispatcher) - } - - @AfterTest - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - fun `rejected lobby operations use the transient repository message path`() = runTest(dispatcher) { - val api = FailingLobbyApi() - val repository = WatchTogetherRepository( - api = api, - authScopeProvider = { AUTH_SCOPE }, - ) - repository.createRoom(CreateRoomRequest()) - val roomSession = RoomSession(repository, backgroundScope, DefaultIdentityTransitionBarrier()) - val viewModel = TvWatchTogetherLobbyViewModel("room-1", repository, roomSession) - val messages = mutableListOf<String>() - val collector = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.errors.toList(messages) - } - - viewModel.vote("suggestion-1") - viewModel.promote("suggestion-1") - repository.reportDeliveryFailure("Socket request rejected") - runCurrent() - - assertEquals( - listOf( - "Voting is disabled", - "Only the host can promote", - "Socket request rejected", - ), - messages, - ) - collector.cancel() - } - - @Test - fun `suggest rejection remains a visible one shot detail message`() = runTest(dispatcher) { - val repository = WatchTogetherRepository( - api = FailingLobbyApi(), - authScopeProvider = { AUTH_SCOPE }, - ) - repository.createRoom(CreateRoomRequest()) - val viewModel = TvSuggestToRoomViewModel(repository) - - viewModel.suggest("movie-1", "movie", "Movie One", null, null) - runCurrent() - - assertEquals("Suggestions are locked", viewModel.uiState.value.error) - viewModel.clearError() - assertEquals(null, viewModel.uiState.value.error) - } - - private class FailingLobbyApi : WatchTogetherApi { - private val roomResponse = - ApiResult.Success(RoomResponse(RoomSnapshot(roomId = "room-1"), "room-token")) - - override suspend fun createRoom(request: CreateRoomRequest, scope: AuthScopeSnapshot) = roomResponse - override suspend fun joinRoom(request: JoinRoomRequest, scope: AuthScopeSnapshot) = roomResponse - override suspend fun getRoom(roomId: String, roomToken: String, scope: AuthScopeSnapshot) = roomResponse - override suspend fun setSelection( - roomId: String, - roomToken: String, - request: SetSelectionRequest, - scope: AuthScopeSnapshot, - ) = roomResponse - - override suspend fun updatePolicy( - roomId: String, - roomToken: String, - request: UpdatePolicyRequest, - scope: AuthScopeSnapshot, - ) = roomResponse - - override suspend fun closeRoom(roomId: String, roomToken: String, scope: AuthScopeSnapshot) = - ApiResult.Success(Unit) - - override suspend fun listSuggestions( - roomId: String, - roomToken: String, - scope: AuthScopeSnapshot, - ) = ApiResult.Success(SuggestionsResponse()) - - override suspend fun addSuggestion( - roomId: String, - roomToken: String, - request: AddSuggestionRequest, - scope: AuthScopeSnapshot, - ): ApiResult<SuggestionsResponse> = - ApiResult.Error(409, "suggestions_locked", "Suggestions are locked") - - override suspend fun deleteSuggestion( - roomId: String, - roomToken: String, - suggestionId: String, - scope: AuthScopeSnapshot, - ) = ApiResult.Success(SuggestionsResponse()) - - override suspend fun vote( - roomId: String, - roomToken: String, - suggestionId: String, - scope: AuthScopeSnapshot, - ): ApiResult<SuggestionsResponse> = - ApiResult.Error(409, "voting_disabled", "Voting is disabled") - - override suspend fun unvote( - roomId: String, - roomToken: String, - suggestionId: String, - scope: AuthScopeSnapshot, - ) = ApiResult.Success(SuggestionsResponse()) - - override suspend fun promoteSuggestion( - roomId: String, - roomToken: String, - request: PromoteSuggestionRequest, - scope: AuthScopeSnapshot, - ): ApiResult<RoomResponse> = - ApiResult.Error(403, "host_required", "Only the host can promote") - } - - private companion object { - val AUTH_SCOPE = AuthScopeSnapshot( - serverId = "server-1", - profileId = "profile-1", - serverUrl = "https://example.test", - profileToken = "profile-token", - identityGeneration = 1L, - ) - } -} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherMenuInitialActionTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherMenuInitialActionTest.kt deleted file mode 100644 index 31f7cd570e..0000000000 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherMenuInitialActionTest.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.prairieserver.prairie.tv.ui.screens.watchtogether - -import kotlin.test.Test -import kotlin.test.assertEquals - -class TvWatchTogetherMenuInitialActionTest { - @Test - fun initialActionPrefersResumeOnlyWhenAvailable() { - assertEquals( - TvWatchTogetherMenuInitialAction.Resume, - tvWatchTogetherMenuInitialAction(canResume = true), - ) - assertEquals( - TvWatchTogetherMenuInitialAction.Host, - tvWatchTogetherMenuInitialAction(canResume = false), - ) - } -} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherViewModelTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherViewModelTest.kt deleted file mode 100644 index 5b125d3d96..0000000000 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherViewModelTest.kt +++ /dev/null @@ -1,136 +0,0 @@ -package org.prairieserver.prairie.tv.ui.screens.watchtogether - -import org.prairieserver.prairie.model.watchtogether.CreateRoomRequest -import org.prairieserver.prairie.model.watchtogether.JoinRoomRequest -import org.prairieserver.prairie.model.watchtogether.RoomPhase -import org.prairieserver.prairie.model.watchtogether.RoomResponse -import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode -import org.prairieserver.prairie.model.watchtogether.RoomSnapshot -import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest -import org.prairieserver.prairie.network.ApiResult -import org.prairieserver.prairie.watchtogether.WatchTogetherEntryGateway -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import kotlin.test.AfterTest -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals - -@OptIn(ExperimentalCoroutinesApi::class) -class TvWatchTogetherViewModelTest { - private val dispatcher = UnconfinedTestDispatcher() - - @BeforeTest - fun setUp() { - Dispatchers.setMain(dispatcher) - } - - @AfterTest - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - fun emptyHostCreatesVoteRoomWithoutSelection() = runTest(dispatcher) { - val gateway = FakeGateway() - val viewModel = TvWatchTogetherViewModel(gateway) - - viewModel.createEmptyVoteRoom() - - assertEquals( - listOf(CreateRoomRequest(RoomSelectionMode.Vote.wire)), - gateway.createRequests, - ) - assertEquals(emptyList(), gateway.selectionRequests) - assertEquals("room-1", viewModel.uiState.value.result?.roomId) - } - - @Test - fun resumeUsesCurrentRoomWithoutNetworkCalls() = runTest(dispatcher) { - val room = RoomSnapshot(roomId = "room-1", phase = RoomPhase.Lobby) - val gateway = FakeGateway(room) - val viewModel = TvWatchTogetherViewModel(gateway) - - viewModel.resumeCurrentRoom() - - assertEquals(room, viewModel.uiState.value.result) - assertEquals(emptyList(), gateway.createRequests) - assertEquals(emptyList(), gateway.joinRequests) - } - - @Test - fun joinCodeNormalizesAndKeepsExistingErrorCopy() = runTest(dispatcher) { - val gateway = FakeGateway() - val viewModel = TvWatchTogetherViewModel(gateway) - - viewModel.joinRoom(" abcd1234 ") - assertEquals(listOf(JoinRoomRequest(code = "ABCD1234")), gateway.joinRequests) - - viewModel.consumeResult() - gateway.joinResult = ApiResult.NetworkError(IllegalStateException("offline")) - viewModel.joinRoom("efgh5678") - assertEquals("Network error. Check your connection.", viewModel.uiState.value.error) - } - - @Test - fun titleDetailHostStillSetsSelection() = runTest(dispatcher) { - val gateway = FakeGateway() - val viewModel = TvWatchTogetherViewModel(gateway) - - viewModel.createRoom(contentId = "movie-1", fileId = 7) - - assertEquals( - listOf(SetSelectionRequest(contentId = "movie-1", fileId = 7)), - gateway.selectionRequests, - ) - } - - private class FakeGateway( - room: RoomSnapshot? = null, - ) : WatchTogetherEntryGateway { - override val roomSnapshot = MutableStateFlow(room) - val createRequests = mutableListOf<CreateRoomRequest>() - val joinRequests = mutableListOf<JoinRoomRequest>() - val selectionRequests = mutableListOf<SetSelectionRequest>() - var joinResult: ApiResult<RoomResponse>? = null - var nextRoom = RoomSnapshot( - roomId = "room-1", - phase = RoomPhase.Lobby, - selectionMode = RoomSelectionMode.Vote, - ) - - override suspend fun createRoom( - request: CreateRoomRequest, - ): ApiResult<RoomResponse> { - createRequests += request - roomSnapshot.value = nextRoom - return ApiResult.Success(RoomResponse(nextRoom, "test-room-token")) - } - - override suspend fun joinRoom( - request: JoinRoomRequest, - ): ApiResult<RoomResponse> { - joinRequests += request - joinResult?.let { return it } - roomSnapshot.value = nextRoom - return ApiResult.Success(RoomResponse(nextRoom, "test-room-token")) - } - - override suspend fun setSelection( - request: SetSelectionRequest, - ): ApiResult<RoomResponse> { - selectionRequests += request - nextRoom = nextRoom.copy( - selectedContentId = request.contentId, - selectedFileId = request.fileId, - ) - roomSnapshot.value = nextRoom - return ApiResult.Success(RoomResponse(nextRoom, "test-room-token")) - } - } -} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvLibraryTabTypeTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvLibraryTabTypeTest.kt index d2a37f14fa..7c35e10ef4 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvLibraryTabTypeTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvLibraryTabTypeTest.kt @@ -13,6 +13,17 @@ import kotlin.test.assertTrue */ class TvLibraryTabTypeTest { + @Test + fun `mixed libraries are reachable from both video tabs only`() { + for (value in listOf("mixed", " Mixed ")) { + val mixed = library(value) + assertTrue(TvLibraryTabType.Movies.matches(mixed)) + assertTrue(TvLibraryTabType.Series.matches(mixed)) + assertFalse(TvLibraryTabType.Music.matches(mixed)) + assertFalse(TvLibraryTabType.Audiobooks.matches(mixed)) + } + } + private fun library(type: String) = UserLibrary(id = 1, name = type, type = type) @Test diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMediaDestinationsTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMediaDestinationsTest.kt index fa76e95058..85f6356d2d 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMediaDestinationsTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMediaDestinationsTest.kt @@ -64,6 +64,22 @@ class TvMediaDestinationsTest { ) } + @Test + fun requestsTrailsTheContentTabsOnlyWhileTheServerEnablesIt() { + val libraries = listOf(library(1, "movies")) + assertEquals( + listOf( + TvRootDestination.Home, + TvRootDestination.LibraryType(TvLibraryTabType.Movies), + TvRootDestination.ForYou, + TvRootDestination.Calendar, + TvRootDestination.Requests, + ), + visibleTvRoots(libraries, requestsEnabled = true), + ) + assertEquals(false, TvRootDestination.Requests in visibleTvRoots(libraries, requestsEnabled = false)) + } + @Test fun firstRouteIsAlwaysHome() { assertEquals(TvMainRoute.Home.route, firstTvRoute()) diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/theme/TvTypographyReadabilityTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/theme/TvTypographyReadabilityTest.kt index 44569797aa..87f4328d7d 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/theme/TvTypographyReadabilityTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/theme/TvTypographyReadabilityTest.kt @@ -21,6 +21,14 @@ class TvTypographyReadabilityTest { "org/prairieserver/prairie/tv/ui/screens/detail/TvDetailEpisodeRail.kt|fontSize = 11.5.sp,", "org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt|fontSize = 11.5.sp,", "org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt|fontSize = 11.5.sp,", + // First-run chrome at tvOS half scale: the SECURE/HTTP badge on the + // server card, and the label inside the "Tap Set up" step picture. + "org/prairieserver/prairie/tv/ui/components/marquee/TvMarquee.kt|fontSize = 9.sp,", + "org/prairieserver/prairie/tv/ui/components/marquee/TvMarqueeSetupSteps.kt|Text(\"Set up\", color = Color.Black, fontSize = 7.5.sp, fontWeight = FontWeight.Bold)", + // Player chrome at tvOS half scale: the uppercase tracked eyebrow over + // the title and panel sections, and the SDH/FORCED track chips. + "org/prairieserver/prairie/tv/ui/screens/player/TvPlayerChrome.kt|fontSize = 11.sp,", + "org/prairieserver/prairie/tv/ui/screens/player/TvPlayerChrome.kt|fontSize = 9.sp,", ) @Test diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/watchnext/WatchNextHomeSyncTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/watchnext/WatchNextHomeSyncTest.kt index f8bc8356c2..627006d9bd 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/watchnext/WatchNextHomeSyncTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/watchnext/WatchNextHomeSyncTest.kt @@ -13,6 +13,8 @@ import org.prairieserver.prairie.network.* import org.prairieserver.prairie.network.api.SectionApi import org.prairieserver.prairie.network.apiv2.HomeSectionsV2Api import org.prairieserver.prairie.repository.SectionRepository +import org.prairieserver.prairie.model.catalog.ItemDetail +import org.prairieserver.prairie.model.settings.EpisodeSpoilerPrefs import kotlin.test.* @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) @@ -27,11 +29,14 @@ class WatchNextHomeSyncTest { private var reads = 0 private var applies = 0 private var hook: () -> Unit = {} + private var episodeRows = false private suspend fun scenario(block: suspend (SectionRepository) -> Unit) { val client = HttpClient(MockEngine { assertEquals(owner, it.attributes[AuthScopeAttributeKey]) val fallback = it.url.encodedPath.endsWith("/items") - val row = """{"id":"row","section_type":"continue_watching","title":"Row","items":[{"content_id":"movie:a","type":"movie","title":"A","backdrop_url":"/image"}]}""" + val row = if (episodeRows) { + """{"id":"row","section_type":"next_up","title":"Row","items":[{"content_id":"episode:a","type":"episode","title":"A","series_id":"series","backdrop_url":"/still-a","backdrop_is_episode_still":true},{"content_id":"episode:b","type":"episode","title":"B","series_id":"series","backdrop_url":"/still-b","backdrop_is_episode_still":true}]}""" + } else """{"id":"row","section_type":"continue_watching","title":"Row","items":[{"content_id":"movie:a","type":"movie","title":"A","backdrop_url":"/image"}]}""" val body = if (fallback) { reads++; row } else { assertEquals("/api/v2/home/sections", it.url.encodedPath); hook() if (empty) """{"sections":[]}""" @@ -48,6 +53,59 @@ class WatchNextHomeSyncTest { assertEquals("prairie://play/movie:a?type=movie", fields.single().intentUri) } } } + @Test fun protectedEpisodesFetchSeriesArtOnceAndReplaceBothTiles() = runTest { + episodeRows = true + var artworkReads = 0 + scenario { repo -> + assertTrue(syncWatchNextHome(repo, gate, { false }, + spoilerPreferences = { EpisodeSpoilerPrefs(hideImages = true) }, + seriesArtwork = { id -> + assertEquals("series", id); artworkReads++ + ItemDetail(contentId = id, type = "series", title = "Series", backdropUrl = "/safe") + }, + ) { fields, run, authority -> + gate.write(run, authority) { + applies++ + assertEquals(listOf("/safe", "/safe"), fields.map { it.posterArtUri }) + } + }) + } + assertEquals(1, artworkReads) + assertEquals(1, applies) + } + + @Test fun failedSeriesLookupRetriesInsteadOfDroppingProtectedTiles() = runTest { + episodeRows = true + scenario { repo -> + assertFalse(syncWatchNextHome(repo, gate, { false }, + spoilerPreferences = { EpisodeSpoilerPrefs(hideImages = true) }, + seriesArtwork = { null }, + ) { _, _, _ -> applies++ }) + } + assertEquals(0, applies) + } + + @Test fun aCastIdentityInstalledMidRunNeverWritesTheLauncher() = runTest { + scenario { repo -> + var borrowed = false + hook = { borrowed = true } + assertTrue(syncWatchNextHome(repo, gate, { false }, borrowedIdentity = { borrowed }) { _, run, authority -> + gate.write(run, authority) { applies++ } + }) + assertEquals(0, applies) + } + } + + @Test fun unknownPreferencesRetryWithoutPublishingAndChangedPreferencesFenceWrites() = runTest { + scenario { repo -> + assertFalse(syncWatchNextHome(repo, gate, { false }, spoilerPreferences = { null }) { _, _, _ -> applies++ }) + assertEquals(0, applies) + var current = true + hook = { current = false } + assertTrue(syncWatchNextHome(repo, gate, { false }, preferencesCurrent = { current }) { _, _, _ -> applies++ }) + assertEquals(0, applies) + } + } @Test fun completeAndEmptySuccessMayReconcileButFailuresCannot() = runTest { scenario { repo -> assertTrue(sync(repo)); assertEquals(1, applies); assertEquals(0, reads) fail = true; assertFalse(sync(repo)); assertEquals(1, applies) diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/watchnext/WatchNextProgramMapperTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/watchnext/WatchNextProgramMapperTest.kt index 3ca2a1c78a..9f9b58186c 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/watchnext/WatchNextProgramMapperTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/watchnext/WatchNextProgramMapperTest.kt @@ -1,11 +1,38 @@ package org.prairieserver.prairie.tv.watchnext import org.prairieserver.prairie.model.section.SectionItem +import org.prairieserver.prairie.model.catalog.MediaItemUserState +import org.prairieserver.prairie.model.catalog.ItemDetail +import org.prairieserver.prairie.model.settings.EpisodeSpoilerPrefs import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test class WatchNextProgramMapperTest { + @Test + fun `protected episode uses explicit series artwork and never publishes the still`() { + val episode = sectionItem(type = "episode").copy(backdropIsEpisodeStill = true, posterIsEpisodeStill = false) + val prefs = EpisodeSpoilerPrefs(hideImages = true) + assertEquals(episode.posterUrl, WatchNextProgramMapper.map(episode, "next_up", prefs)?.posterArtUri) + assertEquals(WatchNextProgramMapper.ASPECT_RATIO_2_3, + WatchNextProgramMapper.map(episode, "next_up", prefs)?.posterArtAspectRatio) + val noSafeSlot = episode.copy(posterIsEpisodeStill = true) + assertNull(WatchNextProgramMapper.map(noSafeSlot, "next_up", prefs)) + val series = ItemDetail(contentId = "series", type = "series", title = "Series", backdropUrl = "safe-series") + assertEquals("safe-series", WatchNextProgramMapper.map(noSafeSlot, "next_up", prefs, series)?.posterArtUri) + assertNull(WatchNextProgramMapper.map(noSafeSlot, "next_up", prefs, series.copy(type = "episode"))) + } + + @Test + fun `legacy protected artwork is replaced but watched and in progress stills remain`() { + val episode = sectionItem(type = "episode") + val prefs = EpisodeSpoilerPrefs(hideImages = true) + assertNull(WatchNextProgramMapper.map(episode, "next_up", prefs)) + assertEquals(episode.backdropUrl, + WatchNextProgramMapper.map(episode.copy(userState = MediaItemUserState(played = true)), "next_up", prefs)?.posterArtUri) + assertEquals(episode.backdropUrl, + WatchNextProgramMapper.map(episode.copy(positionSeconds = 1.0), "continue_watching", prefs)?.posterArtUri) + } private fun sectionItem( id: String = "tt1234", diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/watchnext/WatchNextSeederTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/watchnext/WatchNextSeederTest.kt new file mode 100644 index 0000000000..b87a9ce5d3 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/watchnext/WatchNextSeederTest.kt @@ -0,0 +1,47 @@ +package org.prairieserver.prairie.tv.watchnext + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.work.Configuration +import androidx.work.WorkManager +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class) +class WatchNextSeederTest { + @Test + fun startupKeepsTilesAndOnlyNewlyEnabledProtectionInvalidatesThem() { + val context = ApplicationProvider.getApplicationContext<Application>() + context.getSharedPreferences("silo_watch_next_protection", Context.MODE_PRIVATE).edit().clear().commit() + if (runCatching { WorkManager.getInstance(context) }.isFailure) { + WorkManager.initialize(context, Configuration.Builder().build()) + } + val repository = WatchNextRepository(context) + val seeder = WatchNextSeeder(context, repository) + val original = repository.writeGate.capture() + seeder.updateImageProtection(false) + assertEquals(original, repository.writeGate.capture()) + seeder.updateImageProtection(true) + val protected = repository.writeGate.capture() + assertNotEquals(original, protected) + // The setting is recorded only once the wipe finishes; a restart before + // then must wipe again. + val prefs = context.getSharedPreferences("silo_watch_next_protection", Context.MODE_PRIVATE) + val deadline = System.currentTimeMillis() + 5_000 + while (!prefs.getBoolean("hide_images", false) && System.currentTimeMillis() < deadline) Thread.sleep(10) + assertTrue(prefs.getBoolean("hide_images", false)) + // Recreating the seeder models a process restart with the same known setting. + WatchNextSeeder(context, repository).updateImageProtection(true) + assertEquals(protected, repository.writeGate.capture()) + seeder.updateImageProtection(false) + assertEquals(protected, repository.writeGate.capture()) + WorkManager.getInstance(context).cancelAllWork() + } +} diff --git a/baselineprofile/gradle.lockfile b/baselineprofile/gradle.lockfile index ce822a3179..f4625273f5 100644 --- a/baselineprofile/gradle.lockfile +++ b/baselineprofile/gradle.lockfile @@ -19,6 +19,7 @@ androidx.arch.core:core-runtime:2.2.0=benchmarkReleaseTestedApks,nonMinifiedRele androidx.benchmark:benchmark-common:1.3.4=benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,implementationDependenciesMetadata,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath androidx.benchmark:benchmark-macro-junit4:1.3.4=benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,implementationDependenciesMetadata,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath androidx.benchmark:benchmark-macro:1.3.4=benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,implementationDependenciesMetadata,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath +androidx.browser:browser:1.10.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks androidx.collection:collection-jvm:1.5.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks androidx.collection:collection-ktx:1.5.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks androidx.collection:collection:1.0.0=benchmarkReleaseRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath @@ -287,6 +288,8 @@ io.coil-kt.coil3:coil-network-core-jvm:3.1.0=benchmarkReleaseTestedApks,nonMinif io.coil-kt.coil3:coil-network-core:3.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks io.coil-kt.coil3:coil-network-okhttp-jvm:3.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks io.coil-kt.coil3:coil-network-okhttp:3.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.coil-kt.coil3:coil-svg-jvm:3.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.coil-kt.coil3:coil-svg:3.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks io.coil-kt.coil3:coil:3.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks io.github.peerless2012:ass-kt:0.5.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks io.github.peerless2012:ass-media:0.5.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks diff --git a/build.gradle.kts b/build.gradle.kts index 97a1750ec7..e21b2487d1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -20,6 +20,14 @@ plugins { } allprojects { + tasks.withType<org.gradle.api.tasks.testing.Test>().configureEach { + // Source-policy tests read workflows, manifests and sibling source trees + // directly. Until all those inputs are declared, always execute tests; + // compilation, resource, lint and R8 outputs can still use the cache. + outputs.cacheIf("Tests inspect files outside their runtime classpath") { false } + outputs.upToDateWhen { false } + } + dependencyLocking { lockAllConfigurations() } diff --git a/docs/README.md b/docs/README.md index 9b20327312..f17e6a49de 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,9 +12,12 @@ Start with the root [README](../README.md) for architecture/build instructions a - Android TV navigation is Home, media-type tabs from server libraries, For You, and Calendar, plus search and profile actions. - Requests is live on phone and TV, server-gated by `requests_enabled`. -- Admin surfaces, session management, and Watch Together are not exposed in - either Android app. Device pairing stays. Treat their old routes, repositories, - tests, and design plans as inactive until a product decision exposes them. +- Admin surfaces and session management are not exposed in either Android app. + Device pairing stays. Treat their old routes, repositories, tests, and design + plans as inactive until a product decision exposes them. +- Watch Party is experimental on phone and TV, behind Settings → Experimental → + Watch Party (on in debug builds, off in release builds) and the server's room + capabilities. See [`watch-party-implementation-plan.md`](watch-party-implementation-plan.md). - All server calls use API v2. See [`api-v2/android-migration-status.md`](api-v2/android-migration-status.md). - Phone downloads preserve original filenames/formats in public storage so other apps can open downloaded videos, audiobooks, and ebooks. @@ -33,7 +36,7 @@ Start with the root [README](../README.md) for architecture/build instructions a [collections](collections-api-v2.md), [ebook reader](ebook-reader-api-v2.md), [history](history-api-v2.md), [membership](membership-api-v2.md), [notifications](notifications-api-v2.md), [requests](requests-api-v2.md), - [subtitle AI](subtitle-ai-api-v2.md), + [shuffle](shuffle-api-v2.md), [subtitle AI](subtitle-ai-api-v2.md), [push registration](api-v2/android-push-registration.md) ## Folders diff --git a/docs/api-v2/android-migration-status.md b/docs/api-v2/android-migration-status.md index fb30e70619..5b18b1ad58 100644 --- a/docs/api-v2/android-migration-status.md +++ b/docs/api-v2/android-migration-status.md @@ -7,7 +7,9 @@ and the server ledger `contracts/api/v2/migration.json` (silo-server `origin/api | Endpoint family | v2 paths | Status | | --- | --- | --- | | Auth | `/api/v2/auth/{login,setup,signup,logout,refresh}`, `/api/v2/system/setup`, `/api/v2/account/me`, `/api/v2/system/info` | migrated | -| Device sign-in | `/api/v2/auth/device`, `/api/v2/auth/device/{start,poll,capability,approve,approve-handoff,deny}` | migrated | +| Device sign-in | `/api/v2/auth/device`, `/api/v2/auth/device/{start,poll,cancel,capability,approve,approve-handoff,deny}` | migrated | +| Server identity | `/api/v2/system/identity`, `/api/v2/system/connections` | migrated | +| External sign-in | `/api/v2/auth/providers`, `/api/v2/auth/oauth/capabilities`, `/api/v2/auth/oauth/{install_id}/native/start` (browser), `/api/v2/auth/oauth/complete`, `/api/v2/auth/external-sign-in/capabilities`, `/api/v2/account/identities`, `/api/v2/account/identities/{id}`, `/api/v2/account/identities/{link-ticket,link-complete,link-credentials}` | migrated | | Invitations | `/api/v2/invitations/capabilities`, `/api/v2/invitations/{token}`, `/api/v2/invitations/{token}/accept` | migrated | | Onboarding | `/api/v2/onboarding/{flow,state,progress}` | migrated | | Profiles | `/api/v2/profiles`, `/api/v2/profiles/{id}`, `/api/v2/profiles/{id}/verify-pin` | migrated | @@ -21,6 +23,7 @@ and the server ledger `contracts/api/v2/migration.json` (silo-server `origin/api | Calendar | `/api/v2/calendar` | migrated | | Requests | `/api/v2/requests`, `/api/v2/requests/{id}`, `/api/v2/requests/{id}/cancel`, `/api/v2/requests/{mine,status,search,discover}`, `/api/v2/requests/discover/{section}`, `/api/v2/requests/detail/{media_type}/{tmdb_id}` | migrated (server-gated by `requests_enabled`) | | Playback | `/api/v2/playback/capabilities`, `/api/v2/playback/start`, `/api/v2/playback/route-events`, `/api/v2/playback/sessions/{id}/control/ws-ticket`, `/api/v2/playback/sessions/{id}/control/ws` | migrated (start was redesigned server-side; the client uses the v2 `PlaybackDecision` shape) | +| Shuffle | `/api/v2/shuffles`, `/api/v2/shuffles/capabilities`, `/api/v2/shuffles/{id}`, `/api/v2/shuffles/{id}/{advance,skip}` | v2 only (server-gated by the capability; see [shuffle](../shuffle-api-v2.md)) | | Subtitles | `/api/v2/subtitles/{media_file_id}`, `/api/v2/subtitles/{search,download}`, `/api/v2/subtitles/ai/{status,quota,translate,jobs}`, `/api/v2/subtitles/ai/jobs/{id}`, `/api/v2/subtitles/ai/jobs/{id}/cancel` | migrated | | Settings | `/api/v2/settings/contract/capabilities`, `/api/v2/settings/values/effective`, `/api/v2/settings/values/{key}`, `/api/v2/settings/overlay-config` | migrated | | Downloads | `/api/v2/downloads`, `/api/v2/downloads/{id}`, `/api/v2/capabilities/downloads` | migrated | diff --git a/docs/auth-api-v2.md b/docs/auth-api-v2.md index a63b3387be..e6795c02d0 100644 --- a/docs/auth-api-v2.md +++ b/docs/auth-api-v2.md @@ -2,8 +2,8 @@ The existing phone and TV authentication paths use v2 login, setup, signup, signup status, logout and refresh. Setup status and current account use -`/api/v2/system/setup` and `/api/v2/account/me`. Device login uses v2 start, poll, lookup, capability, -approve, deny and profile-scoped handoff approval. Media credential refresh uses +`/api/v2/system/setup` and `/api/v2/account/me`. Device login uses v2 start, poll, cancel, lookup, +capability, approve, deny and profile-scoped handoff approval. Media credential refresh uses the same v2 refresh route while preserving its captured-origin credential guards. Login accepts200; setup, signup and device start accept201; device polling and @@ -17,9 +17,111 @@ Public auth exchanges omit current bearer/profile credentials. Candidate-server calls retain explicit URLs. Optional request members are omitted when absent by the existing JSON configuration. Login, setup, signup, device start and collecting polls opt out of auth-plugin replay. A successful pending poll schedules the next -protocol poll; a failed or ambiguous poll requires an explicit restart, because -its response may have contained the only issued token pair. Phone and TV prevent -repeated auth submissions while the first is pending. +protocol poll; an approval without usable tokens requires an explicit restart +with a new code, because the server issues the token pair once. Phone and TV +prevent repeated auth submissions while the first is pending. + +## TV device sign-in + +`DeviceSignInMachine` (shared) drives the TV sign-in screen's code; the +setup-mode LAN receiver uses the single-attempt loop in +`DeviceLoginRepository`. Both read every poll answer through +`DeviceLoginPoller`, so they follow the same rules: + +- Poll at once after start, then at `interval` / `poll_after`. Network errors, + 5xx and 429 back off from the interval to 30 s. A 404 or another 4xx on poll + means the server no longer has the request. +- A local deadline starts at `expires_in`. A pending poll answer carries the + request's current `expires_at`, which an approver's lookup extends; the + deadline moves to it, converted to the device's clock through the start + answer's own `expires_at`/`expires_in` pair. Waits are clamped to the + deadline, so the last call before a code is given up is a poll. +- The sign-in screen replaces a code only after a poll: when the server says + it is expired, consumed or canceled, or when a poll at the deadline still + finds it pending, in which case the code is withdrawn with `cancel` first. + A capability read that failed earlier is retried then; the code is withdrawn + unless the capability says `cancel` is false, so a read that fails again + still sends the cancel (a server without it answers 404). It pauses after + an hour without the person doing anything, reports "can't reach" after 10 s + of failed starts or 30 s of failed polls, and "too many requests" after a + minute of 429s, while still retrying. A 404 + or 501 start, or a capability `state` other than `available`, means the + server has no device sign-in: the password form. +- `ON_STOP` ends the wait between polls; a poll already in flight finishes and + an approval being saved completes, because the server hands out the tokens + once. `ON_START` polls a live code at once. Leaving the screen, retrying, + changing server or signing in with a password withdraws the code with + `cancel` (also when the capability couldn't be read; a server without + `cancel` answers 404). A password save that fails leaves the code working. + "Continue on your phone" shows when a poll reports `opened`, which only + servers that advertise `opened_signal` send. +- The code is shown grouped 4+4 with `<host>/activate` and a QR of + `verification_uri_complete`. Match words are never shown on the TV. The + phone's LAN confirmation adds an "Older TV apps show ..." line with them, + like the web `/activate` card, until TV apps that show user codes have + shipped. + +## Server identity and LAN pairing + +Saved servers record the deployment identity verified at their URL +(`ServerEntry.verifiedServerId`, from `GET /api/v2/system/identity`). +`prairie://device?server=<server_id>&url=<base>&code=<code>` links and signed-out +TVs are matched to saved servers by that identity, never by URL spelling; when +no identity can be verified the link's origin is matched instead. A link +without `server` (the web page omits it when the identity is unknown) is +scoped by `url`'s origin, the way an HTTPS device link is. + +A link's code is approved on the saved server it names, through that server's +own profileless account scope, without switching the phone's active server +(prairie-apple parity). When the phone isn't signed in there it asks to sign in +first rather than trying another server. Only `token` links, which the active +server's lookup alone accepts, still offer the switch. A link naming a server +the phone hasn't saved offers to add it, with the link's `url` prefilled. + +The approver renews its access token before looking a code up and before +approving or denying: the approval's account read and decision calls are +marked `freshPrairieAuth()`, so `PrairieAuthPlugin` refreshes that server's scope +first when its token is expiring, under the same lock as the 401 refresh. +On a saved server other than the active one, the card names the account read +through that server's scope; when that read fails it says it couldn't confirm +the account (or that the provider can't be reached, when that was the +reason), offers a retry, and keeps approving disabled. +"Not you?" signs out of that server's account and returns to sign-in, keeping +the pending code; on a server other than the active one it makes that server +active first. As on prairie-apple, it reads "Not you? Switch account" when the +next sign-in lets the person choose (a provider that takes `select_account`, +or only the password form) and "Not you? Sign out" otherwise, including when +the server's sign-in options couldn't be read. Where a provider takes +`select_account` and the server lists exactly one provider, the login screen +that follows starts that provider's sign-in by itself, once +(`NativeSignInCoordinator.requestAccountChoice(autoStart = true)`, kept in +memory only and handed back if the screen goes away before the browser +opens). "Sign in" after a 401 does the same without +signing out: it signs in on the server the code was looked up on. + +The LAN pairing protocol matches prairie-apple frame for frame +(`PairingProtocolGoldenFramesTest`). A TV on its sign-in screen advertises +`_silopair._tcp` with TXT `st=login` and `srv=<server_id>`; a phone offers it +only when it holds a signed-in server with that verified identity, skips the +server chooser and pushes only that server. That TV accepts only a push whose +`serverIdentity` equals its `srv` and answers with the code its sign-in screen +already shows; the screen polls and saves the session, so there is only one +request. The TV advertises only while its sign-in screen is visible. +Advertising `st=login` sits behind a rollout gate shared with prairie-apple +(`PairingProtocol.advertisesSignInTVs` there, `TvLoginViewModel`'s +`advertisesSignIn`, set from `BuildConfig.DEBUG`, here): on in debug builds, +off in release until the iOS and Android phone apps that accept `login` TVs +have both shipped. A phone that predates them offers a sign-in TV the setup +chooser and pushes servers without an identity, which the TV refuses. First-run +TVs keep `st=setup` and +the chooser, with the phone's active server preselected. `pushServer` carries +`serverIdentity` and `endpoints` (from `GET /api/v2/system/connections`); the +TV probes the pushed address, offers a verified alternate when it can't reach +it, and never signs in at an address that answers with another identity. A +newer push replaces one in flight. `serverResult.error` is a failure code +(`auth_failed`, `denied`, `expired`, `unreachable`, `identity_mismatch`, +`update_required`). People compare the TV's user code; `match_code` stays on +the wire for the phone's consistency check. Before an asynchronous session exchange, callers capture the intended server URL and an account identity generation under the identity transition barrier. Account @@ -36,11 +138,122 @@ Custom TokenManager implementations must implement atomic expectation capture and replacement; the default interface refuses a guarded installation rather than falling back to check-then-write. -Invitation transport remains on its existing v1 domain. Browser OAuth initiation -and callback and plugin launch/proxy are unchanged. Provider listing, OAuth -completion, password management and session-management UI have no active Android -consumer in this slice and are not introduced. Membership runtime activation is -separate; none of its producers or dispatch paths are enabled here. +Invitation transport remains on its existing v1 domain. Plugin launch/proxy is +unchanged. Password management and session-management UI have no active Android +consumer and are not introduced. Membership runtime activation is separate; none +of its producers or dispatch paths are enabled here. External sign-in is described +below. + +## External sign-in (OIDC, LDAP) + +Server contract: prairie-server `docs/auth-api.md` ("External sign-in", "OAuth +sign-in flows") and `docs/architecture/external-sign-in.md`. + +What a sign-in screen offers (`SignInOptions`, shared) comes from +`listAuthProviders` and `getOAuthHandshakeCapabilities`, both read without +credentials at the server's saved URL. The password form shows when +`password_login` is true or any credentials provider is listed; a directory +(LDAP) sign-in sends no `provider`, because the server routes a password login +by account. A server that answers without provider discovery keeps the +password form alone. The phone shows no password form while discovery runs; +when the providers or the handshake capability can't be read (a network +failure, a 5xx or a 429), it shows the password form with a retry for the +other options, and the TV keeps its password option. The phone adds a "Sign +in with <display_name>" button for each oauth provider with a native start +(`native_start_path`; a provider without it offers no native sign-in), on +a server whose handshake capability says `native`, and "Use a different +account" under them when it also says `select_account`. TVs never run a provider sign-in: they hide the password option when the server +lists no credentials provider and `password_login` is false, and sign in with +a phone instead. On a server with an oauth provider the TV's password form +says "If you sign in with <provider>, use your phone instead.", and so does +its wrong-password message. + +The phone's native flow (`androidApp/.../auth/`): + +- The native start always opens on the saved server's base URL. The app keeps + only the part of `native_start_path` from + `/api/v2/auth/oauth/{install_id}/native/start` on, with its query, and + appends it to the saved base, path prefix included. Any path prefix the + listing names is ignored, so the app never opens another origin + (`ExternalSignInApi.nativeStartPath`, `NativeSignInProtocol.startUrl`). +- The caller re-reads the saved server's `server_id` (`getServerIdentity`) + and passes it, with the saved URL, to `NativeSignInCoordinator.begin`. + `begin` binds the flow to the saved origin, makes a random `app_state` and + a PKCE S256 verifier, and keeps the flow in encrypted preferences for its + 10-minute lifetime, so it survives the process being killed while the + browser is open. The start URL opens in a Custom Tab, never a WebView. + "Use a different account", and the first provider sign-in on a server + after an explicit Prairie sign-out (Settings, the profile menu, "Not you? + Switch account"), add `prompt=select_account` when the handshake + capability says `select_account`; a sign-in after a session expired and a + linking flow never do. +- `NativeSignInCallbackActivity` receives `org.prairieserver.prairie:/auth/callback` + (scheme-only filter, no `autoVerify`; Android can't match a path without a + host, so `NativeSignInCallback.parse` accepts only that exact path). +- Any app can claim or send the custom scheme, so a redirect counts only when a + flow is pending, not expired, and its `state` matches in constant time. + Anything else changes nothing and leaves the pending flow for the real + redirect. After a match the flow is spent: `iss` (RFC 9207; the server sends + the origin where the native start first arrived) must be present and equal + the saved base's origin, compared in the form a browser sends as `Host` + (lowercase host, IDN names in punycode, IPv6 bracketed and compressed per + RFC 5952, default port omitted), or the code is discarded with + "This sign-in came back from a different server. Nothing was signed in."; + `server` must equal the saved server's verified `server_id`. The code is + redeemed once, at the saved base only and only while the saved server is + still at that origin, with the flow's verifier (`completeOAuthLogin`; + `completeAccountIdentityLink` for a `link=1` redirect, with the session that + asked for the link ticket). +- `iss` closes a relay through the browser: a hostile saved server whose + native start redirects the browser to the real server's native start gets + back a redirect naming the origin where the real server's start arrived, + its own, which is not the saved origin. It covers only relays the server + can't be tricked into recording. A hostile server can request the start + itself with its own origin as `Host`, so the server records a start origin + other than its public URL only where a server outside the local network + can't hold it: the origins of connected network access providers, + loopback, private, link-local and `100.64.0.0/10` IP literals, and + single-label, `.local`, `.lan`, `.localdomain`, `.home.arpa` and + `.internal` names; it refuses any other `Host`. A hostile saved server on + such an address can still relay. The rule and the residual are in + prairie-server `docs/architecture/external-sign-in.md` ("Native apps"). +- A sign-in never changes saved servers: a server saved by its LAN address + signs in on that address and stays there. The app never moves, re-keys or + duplicates a saved server because of a provider sign-in. +- `error=<reason>` and redemption problems map to text in + `NativeSignInMessages`, including `account_required` ("You don't have an + account on this server yet. Ask an admin to add you."); `invalid_grant` and + `invalid_token` both mean the flow starts again. Codes, verifiers, tickets + and tokens are never logged. + +Account settings "Sign-in" (`SignInSettingsViewModel`) shows only on a server +whose `getExternalSignInCapabilities` says `identities`. It lists the linked +identities and offers Disconnect (`deleteAccountIdentity`) unless the list's +`can_unlink` is false, in which case it says the identity is the account's +only way to sign in; the server still refuses the last sign-in method with +409 `last_sign_in_method`. "Connect" for an oauth provider (handshake `native` +and `linking`) confirms the local password with +`createAccountIdentityLinkTicket` and runs the native flow with `link_ticket`. +"Connect" for a directory (`credentials_linking` in +`getExternalSignInCapabilities`; directory linking needs no handshake) sends +the local password and the directory username and password to +`linkAccountIdentityWithCredentials`. Its two 422 refusals differ only by the +member named (`body.password`, `body.directory_password`); every other refusal +is branched on by problem code, including `local_password_required` and +`already_linked`. + +Password login maps `local_login_disabled`, `not_permitted`, +`password_expired`, `provider_unavailable`, `email_in_use`, +`identity_linked_elsewhere`, `account_required` and 429 to their own messages +on phone and TV. A refresh answered with 503 `provider_unavailable` (the +provider could not re-check the account) keeps the session like any other +5xx; the next request refreshes again. A request that needs a fresh bearer +(approving or declining a TV, on the phone or through a nearby TV, on a +pinned server or the active one; reading the account the approval card names; +a link ticket, a link's confirmation and linking with directory credentials) +whose access token has expired and whose refresh got that answer is not sent; +the screen says the provider can't be reached, that the person is still +signed in, and to try again. ## Email invitation claims diff --git a/docs/playback/02-migration-compatibility-validation.md b/docs/playback/02-migration-compatibility-validation.md index 037969b5c9..a082f80d9b 100644 --- a/docs/playback/02-migration-compatibility-validation.md +++ b/docs/playback/02-migration-compatibility-validation.md @@ -210,7 +210,8 @@ least: | Fixture | Required observation | | --- | --- | | H.264/HEVC SDR in MP4, MKV, and AVI | Direct play only for extractor/codec/seek combinations that pass; otherwise declared adaptation. | -| MOV/QT and TS/M2TS | Test direct, seeking, tracks, subtitles, and recovery; do not force remux solely from the extension. | +| MOV/QT and TS | Test direct, seeking, tracks, subtitles, and recovery; do not force remux solely from the extension. | +| M2TS/MTS (BDAV, 192-byte packets) | Expect server adaptation (remux), not direct play: Media3 cannot read 192-byte packets, so the client does not claim these containers. Test seeking, tracks, subtitles, and recovery on the adapted stream. | | HDR10, HDR10+, and HLG | A claimed HDR output requires correct planned delivery plus display/TV evidence, including after server adaptation. Without external evidence, record only planned/effective range. | | DV P5, P7 MEL, P7 FEL, and every P8 variant advertised by the server | The fixture manifest enumerates each claimed variant; initially include P8.1, P8.2, and P8.4 if advertised. Test decoder, plan, display mode, and visible output separately. Unsupported cases use a validated server transformation or `adaptation_unavailable`. | | E-AC-3/JOC, TrueHD/Atmos, DTS-HD, AC-3, and AAC | Pass when an advertised encoding/layout produces AVR-confirmed bitstream; otherwise the plan must declare PCM or server adaptation with no unclassified failure. | diff --git a/docs/playback/README.md b/docs/playback/README.md index c31d4e74fb..68e0cf46d6 100644 --- a/docs/playback/README.md +++ b/docs/playback/README.md @@ -36,6 +36,7 @@ and [migration guide](https://developer.android.com/media/media3/exoplayer/migra | [Reference review](03-reference-implementation-review.md) | Source-pinned Wholphin/Plezy observations. It is evidence, not another implementation plan. | | [Implementation status](04-implementation-status-and-dv-handoff.md) | Code, automated proof, dev-server v3 status, and the 4K Dolby Vision handoff checklist. | | [Intro skip](intro-skip.md) | Where the never/ask/always prompt lives, and the rules the server spec pins. | +| [Watch Party implementation plan](../watch-party-implementation-plan.md) | Room contract, player binding and catch-up, phone/TV experience, milestones and qualification. Server documents own wire semantics. | | [Shield 1080p capability audit](05-shield-1080p-playback-capability-audit.md) | Live protocol-v3 route matrix, catalog coverage, current direct-play gaps, and prioritized causes. | | [Device-correction evidence and design](06-device-quirk-evidence-and-design.md) | Current Jellyfin Android TV, Jellyfin Android, Wholphin, Plezy, Android platform, and issue evidence for the server/client quirk layer. | @@ -66,18 +67,38 @@ feature token has the same name. ## Native embedded subtitles Android advertises `embedded_subtitles_v1` only when the original-HTTP delivery -includes a `native_embedded` capability. The first supported pair is MP4 with -`mov_text`, using `container_track_id`. Media3 1.11.0 sets a timed-text format's -ID from the MP4 `tkhd` track ID; FFmpeg stream indexes are not Media3 track IDs. -See the pinned [Media3 MP4 parser](https://github.com/androidx/media/blob/1.11.0/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/BoxParser.java). +includes a `native_embedded` capability. Both supported entries use +`container_track_id`; FFmpeg stream indexes are not Media3 track IDs. + +- MP4 with `mov_text`: Media3 1.11.0 sets a timed-text format's ID from the MP4 + `tkhd` track ID. See the pinned [Media3 MP4 parser](https://github.com/androidx/media/blob/1.11.0/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/BoxParser.java). +- MKV with `subrip` and `ass`: `MatroskaExtractor` sets each track's ID to its + Matroska `TrackNumber`. See the pinned [Media3 Matroska extractor](https://github.com/androidx/media/blob/1.11.0/libraries/extractor/src/main/java/androidx/media3/extractor/mkv/MatroskaExtractor.java). + Remuxed files keep their original track numbers, so numbers can have gaps + and do not equal stream index + 1. The `ass-media` extractor subclass passes + formats through unchanged, so ASS keeps the same ID. The MKV entry's + `ass_styling` and `font_attachments` report whether libass renders the stream + with its attached fonts; when they are false and the user prefers preserved + styling, the server keeps ASS on its sidecar path. + +The server must record each Matroska subtitle's `TrackNumber` as +`container_track_id` (proposed in Prairie-Server/prairie-server#1890). Tracks without +a recorded ID keep using sidecars. MKV WebVTT (`S_TEXT/WEBVTT`) is not +advertised because FFmpeg does not recognize that codec, so the server cannot +identify those tracks. Media3 drops tracks with the legacy `S_ASS` and `S_SSA` +codec IDs, which FFmpeg still reports as `ass`; the server records no ID for +them, so they stay on sidecars too. + +Known cost of the stream path: after a seek or resume, a cue that began before +the cluster Media3 resumes from stays hidden until the next cue. A plan's `subtitle.embedded` selects the exact container track. Inventory URLs remain fallback descriptions and are not mounted alongside that selection. Phone and TV commit the preference only after the native track is mounted. Missing, ambiguous, or unsupported identities produce `subtitle_embedded_failed` -and let the server replan with its sidecar path. Matroska and tracks without a -probed container ID keep using sidecars; metadata similarity is not exact -identity evidence. +and let the server replan with its sidecar path. Tracks without a probed +container ID keep using sidecars; metadata similarity is not exact identity +evidence. Sidecar cues use absolute source timestamps. Android subtracts the plan's `timeline_offset_seconds` once, alongside the user's subtitle delay. Embedded diff --git a/docs/playback/sequenced-api-v2.md b/docs/playback/sequenced-api-v2.md index ef116a5154..b4a723bbf0 100644 --- a/docs/playback/sequenced-api-v2.md +++ b/docs/playback/sequenced-api-v2.md @@ -32,14 +32,21 @@ bytes of an unresolved sample. Stops persist one UUID and body before dispatch. Only a matching HTTP 200 stopped or replayed receipt confirms completion. Network errors and HTTP 503 responses -retain the request after the bounded stop retry sequence. A later start for -the same login, server and profile first runs recovery inline and only refuses -with `playback_pending` when the retained request still cannot settle; Settings -also exposes **Retry pending playback stops**. Recovery validates the installation, -canonical account, saved login, origin and profile, resolves an uncertain start -with its original attempt, and stops an allocated session without starting a -renderer. It does not stop a currently adopted in-process player. Identity -changes fence pending requests; stored requests grant no authority. +keep the stop in the journal after the bounded retry sequence, and playback +moves on regardless. The next start for the same login, server and profile +first resends any retained stop, then starts whether or not that succeeded. +Each start makes one attempt per retained stop and ends that pass at the first +failure, so a server that is not answering stops cannot hold a start for longer +than one request. It never waits on earlier sessions: the server allows a new session while an older +one is still open and expires a session nobody stops. It does not stop a +currently adopted in-process player. Identity changes fence pending requests; +stored requests grant no authority. + +A start whose reply was lost is dropped, not replayed. A replay would open a +session the server may never have created just to stop it, and the server +expires any session the lost request did open. Retained requests recorded under +an origin, account or installation this server no longer reports can never be +acted on again, so the next start drops them as well. The app-private journal uses atomic writes and a process ownership lock and is excluded from Android backup. It stores request identities and bodies without @@ -47,11 +54,6 @@ credentials. Clearing application storage removes this recovery state. Existing queued intents retain their original bytes and authority; they are not converted, reset or replayed as new v2 intents. -Generic errors, including HTTP 409, 422 and 503, do not prove that an earlier -allocation is absent. They retain uncertain START requests. Recovery may replay -the exact retained attempt under its original validated authority; it must not -rebase the request, allocate a replacement attempt or fall back to v1. - ## Audiobooks The server has no whole-book timeline. The audiobook player stitches the @@ -74,7 +76,7 @@ only canonical positive IDs representable by its integer model. Focused tests cover persisted commands, receipt validation, lost replies, restart recovery, identity changes and shared lifecycle behavior. -Phone and TV builds check caller and Settings integration. These checks do not +Phone and TV builds check caller integration. These checks do not establish rendered media behavior, device process-kill durability, live server admission or physical TV acceptance. Actual isolated native media validation is a separate requirement; client conformance does not activate the server runtime. diff --git a/docs/shuffle-api-v2.md b/docs/shuffle-api-v2.md new file mode 100644 index 0000000000..0b4e7ad756 --- /dev/null +++ b/docs/shuffle-api-v2.md @@ -0,0 +1,55 @@ +# Shuffle + +Shuffle plays random movies and episodes from a library, a series, a season, or +a collection until the viewer stops. The server picks every item and applies +the profile's access and parental limits; the client never chooses or orders +picks. The contract is the server's `docs/playback-api.md` ("Shuffle"). + +## Client pieces + +- `ShufflesV2Api` (shared) wraps the six `/api/v2/shuffles` operations. Every + call is pinned to the profile that made it; mutations are single-attempt. +- `ShuffleFeatureStore` (shared) holds the capability. Entry points appear only + for scope kinds listed in `scope_kinds` while `state` is `available`. A `404` + from an older server hides them. Both shells reset and refresh it on every + server or profile change. +- `ShufflePlayback` (shared) is one running shuffle inside a player. It keeps + the last shuffle the server returned, and treats `409` from a read as "nothing + in the scope can play any more". Other failures keep the last pick. + +## Entry points + +| Scope | Phone | TV | +| --- | --- | --- | +| Library (movie, TV, mixed) | Shuffle chip before the library tabs | Shuffle pill in the Library tab's Sort/Filter row | +| Series | "Shuffle Series", first in the series overflow menu | "Shuffle Series", first in More Actions | +| Season | "Shuffle Season N" after it, shown with two or more playable episodes | Same, in season mode only | +| Server collection | Top-bar Shuffle button | Shuffle pill in the collection's control row | +| User collection | Top-bar Shuffle button | Shuffle pill beside the title | + +Neither app has a separate season screen: a season is the series page with that +season selected. Android TV has no screen for mixed libraries. + +## Player rules + +- The player route carries `shuffleId`. Every pick starts at `0.0` (the start + over position), and a multi-part item opens at its first part. +- While a shuffle plays, the player resolves no sequential next episode. The + phone tabletop Next button, the TV Up Next transport button, and the + PrairieCast `hasNextEpisode` flag are off. +- The up-next card reuses the next-episode flow and countdown. It reads + "Shuffling <scope>" (a season reads "<series> · <season>"), shows a movie by + its title without an S·E line, and adds Pick Another and Stop shuffling. +- The card announces the `next` the server returned. When the card opens, the + player reads the shuffle again so the server can replace a pick that can no + longer play. When `next` equals the item playing, or the read answers `409`, + the card shows Finished. +- Play Now and the countdown call `advance` with the item that played, then + play the returned `current`. Pick Another calls `skip`; a new pick restarts + an at-end countdown. Stop shuffling deletes the shuffle and leaves the + player. +- A multi-part pick plays each later part in place before the card appears. +- Leaving the player with Back keeps the shuffle on the server. Playing an On + Deck item from the phone card leaves the shuffle. +- Starting a shuffle opens the local player; it is not launched on a + connected PrairieCast target. diff --git a/docs/watch-party-implementation-plan.md b/docs/watch-party-implementation-plan.md new file mode 100644 index 0000000000..59d7ad5138 --- /dev/null +++ b/docs/watch-party-implementation-plan.md @@ -0,0 +1,1032 @@ +# Android Watch Party implementation plan + +Build Watch Party for Android phone and Android TV on one shared room owner and +one shared player binding over the existing Media3 players, with native Compose +screens. Follow the current server contract, including its September 23 +buffering and catch-up policy. Use the merged Apple client as the reference +native implementation. Ship behind a device-local Experimental toggle, off by +default in release builds. + +Status: planning only. No Android implementation or playback qualification is +claimed here. This plan replaces the September 21 draft, which predates the +server's buffering policy and Apple's merged client. The June and July Watch +Together plans under `docs/superpowers/` remain history. +[Android playback architecture](playback/README.md) continues to own Media3, +playback-v3, timeline mapping, and output-route behavior. Tracking issue: +[Android #355](https://github.com/Prairie-Server/prairie-android/issues/355). + +## What changed from the September 21 draft + +- The server now has a buffering and catch-up policy: a 2-second buffering + grace, `self_ignore_wait`, `ready` while the room plays or is paused, no + position reports while stalled, rate convergence, and paced correction + reloads. The draft's 500 ms stall rule and "no second drift controller" rule + contradicted it. +- The snapshot anchor is already projected to send time. Projecting it again + counts elapsed playback twice (Apple fixed this in #347). +- A host who leaves ends the room two minutes later unless they rejoin. There + is no leave route; the room closes two minutes after the host's socket drops. +- Socket rotation needs a token refresh path for the ticket mint. The draft's + `singleAttempt()` rule would have ended the party at an ordinary rotation. +- Exposure follows Apple: an Experimental toggle, not a debug-only build flag. +- The player section lists every Android input and session-replacement path, + including code that landed after the draft (seek intervals, extras, named + editions, Force HDR passthrough). +- UX adds in-playback status, host-pick suggestions, end-of-file handling, + a lower-quality offer, other-playback rules, and a background table. + +## Sources and baselines + +| Source | Revision | Use | +| --- | --- | --- | +| Android | `main` at `03ac5b71` (2026-09-24) | Code baseline. Watch Together sources are unchanged since `6e26ae46`; the player gained seek intervals (#365), extras (#369), Force HDR passthrough (#360), and named editions | +| Server | `main` at `2812ac39` (2026-09-24) | Contract owner: [room phases](https://github.com/Prairie-Server/prairie-server/blob/2812ac390829bec2b64328dcee29f0eb15a9b808/docs/architecture/watch-together-rooms.md), [realtime API](https://github.com/Prairie-Server/prairie-server/blob/2812ac390829bec2b64328dcee29f0eb15a9b808/docs/realtime-api.md), [synchronization and buffering policy](https://github.com/Prairie-Server/prairie-server/blob/2812ac390829bec2b64328dcee29f0eb15a9b808/docs/architecture/watch-party-synchronization.md), [capabilities](https://github.com/Prairie-Server/prairie-server/blob/2812ac390829bec2b64328dcee29f0eb15a9b808/internal/apiv2/watch_together_capabilities.go) | +| Web reference client | Same server revision | Client timing reference: [`useWatchTogetherPlaybackSync.ts`](https://github.com/Prairie-Server/prairie-server/blob/2812ac390829bec2b64328dcee29f0eb15a9b808/web/src/player/hooks/useWatchTogetherPlaybackSync.ts), [`roomSyncCatchup.ts`](https://github.com/Prairie-Server/prairie-server/blob/2812ac390829bec2b64328dcee29f0eb15a9b808/web/src/player/utils/roomSyncCatchup.ts) | +| Apple | `main` at `5d9dc764`, including [#347](https://github.com/Prairie-Server/prairie-apple/pull/347) | Native reference: session, playback adapter, end of file, Experimental gate, host-pick suggestions | +| Shared-dev | Record at every run | Must run server code that includes `9482302b` and advertise every required capability | + +When sources disagree, server code wins, then server docs, then the web +client, then Apple. Apple still lags the server on buffering: it reports +buffering after 0.5 s and has only a small 0.95x/1.05x correction, with no +catch-up acknowledgement, reload budget, or lower-quality offer. Follow the +server there. + +On 2026-09-24, shared-dev's `/api/v2/admin/system/build` returned +`available: false`. Read the running image and revision through the runtime +and fleet tooling instead. The last recorded inspection (2026-09-22) showed +one API node with workers disabled. That runtime cannot qualify HLS, transcode, +remux, or cross-node cases. + +## Product decisions + +| ID | Decision | Basis | Status | +| --- | --- | --- | --- | +| D1 | Add a device-local **Settings → Experimental → Watch Party** toggle on phone and TV. Debug builds default on; release builds default off. The section is visible to everyone. Turning it off hides every entry point, ignores invitations, and leaves an active party (for a host, the room ends two minutes later unless they rejoin). A server reporting `allowed=false` or missing capabilities is the remote kill switch. | Apple #347 and `5d9dc764` | Agreed 2026-09-24. Record it in `AGENTS.md` "Current Product Exposure" in the change that adds the toggle | +| D2 | Everyone gets **Leave party**. The host also gets **Return everyone to lobby** (Stop) and **End party for everyone**. A host's Leave explains that the party ends two minutes after they leave unless they rejoin. | Apple's room menu; no leave route; 2-minute host grace | Adopted | +| D3 | No new foreground service keeps the room socket alive. Background behavior follows the table under "Background and process death". Background hosting is not promised. | 2-minute host grace | Adopted | +| D4 | Invitations: room code everywhere; phone share and copy of the server's `https` invitation link (custom-scheme links aren't clickable in Messages or email); a paste parser; a TV QR code of the link. The app link is `prairie://watch-party?server=<server URL>&token=<join token>`, and it resolves to the same invitation as the web link. Tapping a shared `https` link opens the app only after the server's join page offers that app link. | Server `invite_path`; [Apple #409](https://github.com/Prairie-Server/prairie-apple/issues/409); format proposed in [Apple #412](https://github.com/Prairie-Server/prairie-apple/pull/412) | Not final. Build against #412's format; freeze it before the M5 link handling merges | +| D5 | While engaged, solo playback (detail Play, extras, launcher and deep links, PrairieCast launch) asks the user to leave the party first. An invitation that arrives while engaged explains that the user must leave first. | Invitation message from Apple #347; the solo-playback guard is an Android decision (one shared service player) | Adopted | +| D6 | Catch-up follows the server policy and the web client: 2-second buffering grace, rate convergence between 0.9x and 1.25x kept separate from user speed, paced correction reloads, and a lower-quality offer. User playback speed is a session-only 1x. | Server sync doc; web client | Adopted | +| D7 | Party PiP, Google Cast, and external players stay disabled in rooms until each route is qualified. Starting party playback ends a solo PiP session. The party player blocks Cast connection. | Apple disables party PiP and AirPlay and ends solo PiP; Cast and external players are the Android equivalents | Adopted | +| D8 | In a party, intro and credits never skip automatically. Members who may seek get the Skip Intro offer (sent as a room seek) even when their setting is Always. Members who may not seek get no intro or credits pill. | Apple #347 | Adopted | +| D9 | Suggestions work in both modes. In a host-pick lobby, the host's **Queue** stages a suggestion and labels it "Up next"; it never promotes. While engaged, a guest's detail page offers **Suggest to Party**. The host's detail action stages the item in the lobby and selects it while playing. Android adds a confirmation before a selection that replaces what everyone is watching. | Web client; Apple #347 (Apple selects without a confirmation) | Adopted | + +## Scope + +| Area | Required behavior | +| --- | --- | +| Entry | Experimental toggle (D1); profile-menu hub; More action on movie and episode details and series (resolves to the next-up episode); Return to Party while engaged. Navigation tabs are unchanged | +| Host | Host Picks by default; create an empty room, or create and stage the chosen item; the host may start alone | +| Join | Room code, pasted invitation, validated invitation link, explicit recent-party rejoin; deferred sign-in and profile selection are preserved | +| Lobby | Empty or staged item, members and seats, advisory lobby Ready, host Start, mode switch, guest control policy, options menu with End as the only destructive action | +| Suggestions | Add, remove (own, or any as host), vote and unvote, winner display, host override when advertised, host-pick Queue (D9) | +| Picker | Server picker rows, search, series, season and episode navigation, selected-candidate member state | +| Playback | Server-owned play, pause, seek, and source; per-viewer delivery, quality ladder, audio, subtitles, and volume | +| Catch-up | Server buffering policy with client rate convergence, reload budget, and lower-quality offer (D6) | +| Party controls | Phone sheet and TV overlay over retained playback; Back opens them; Stop, End, and Leave per D2 | +| Status | Waiting and catching-up overlays, left-behind and host-disconnected notices, delayed reconnect notice, denied-action notices | +| Invitations and recents | D4; one recent party per identity for 24 hours | +| Recovery | Socket rotation, proof renewal, replacement and rejoin, identity cleanup, session reattachment, coordinated source fallback | +| Next episode | Optional, last: an explicit host Next Episode action in host-pick rooms through the selection operation | + +Out of scope: downloads, live TV, music, audiobooks, ebooks, chat, reactions, +kicking members (no server endpoint), host transfer, automatic episode queues, +activity feeds, and a room-aware PrairieCast launch handoff. Admin surfaces stay +hidden. Suggestions are not a queue. + +## Server contract + +All paths are under `/api/v2`. Wire names keep `watch-together`; user-facing +text says Watch Party. + +### Capabilities + +Two documents gate the feature. Cache each within the captured authority, and +refresh on a server, account, or profile change. + +- `GET /watch-together/capabilities` (account authentication, ETag + revalidation): `state`, `allowed`, `socket_protocol`, `connection_replaced`, + `lobby_ready`, `staged_selection`, `stop_playback`, `selection_mode_switch`, + `picker`, `member_state`, `max_member_state_ids`, `vote_host_override`. +- `GET /playback/capabilities` (profile authority; PIN profiles also need their + profile token): `features[]` contains `watch_party_coordinator_v1`, + `fixed_media_file_v1`, and `watch_party_source_fallback_v1`. + +Entry requires room `state=available`, `allowed=true`, +`socket_protocol == "prairie.room.v2"` (an empty string means no v2 socket) and +`connection_replaced=true`, plus a playback document that is available, allowed, +and lists the coordinator and fixed-file features. Unknown phases, roles, or +policies grant no actions. + +| Flag false or absent | Behavior | +| --- | --- | +| Required baseline | No create, join, or room playback. Show "not supported" or "not allowed"; a failed probe shows Retry and keeps any known state | +| `staged_selection` | Disable hosting and staging; joining an existing room still works. Never present direct selection as staging | +| `stop_playback` | Hide Stop; keep End and Leave | +| `selection_mode_switch` | Hide the mode switch | +| `lobby_ready` | Hide lobby Ready; never substitute playback readiness | +| `picker` | Use ordinary authorized catalog browsing | +| `member_state` (`max_member_state_ids` = 0) | Omit member watch state; never infer it from local history | +| `vote_host_override` | Hide promotion entirely. Without it, promote returns 503 | +| `watch_party_source_fallback_v1` | Keep the playback refusal visible with Retry and Leave; never pick another file locally | + +With the coordinator feature, omitted member status booleans mean false. +Without it, absence is not evidence of readiness. + +### Operations + +"Proof" means the `X-Room-Token` room access token. Retry classes come from +each operation's server `RetrySafety`. + +| Operation | Route | Needs | Retry class | +| --- | --- | --- | --- | +| Create room | `POST /watch-together/rooms` with client UUID `room_id`, `selection_mode` (`host_pick` or `vote`); returns 201 | Account and profile | Replay the same ID and body | +| Join | `POST /watch-together/join` with `code` or `join_token` | Account and profile | Idempotent; each call can return a different proof | +| Read and renew | `GET /watch-together/rooms/{id}` | Proof | Idempotent; returns a renewed `room_access_token` | +| End | `DELETE /watch-together/rooms/{id}` | Host account and profile | Converges on ended | +| Policy | `PATCH .../policy` (`host_only` or `guest_play_pause`) | Host | Idempotent; use the returned policy | +| Stage | `PUT .../staged-selection` | Host | Idempotent | +| Start | `POST .../playback/start` | Host | **Non-retryable** | +| Stop | `POST .../playback/stop` | Host | Idempotent | +| Mode | `PATCH .../selection-mode` | Host | Idempotent | +| Select | `PUT .../selection` (`content_id`, optional `file_id`, `library_id`) | Host; not in vote rooms | **Non-retryable** | +| Source fallback | `POST .../source-fallback` (`selection_revision`, `failed_file_id`, `reason`) | Proof and a connected member | Fenced; a stale call returns the current snapshot | +| List suggestions | `GET .../suggestions?limit=&cursor=` (limit 1–200, default 50) | Proof | Idempotent | +| Add suggestion | `POST .../suggestions` with client UUID `suggestion_id` | Proof | Replay the same ID and body | +| Remove suggestion | `DELETE .../suggestions/{sid}` | Proof; host or suggester (account and profile) | A repeat returns 404 | +| Vote and unvote | `POST` and `DELETE .../suggestions/{sid}/vote` | Proof | Returns 204, including when already in the requested state | +| Promote | `POST .../suggestions/promote` | Proof and host; hidden without `vote_host_override` | **Non-retryable** | +| Member state | `POST .../member-state` with 1–`max_member_state_ids` IDs | Proof | Idempotent read | +| Picker | `GET .../picker` | Proof | Idempotent read | +| Socket ticket | `POST .../ws-ticket` | Proof and an expiring access session | Safe to repeat | +| Socket | `GET .../ws` upgrade with the ticket as a subprotocol | Ticket | Not applicable | + +End, policy, stage, start, stop, mode, and select need host identity, not +proof. Promote needs both. Every successful room response carries a renewed +`room_access_token`: create, join, read, policy, selection, stage, start, stop, +mode, promote, and source fallback. + +The server gives only the host an `invite_path`: a relative web path, +`/rooms/join?token=<24-character token>`. Room codes are 8 characters from +`ABCDEFGHJKLMNPQRSTUVWXYZ23456789`. The server trims input and matches codes +without regard to case, but it does not strip separators. + +### Socket frames + +- Server to client: `snapshot`, `transport_command`, `suggestions_update`, + `pong`, `error` (code `bad_request`), `connection_replaced`, and + `room_closed`. At connect time, `room_closed` carries `not_found` or `ended`. + Every later close, including the host's explicit End, carries `host_left`, + so the reason cannot tell End apart from grace expiry. +- Client to server: `attach_session`, `transport_request`, `state_report` + (optional `command_id` and `is_ready`), `ready` (`session_id`, + `position_seconds`, `is_paused`, `command_id`), `buffering`, `lobby_ready` + (`ready`), `ping`. There is no `client_state` frame. +- Ignore unknown frame types. A malformed frame of a known type triggers a + logged room read, not the end of the party. Apple ends the party here and + lists that as an open issue. +- `suggestions_update` carries the common list in `vote_count` descending, then + `created_at` ascending order, with every `voted_by_me` false. That field is + not authoritative; personal votes come from HTTP reads. + +Model these snapshot fields: `room_id`, `phase` (`lobby`, `playing`, `ended`), +`playback_state` (including `waiting`), `selection_mode`, `selection_revision`, +selected content, file, and library IDs, `code`, `guest_control_policy`, +`is_paused`, `anchor_position_seconds`, `anchor_updated_at`, `generation`, +`member_count`, `host_connected`, `self_role`, `self_can_control_transport`, +`self_can_manage_room`, `self_ignore_wait`, `attached_session_id`, +`invite_path`, and `members[]` with `user_id`, `profile_id`, `display_name`, +`is_host`, `is_self`, `connected`, `lobby_ready`, `is_ready`, `is_buffering`, +and `is_syncing`. Take the complete shape from the fixtures. Android's `RoomSnapshot` has no `members` today. HTTP v2 IDs are +strings; socket frames send several of them as numbers. + +### Timing and limits + +| Owner | Value | +| --- | --- | +| Server | Ticket lifetime ≤ 30 s (`expires_in` is 0 when the access token or the room proof has under a second left). Socket closes at the earliest of 5 min, access-token expiry, or proof expiry. Room proof is an HS256 JWT with `exp` 24 h after issue. Authority recheck every 15 s | +| Server | Host-disconnect grace 2 min. Presence lease 45 s. Idle room expiry 24 h. Socket write queue 64 frames; a full queue closes the socket | +| Server | Waiting deadline 10 s, and only once at least one attached member is ready; past it, the first `ready` resumes the room. Seek tolerance: guest 1 s, host 15 s (a host more than 1 s away re-anchors the room). Maximum command lead 5 s | +| Server | Buffering pauses the room only if the viewer has not stalled in 5 min, the room has not paused for buffering in 1 min, and the viewer is not already catching up. A lone viewer's stall always pauses and waits past the deadline. A buffering member gets no corrections for up to 30 s. A `state_report` within 1 s of the room with the same pause state marks the member ready. A host drifting 2 s or less is corrected like a guest; a larger jump or a pause mismatch moves the room | +| Server | `max_member_state_ids` = 200. Picker rows are capped at 20 and 30. Suggestion pages default to 50 | +| Client (web reference; tune only with evidence) | State reports every 1.5 s. Readiness retry every 500 ms. Ping every 15 s. Command quiet period 250 ms. Buffering report after 2 s of unplayable media. Catch-up band 2 s, deadband 0.35 s, rate 0.9–1.25x at `1 + drift / 8 s`. Reload budget: one at a time, 10 s doubling to 60 s, 30 s stale limit, lead ≤ 10 s, capped at the media duration. Reconnect notice after 2 s disconnected. Lower-quality offer after 2 sustained stalls within 5 min | + +### Errors + +| Response | Handling | +| --- | --- | +| 401 | Retry-safe operations, including the ticket mint: refresh under the same logical authority once, then resend. This follows Apple; the web client treats a ticket 401 as terminal, and the server marks the mint naturally idempotent. Non-retryable operations: refresh before sending when the token is near expiry; a 401 leaves the outcome uncertain, so reconcile | +| 403 `permission_denied` or `profile_verification_required` | Proof or authority failure on membership operations ends the engagement. A guest calling a host operation gets an action-level denial | +| 404 | On a room operation, the room is gone: end the engagement. On Join, an unknown code or token: action-level. A missing suggestion: action-level | +| 409 | Ambiguous: it means both a closed room and an ordinary conflict (vote room, nothing staged, already playing, no fallback candidate, reused ID). Reconcile with `GET /rooms/{id}`; a 409 there means closed | +| 422 | Action-level invalid input, such as a missing code or an unplayable selection | +| 429 `rate_limited` | Action-level; honor `Retry-After` | +| 503 | Transient; retry reads, and reconcile mutations | +| Malformed 2xx | `invalid_response`. For a mutation, the outcome is uncertain: reconcile, and never report success or failure blindly | +| Ticket `expires_in: 0` | Recoverable: refresh the access token or renew the proof, whichever is expiring, then mint again | +| Ticket 403, 404, 409, 422; `room_closed` at connect | Terminal | + +Use `detail` text for display only, never for control flow. + +## Design and ownership + +| Boundary | Responsibility | Code to evolve | +| --- | --- | --- | +| Wire and API | Typed capabilities, operations, frames, response validation, retry classes, error mapping | `shared/.../model/watchtogether/WatchTogetherModels.kt`, `network/api/WatchTogetherApi.kt`, `network/WatchTogetherRealtimeClient.kt`, `network/apiv2/ApiV2Call.kt` (`ownedV2Call`) | +| Room engagement | One app-level engagement: authority, membership, proof, socket, reconnect, clock, snapshot ordering, actions, votes, recent party | `shared/.../watchtogether/RoomSession.kt`, `repository/WatchTogetherRepository.kt`, `RoomTransportAuthority.kt`, `RoomVote.kt`, `WatchTogetherEntryPolicy.kt` | +| Player binding | Room playback context, paused preparation, attach, command scheduling, readiness, reports, catch-up, reload budget | New `android-shared/.../player/watchtogether/`; absorbs `RoomSyncEngine.kt`, `RoomDeliveryLatch.kt`, `RoomSyncStateReportGate.kt`, and the duplicated mechanics in both controllers | +| Player adapters | Connect the binding to each player: observed position, completed load and seek, buffering, media mount identity, input gating | `RoomSyncController.kt`, `TvRoomSyncController.kt`, both player ViewModels, `MobileVideoPlaybackStarter.kt`, `TvVideoPlaybackStarter.kt` | +| Shared presentation | Entry, lobby, suggestion, and picker state and actions; eligibility; pending and error state; domain destinations | Merge the duplicated phone and TV ViewModels into `shared/` or `android-shared/` | +| Platform UI | Phone layout and sharing; TV layout, focus, and QR; route construction | Both `ui/screens/watchtogether/` directories, menus, detail actions, settings, navigation | + +The application owns one engagement. Its room socket survives ordinary +navigation. The player binding owns only the current playback epoch and its +player observers. The room socket and the playback-control socket keep +independent connection state and recovery. + +Keep these identities distinct: + +- **Authority:** verified server (base path included, matched with + `AndroidServerRegistry.serverIdsMatch`), `loginId`, profile, PIN scope, and + identity generation. Unlocking the same PIN profile again does not change the + owner. +- **Membership:** authority, room ID, and engagement generation. Joining the + same room again creates a new membership. +- **Proof:** the renewable room token and its `exp`. Renewing proof never + changes membership. +- **Socket attempt:** connection epoch. +- **Playback epoch:** membership plus `selection_revision`. +- **Attachment:** playback epoch plus the committed playback session ID. +- **Command:** command ID, with pending and applied state. + +Every asynchronous result checks the identities its effect depends on. +Replacing the account, profile, PIN authority, or server cancels old work +before the new identity can use it. + +### Room transitions + +| Accepted state or event | Client action | +| --- | --- | +| Lobby, no selection | Empty lobby; no player | +| Lobby, staged item | Staged lobby; no player | +| Stage, restage, or mode switch | Update the lobby and lobby Ready; no playback epoch | +| Playing with a new `selection_revision` (Start, Select, Promote, fallback) | Cancel the old epoch; prepare the exact selected file, paused | +| Playing with a membership-only or anchor-only update | Update room state; keep the player | +| `playback_state=waiting` | Apply the waiting command; acknowledge readiness (see Readiness) | +| Playing or paused with `self_ignore_wait` or own member `is_buffering` | Catching up (see Buffering) | +| `host_connected=false` while engaged | Tell guests the party ends two minutes after the host drops off unless the host returns | +| Host Stop | End the playback epoch; show the retained staged lobby; keep membership | +| Ended snapshot or `room_closed` (any reason) | End the engagement, player, reports, commands, and reconnects. Say the party ended; say who ended it only when this device did. Clear the recent party. No current server path sends an ended snapshot, but keep the check | +| `connection_replaced` | End this device's engagement; offer explicit Rejoin; the room continues | +| Leave or identity replacement | Cancel work, stop the room player, close the socket, clear identity-scoped state. For a host, the room ends two minutes later unless they rejoin | + +Snapshot acceptance rejects results from an older room, authority, revision, or +generation. An HTTP response with the same generation must not erase a newer +socket attachment or readiness update: track socket receipt order as well as +generation. A stale response cannot reopen a terminal engagement. Accept a +renewed proof from a response even when its snapshot body is stale. + +## Playback rules + +### Room playback context and start + +- Build the context from authority, room, `selection_revision`, content, file, + library, the snapshot's paused state, and `anchor_position_seconds` **as + sent**. The server has already projected it; the command sent after attach + corrects the remaining drift. Resolve series or season entry to a playable + episode before building the context. +- Pass the context through both starters. Room starts send + `allow_alternate_versions: false`, and only to servers that advertise + `fixed_media_file_v1`. The field is nullable so solo starts omit it. The + server stores it with the attempt and keeps it through every replan. Refuse + any plan for a different file. +- Room playback always streams: bypass downloaded copies. Never fall back to + personal resume or a preferred alternate version. If the chosen file is + inaccessible, fail with a clear message and follow the fallback rules below. +- Prepare paused. Nothing plays until an accepted room command says so. + +### Session replacement paths + +Each of these paths can create a new playback session. Each must carry the room +context, start paused at the room position, keep the fixed file, and hold +reports until the matching attach echo arrives: + +- 404 session renewal, the only path that uses `StartParams` today + (`PlaybackSessionLifecycle.kt`). +- `replanActiveVideoSession` (`PlaybackSessionManager.kt`), reached from a + phone output-route change, an embedded-subtitle failure, an audio failure, a + subtitle inventory change, a player error, and a queued re-drive. +- `stageActiveVideoSessionReplan` (`MobileSubtitleTransactionAdapter.kt`, + `TvSubtitleTransactionAdapter.kt`), reached from user subtitle changes and + the TV output-route change. +- `onUnsupportedPlayback` in both ViewModels. +- Seek recovery and seek re-anchoring in `PlaybackSessionManager.kt` and both + ViewModels. +- Phone full restarts: `startVersionPlayback`, subtitle-adoption recovery, and + `replayLoad` (Retry, Try Anyway). +- TV full restarts: `restartSessionInPlace`, reached from the version picker + and the Dolby Vision toggle; `retry` and the reachability retries call + `loadContent` directly. + +Both ViewModels currently publish `isPaused=false` when the player becomes +ready. Room playback must not. + +### Attach and report + +- Attach the committed session after every room-socket epoch and every session + ID change. Wait for the matching `attached_session_id` before reporting. A + playback-control socket reconnect for the same session needs neither. +- Send a `state_report` every 1.5 s built from observed player state: the + source-timeline position from the existing timeline mapper, and whether the + player is actually playing. Today's reports send the intended paused state and + an optimistic position; replace that. +- Send no report while stalled, while the media is unplayable and a readiness + acknowledgement is pending, during provisional preparation or replacement, + during a local suspension, or before re-syncing after one. + +### Commands and clock + +- Apply accepted commands through an internal path, separate from user + transport requests, so they are never echoed back as intent. +- Sample the clock with ping and pong from socket open, before a player + attaches. Stamp pong receipt at transport ingress and fence samples by + connection epoch. Reject invalid samples and detect clock discontinuities. + Pings run every 15 s. +- Schedule with the server-clock estimate and a monotonic delay (at most the + 5 s lead). A late Play advances its target by the elapsed server time. With + no valid sample, stay paused at the snapshot anchor, obtain one, and bound + that wait with a recovery offer instead of assuming zero offset. +- Track pending and applied command IDs. A superseding command cancels delayed + work. A cancelled command never executes later and is never acknowledged. + Recheck ownership immediately before execution. + +### Readiness while the room waits + +- Readiness is level-triggered and keyed to the current command, epoch, and + session. Evaluate it on every Media3 signal that can mean playable media + (`STATE_READY`, a seek discontinuity, `onIsPlayingChanged`, first frame) and + on a periodic tick. It requires: the latest command executed, no pending + seek, playable media, and, for an explicit seek, an actual position within + 1 s of the destination (15 s for the host). +- Send `ready` with `command_id`, the actual position, and the pause state. + Retry every 500 ms, and also send `state_report` with `command_id` and + `is_ready: true`, until a fresh snapshot on the current connection marks the + own member `is_ready`. Socket delivery alone does not settle it, and neither + does an `is_ready` that predates the command. Then return to ordinary + 1.5 s reports. +- A second seek on the same session re-arms readiness even if buffering never + changes and the room stays waiting. + +### Buffering and catching up + +- Stalls under 2 s stay local. After 2 s of unplayable media while the room + plays, send one `buffering`. Recovery, a new command or session, a pause, a + phase change, a disconnect, or disposal cancels a pending report. +- The room is catching up for this viewer when the phase is `playing`, the + playback state is `playing` or `paused`, and the snapshot shows + `self_ignore_wait` or the own member `is_buffering`. Once the latest command + has executed and the media is playable, send `ready` and retry every 500 ms + until the own member is `is_ready`. The server clears the status. While the + room plays, it then sends a command at the room position; a paused room + sends none. A viewer catching up with nobody else watching moves the room to + its own position instead. +- A late joiner or a replaced session does not pause the room. It syncs alone, + and its startup counts as that viewer's stall. + +### Corrections + +Follow `decideRoomCatchup` in the web client: + +1. Explicit seek commands always seek, immediately, outside any budget. +2. Drift within 0.35 s: do nothing. +3. A target the player can reach in the current stream: seek. +4. A pause correction that needs new media: do nothing. The next Play realigns. +5. Drift over 2 s: seek, including a re-anchor or replan. +6. Otherwise converge by rate: `1 + drift / 8 s`, clamped to 0.9–1.25x, + tracking the room position as it advances, until within 0.35 s. Then reset + to exactly 1x. + +A Play correction whose target isn't buffered, and so needs a range request or +a stream rebuild, goes through the reload budget. Only one runs at a time. The +next waits 10 s after the previous one started playing, doubling to 60 s until +the viewer converges. An unlanded reload stops blocking after 30 s. Each +reload aims ahead by the previous load time (at most 10 s) and never past the +media duration. A quality change restarts the budget. + +The correction rate is not a user speed. It is never saved or shown as a speed +change, and it always returns to 1x. Media3 may not apply speed changes with +audio passthrough, offload, or tunneling. Verify this on TV. Where speed cannot +change, use a seek inside the reload budget instead. + +After 2 sustained stalls within 5 minutes, offer one step down the quality +ladder of the same file, once per quality. Withdraw the offer if no lower rung +remains or the quality changes. + +### Host reports and local suspensions + +A host report can move the whole room: a jump of more than 2 s or a pause +mismatch re-anchors it. The host must never report a position that isn't a +decision. These events are local suspensions, not room intent: + +| Event | Where today | +| --- | --- | +| Audio focus loss or becoming noisy | Deliberately not reconciled (`PlayWhenReadyReconciliation.kt`) | +| Sleep timer | Sets `isPaused` directly (both ViewModels) | +| TV `ON_PAUSE` and `ON_STOP` | `TvPlayerScreen` pauses, then exits and leaves the room on stop | +| Phone background | Keeps playing; automatic PiP entry is not room-gated | +| Cast connect | Calls `remotePause` | +| Subtitle preparation that holds playback (Apple's subtitle AI case) | Not confirmed on Android: the subtitle remount keeps `playWhenReady`. Audit every subtitle path in M3 | +| Stall recovery (seek back, re-prepare) and the audio-sync no-op seek | Local recovery actions | +| Output-route change | Replans | + +During a suspension, send no reports and no transport requests. On resume, +re-sync before reporting. While the room plays, re-attach the current session: +the server answers with a command at the projected room position (or the +waiting command) and clears this member's catching-up state. While the room is +paused, the server sends nothing; apply the snapshot anchor locally. Guests +follow the same rule. Apple asks the room to resync after subtitle AI +preparation. + +### Input permissions + +Route every input through one decision. Guests never seek; play and pause +follow the room policy. A denied input changes nothing locally and shows a +notice. An input during a reconnect shows an immediate notice; it is neither +dropped silently nor queued ([Apple #410](https://github.com/Prairie-Server/prairie-apple/issues/410)). + +| Input | Today | Required | +| --- | --- | --- | +| Touch transport, gestures, scrubber, D-pad and media keys | Partly gated | Room request | +| Chapter selection (phone) | Bypasses the gate | Room seek request (host) | +| Intro and credits pill | Phone pill bypasses the gate; phone auto-skip is not pinned (TV pins it to Ask) | D8 | +| MediaSession play and pause, notification, headset, Assistant | Play and pause reconciled | Room request | +| MediaSession seek back and forward via `SeekIntervalForwardingPlayer` | Calls `seekTo` with no room check | Room seek request | +| PiP actions (`PrairiePlaybackService`) and automatic PiP entry | Not room-gated | Disabled in rooms (D7) | +| PrairieCast transport from a phone (`TvPrairieCastReceiver`, `TvPrairieCastPlayerAdapter`) | Play, pause, seek, and stop suppressed in rooms, hosts included (`TvPlayerScreen`) | Route through the TV membership's permission decision, so a host's phone can control the room | +| PrairieCast `setPlaybackSpeed` | Ungated; writes the saved preference | Refuse in rooms; never write the preference | +| PrairieCast launch request | Replaces the party player with solo playback | D5 prompt | +| Playback-control socket commands | Admin Stop is classified as transport and rejected | Transport follows the room; admin Stop and terminate end the local engagement (for a host, the 2-minute grace starts) | +| `PlaybackDebugReceiver` play, pause, seek | Call Media3 directly | Fault injection only; never evidence of room behavior | +| Version and edition picker (phone Version sheet, TV HUD) | Enabled; picks another file | Disabled in rooms. Quality means the ladder of the same file | +| Speed menu and hold-to-2x | Saved speed applied in rooms | Hidden in rooms; session-only 1x; restore the preference on exit | +| Autoplay, next-up, postroll | Autoplay already suppressed | Suppress; optional host Next Episode (Scope) | + +### Source fallback + +Request coordinated fallback only for `no_alternate_version`, +`hdr_transcode_unsupported`, `subtitle_conversion_unsupported`, and +`transcoding_disabled`, and only when the capability is advertised. Send the +failed file and selection revision, including for a startup refusal before +attach. A successful fallback changes the source and epoch for everyone. +Access and network failures never trigger fallback. If no candidate remains, +keep the original refusal. Report actual decode and output capabilities with +no blanket 1080p or SDR cap. Log the capability snapshot, including the Force +HDR Passthrough flag, with each fallback request: one under-reporting TV can +move the whole room to an SDR file. + +### End of file + +Match Apple (`WatchPartyEndOfFileTests.swift`): an ended player counts as ready +and paused for reports and commands. A room seek after the end remounts the +stream at the target, paused. Local seeks after the end go to the room. A +premature end remounts at the drop point without marking the item watched. Play +from the end is not sent to the room. Solo playback, next-up, and the postroll +are unchanged. Apple leaves repeated premature ends uncapped and lists that as +an open issue; Android caps the remounts and then shows the playback error. + +### Other playback and teardown + +- While engaged, the D5 prompt guards every solo start: detail Play, extras + (`ItemDetailScreen`, `TvItemDetailScreen`), launcher and deep links, and + PrairieCast launch (`TvAppNavigation`). The service player is shared (`ActivePlayerHolder`), so + nothing may start without passing that guard. Picker cards never start solo + playback. +- Starting party playback ends a solo PiP session. Entering a party while + casting asks the user to stop casting first. +- Teardown is idempotent across Stop, End, Leave, replacement, identity switch, + navigation, and process recovery. Late player callbacks cannot affect the + next solo or room session. Disposing a screen binding does not leave the + room. + +## Experience + +### Entry and exposure + +- Add the Experimental section to phone Settings and TV Settings (D1). Store + the toggle locally on the device and exclude it from settings sync, like + `player.force_hdr_passthrough`. Inject the effective policy at the platform + boundary, the way both DI modules inject `PrairieClientBuildIdentity`. Replace + the `CLIENT_WATCH_TOGETHER_SURFACE_ENABLED` constant at its eight production + call sites (`MainScreen`, `ItemDetailScreen`, `TvMainShell`, + `TvItemDetailScreen`). Gate + on build type, not the APK file name: the published `*-debug.apk` is a + release build. +- Show entry points for movies, episodes, and series (resolved to the next-up + episode). The TV detail screen currently offers entry for any non-audiobook + type; restrict it. Stage the file of the displayed edition, and show the + selected edition in the lobby. +- The hub offers Host, Join by code, Paste invitation, and Rejoin recent party, + plus availability states: unsupported, not allowed, and failed probe with + Retry. Hosting defaults to Host Picks; the menu creates Vote rooms today. +- Normalize codes the same way on both platforms: trim, remove whitespace and + dashes, uppercase, and check for 8 characters from the server alphabet. + +### Lobby, suggestions, and picker + +- Pass a title preview from the detail page or picker so the lobby lays out + once. Start stays disabled until the room confirms the selection. The host's + seat appears after the socket connects; show a placeholder until then. +- Lobby Ready is advisory and counts connected guests. It is separate from + playback readiness. The host may start alone and before everyone is ready. + Promotion starts playback directly with no extra ready step. +- Rank suggestions by the server rule (votes, then creation time), independent + of HTTP pagination order. Drain pages under one captured authority, merge + without truncation, and handle more than 50 items. Treat personal votes as + unknown until an HTTP read, and keep known flags across socket broadcasts. + Reconcile after entry, reconnect, a vote mutation, or an uncertain outcome. + A successful mutation followed by a failed list read is still a success with + stale display state. +- The picker uses server rows. Continue Together is empty unless two or more + connected members have progress; hide empty rows. The phone may add the + chooser's own Continue Watching row, as iOS does; TV follows tvOS and web and + leaves personal rows out. Search is debounced and cancels stale responses. + Series flow to seasons and episodes, and an episode offers "See all + episodes". Fetch member state only for the selected candidate; treat omitted + IDs as unavailable, not unplayed. Watchlist and progress rows do not prove + every member can play an item. + +### During playback + +- Back opens the party panel over the retained player: a phone sheet or a TV + overlay. Everyone sees Leave and Return to playback; the host also sees Stop + and End (D2). Closing the panel never tears down the player. +- Status, matching the web client: + - "Syncing playback · Waiting for …" names the members the room waits for. + - "Catching up to the party" and "The party kept playing" for the viewer who + is catching up. + - "Continuing without …" for everyone else when the room leaves someone + behind (`roomMembersLeftBehind`). + - "The host stopped playback" on Stop. + - A host-disconnected notice when `host_connected` is false. + - A reconnect notice after 2 s disconnected, timed from the first + non-connected state and cleared on reconnect. + - Immediate notices for denied actions and taps during a reconnect. Repeated + notices show again. + - The lower-quality offer. +- Keep focus stable on TV through live snapshots, vote reordering, and loading, + error, and empty states. Keep pending buttons focusable while disabled. Live + updates must not trigger a TalkBack announcement each time. + +### Invitations and recent parties + +- Build the host's invitation link by resolving `invite_path` against the + verified server URL, base path included. Guests share the code only. +- The paste parser and deep-link handler accept a bare code, a server + invitation link, and a `prairie://watch-party` link (D4). Validate the app link + as strictly as Apple #412 does: an `http` or `https` server with no userinfo, + query, or fragment, and exactly one non-empty `server` and `token`. Scheme + matching ignores case. Reject `prairie://invite`, which is the account + invitation link. Add a `watch-party` host to the phone manifest's `prairie` + intent filter. TV queues deep links without identity scoping today; + port the phone's `ExternalRouteScope` model before TV accepts invitation + links. +- Match the link's server against the current one with `serverIdsMatch`. A + foreign server goes through the normal server, sign-in, and profile flow and + never reuses current credentials. A LAN URL and a public URL for the same + server count as different. +- The join token is a secret. Keep it out of route strings, saved state + (`consumedExternalRoute`), logs, and the recent-party store. +- The TV QR code encodes the invitation link, not the bare code. +- Store one recent party in `SecureSharedPrefs` for at most 24 hours, keyed by + server ID, `loginId`, and profile. Store the code and display metadata only, + never a room token. Rejoin joins again by code for fresh proof. Remove the + entry on a terminal state, on expiry, on sign-out, on server removal, and on + profile deletion. Never show it under another identity. A replaced + connection keeps the entry for explicit Rejoin. + +### Background and process death + +| Situation | Phone | TV | +| --- | --- | --- | +| Lobby, app backgrounded | Keep the engagement while the process lives. A host away longer than the grace, with its socket closed, ends the room | Same | +| Playing, app backgrounded | Local suspension: stop playback, send no reports. Resync on return | `ON_STOP` becomes a local suspension; it no longer leaves the room | +| Host socket lost for more than 2 min | Room ends for everyone (`host_left`); show the reason | Same | +| Activity recreation (font scale; rotation is handled in-activity) | Engagement survives; the screen rebinds | Same | +| Process death | A restored room route opens the hub with a Rejoin prompt. The room token is memory-only, so never re-adopt silently | Same | + +### Copy and documentation + +Use "Watch Party" in all user-facing text. Rename the 18 existing "Watch +Together" and "Watch together" strings, including shared repository errors, the +phone share text, and `ProfileMenu`. +The app has no localization setup; this plan adds none. + +## Milestones + +All milestones are pending. Write focused tests with each one. M0 fixtures can +run alongside M1. M4 can run alongside M3. Phone and TV layouts can proceed +independently once M4's actions are stable. Keep commits scoped to one +concern. Do not open a pull request unless asked. + +| Milestone | Scope | Depends on | Exit evidence | +| --- | --- | --- | --- | +| M0 | Decisions, environment, fixtures, harness | — | D1 signed off; pinned runtime and devices; a two-device observation run | +| M1 | Wire contract | M0 fixtures | Fixture and API tests; both apps compile | +| M2 | Room engagement owner | M1 | Lifecycle, ordering, renewal, and action tests | +| M3 | Player binding and catch-up | M1–M2 | Player-boundary tests; first live two-player checkpoint | +| M4 | Shared lobby, suggestions, picker | M2 | Action, ranking, and pagination tests | +| M5 | Native UI, gate, invitations | M3–M4 | Rendered phone and TV flows | +| M6 | Qualification matrix | M3–M5 | Matrix passes with native playback evidence | +| M7 | Cleanup and enablement | M6 | Checks green; exposure matches D1 and the evidence | + +### M0: Decisions, environment, fixtures, harness + +- [x] D1 agreed 2026-09-24. +- [ ] Record D2, D3, and D5–D9. Track D4 against Apple #412 and the server + join page. +- [ ] Pin Android, server, web, and Apple revisions for each run. Confirm + shared-dev includes `9482302b` and advertises every required capability. + Record the image through the runtime or fleet tooling. +- [ ] Build the fixture corpus in `shared/src/commonTest/resources/watchtogether/v2/` + with a `SOURCE` file naming the server revision and a sync script modeled on + `scripts/sync-apiv2-fixtures.sh`. Load fixtures through the + `ApiV2FixtureSupport` expect/actual pattern; plain `commonTest` resources are + only on the Android unit-test classpath. Label fixtures derived from Apple's + `WatchPartyAPITests`, `WatchPartyStateTests`, and + `WatchPartyEndOfFileTests`. +- [ ] Register room log attributes in the diagnostics attribute registry, with + its parity test. Debug builds throw on unregistered attributes. +- [ ] Extend the harness. `PlaybackDebugReceiver` status gains elapsed-realtime + and wall-clock timestamps, the server-clock estimate, the source-timeline + position, room phase, playback state and revision, connection epoch, + committed and attached session, pending and applied command, catching-up + state, stall count, reload-budget state, and current rate. Never expose + tokens or tickets. Add any new receiver actions to both manifests. Add a + two-device runner to `scripts/android-playback-test.sh` that parses JSON + instead of the current flat `sed`. +- [ ] Build deterministic seams: fake clock, socket, player, and HTTP, with + delayed responses, closures, bursts, reordering, failed attach and ready, + source refusals, and clock jumps. +- [ ] Allocate participants: a separate profile per simultaneous participant, + a reused profile only for replacement tests, and restricted profiles for + access tests. `prairie-runtime dev api` authenticates only as admin; probe + participant capability and room behavior with participant credentials from + local configuration, including `X-Profile-Token` for PIN profiles. +- [ ] Devices: claim phone and TV emulators through the device hub and never + bypass another task's claim. Both apps use `org.prairieserver.prairie`, so each + participant needs its own device. Debug builds are signed with each build + host's debug keystore; install from one host or uninstall first. Don't clear + app data as routine setup. +- [ ] Peers: the web client on shared-dev through browser automation; Apple + simulators from Apple `main` with Experimental → Watch Party on, via the Mac + build workflow; a two-API stack with workers from the dev-builder for + cross-node and worker-dependent deliveries; network shaping through the + emulator console on owned emulators, or a proxy. +- [ ] Each run gets a unique room, an artifact directory, and a participant + set. Cleanup closes only rooms the test created and releases only its own + device claims, even when the test fails. + +Exit: two Android clients can be observed at once with correlated timestamps, +and every run records its runtime, fixtures, and participants. + +### M1: Wire contract + +- [ ] Type both capability documents and the entry baseline. Keep "unsupported", + "not allowed", and "probe failed" distinct. +- [ ] Complete the room, member, member-state, picker, and suggestion-page + models from fixtures, including v2 string IDs and numeric socket IDs. +- [ ] Implement every operation in the table through `ownedV2Call`, validating + the status, required fields, and IDs. A malformed 2xx is `invalid_response`. + `safeApiV2Call` currently turns it into a network error. +- [ ] Keep room and suggestion UUIDs and exact bodies in the logical action, not + in each network attempt. Today they are generated per attempt. +- [ ] Apply retry classes. `singleAttempt()` only skips the pinned-scope 401 + refresh; it doesn't stop OkHttp's connection retry, which is still on for the + API client. Prove with a request-count test that non-retryable operations + send at most once; if they don't, route them through a client with + `retryOnConnectionFailure(false)`, as the player's auxiliary call factory in + `PlayerOkHttpClient` does. Retry-safe + operations, including the ticket mint, get one refresh and resend on 401. +- [ ] Adopt `room_access_token` from every 200 room response without changing + membership. Read `exp` from the token. +- [ ] Decode `connection_replaced` as terminal. Add `lobby_ready`, + `ready.command_id`, `state_report.command_id` and `is_ready`, and + `self_ignore_wait`. Ignore unknown frame types; reconcile after malformed + known frames. +- [ ] Validate tickets: `expires_in` from 0 to 30, `max_connection_seconds` + from 1 to 300, and header-safe characters. Handle 0 as in the errors table. + The events client's `validTicket` rejects 0, so adapt it rather than reuse it + unchanged. Check the negotiated protocol. Mint a fresh ticket for each + attempt. +- [ ] Add nullable `allowAlternateVersions` to `PlaybackStartRequestV3`, its + wire body (`allow_alternate_versions`), start allocation, and `StartParams`. +- [ ] Add the error mapping table, including the 409 reconcile rule. +- [ ] Add fixture tests for re-attaching the same session in a playing room + and in a paused room (see Host reports and local suspensions). + +Exit: fixtures cover every request and response shape, authorization header, +negative capability case, retry class, and error mapping. Both apps compile. + +### M2: Room engagement owner + +- [ ] Consolidate transitions and eligibility. Remove the content-present and + member-count shortcuts (`WatchTogetherEntryPolicy`, + `WatchTogetherLobbyViewModel`). Treat an ended snapshot as terminal even + without `room_closed`. +- [ ] Separate membership from proof. Renew through `GET /rooms/{id}` before + `exp`, leaving more than 5 minutes plus the ticket lifetime. If renewal fails + terminally, end the engagement and offer Rejoin. +- [ ] Fix same-room rejoin: `RoomSession` returns early for a matching room ID + today. A new binding replaces the old connection job, and the old socket + closes before the new owner takes over shared state. +- [ ] Order snapshots by persisted generation and socket receipt order. Fence + suggestion reads separately from room mutations. +- [ ] Take suggestion hydration off the socket receive path. Replace the + unchecked `trySend` and drop-oldest flows with reliable delivery or an + explicit recovery read. Drain terminal frames before treating closure as a + transient failure. +- [ ] Reconnect at once after a socket that stayed up (the 5-minute rotation). + Back off from 0.5 s to 5 s, with jitter, only for sockets that fail quickly. + Keep trying for at least the host grace period, unless a terminal signal or + an authority change stops it. The current budget gives up after six failures + (about 13.5 s of backoff). Never reclaim after `connection_replaced`. +- [ ] Move clock sampling to the room socket, from socket open (see Commands + and clock). +- [ ] Serialize conflicting room and vote actions. Capture displayed ownership + and inputs before dispatch. Reconcile uncertain Start, Select, Promote, and + Stop outcomes without replaying them. Expose pending state so repeated taps + can't reorder work. +- [ ] Persist the recent party as described under Invitations and recent + parties. + +Exit: tests cover stale and equal-generation HTTP, ended without close, +replacement, same-room rejoin, proof renewal, blocked hydration, event bursts, +conflicting votes, rotation reconnect timing, and uncertain non-retryable +mutations. + +### M3: Player binding and catch-up + +- [ ] Room playback context through both starters and every session-replacement + path. Paused preparation. Download and resume bypass. +- [ ] Extract attachment, reporting, scheduling, readiness, catch-up, and the + reload budget from the two controllers into the shared binding. Keep thin + adapters that expose observed position, completed load and seek, buffering, + and media mount identity. +- [ ] Reports from observed state; report suppression; buffering after 2 s; + catch-up acknowledgement. +- [ ] Correction policy with rate convergence and the reload budget. Verify + Media3 speed behavior with passthrough and tunneling on TV. +- [ ] Local suspensions and resync. Host report guards. +- [ ] One input-permission decision for every row in the input table. +- [ ] 1x session speed; speed UI hidden in rooms; preference restored on exit. +- [ ] Version and edition pickers disabled in rooms. +- [ ] Coordinated fallback with a capability snapshot in the log. +- [ ] End-of-file handling. +- [ ] Other-playback guard (D5), PiP, and Cast rules (D7). +- [ ] Idempotent teardown. + +Exit: deterministic binding tests pass. Then, under a debug entry, two real +Android players attach separate sessions to the same file and pass pause, seek, +Stop and Start, rotation reconnect, a late join without a room pause, and one +catch-up after the deadline. Repeat the core flow with a web peer. This is the +first live playback checkpoint. + +### M4: Shared lobby, suggestions, picker + +- [ ] Replace the duplicated phone and TV entry, lobby, and suggestion + ViewModels with one shared state and action owner that exposes domain + destinations. Platform navigation builds the routes. +- [ ] Centralize eligibility for stage, start, lobby Ready, stop, end, leave, + mode, policy, suggest, vote, remove, promote, queue, and rejoin. It depends + on phase, role, capabilities, and pending work. Removing a suggestion matches + both user and profile. +- [ ] Host Picks by default. Stage from a detail page with the displayed + library and file. If creation succeeds but staging fails, keep that room and + offer retry or leave; never create a second room silently. +- [ ] Suggestion ranking, pagination, personal votes, and Queue (D9). +- [ ] Picker rows, search, episode navigation, and member state with captured + authority and bounded reads. +- [ ] Give both platforms the same guest policy, mode switching, errors, + pending behavior, connection status, and rejoin semantics. + +Exit: shared tests cover eligibility, ordering, ranking across pages, restricted +catalog responses, stale search, partial create-and-stage failure, and +identical phone and TV destinations. + +### M5: Native UI, gate, invitations + +- [ ] Experimental toggle and injected policy (D1). +- [ ] Hub, entry points, and availability states. +- [ ] Lobby, suggestions, picker, and options menu on phone and TV, built from + existing components. +- [ ] Party panel over the retained player; the Back behavior in D2. +- [ ] In-playback status and notices. +- [ ] Invitations: phone share and copy, paste parser, TV QR, scoped deep links, + and the leave-first message (D4, D5). +- [ ] Background and process-death behavior from the table. +- [ ] TV focus with the existing helpers (`TvControlState`, + `tvControlSemantics`, `tvModalFocusBoundary`, + `TvRestoreFocusOnModalDismiss`, `TvReturnTarget`). Phone + rotation, font scale, large text, touch targets, TalkBack labels, and tablet + layout. +- [ ] String rename. + +The phone app has Robolectric but no Compose UI test dependency. Adding one +means updating the dependency lockfiles and verification metadata. Keep UI +tests to the flows this plan requires. + +Exit: phone and TV complete every scoped flow through rendered UI, keep +playback behind the party panel, and recover from errors without losing focus +or creating duplicate rooms. Debug routes and real entry points call the same +actions. + +### M6: Qualification + +- [ ] Run the validation matrix on shared-dev with phone and TV emulators, then + the web and Apple pairings. Run cross-node and worker-dependent cases on a + two-API stack with workers. Record actual revisions and runtime identities. + Another client's pass does not count for Android. +- [ ] Soak for at least 8 minutes to cover socket rotation. Test proof expiry + with an accelerated clock in fixtures rather than waiting 24 hours. +- [ ] Investigate each failure at the boundary that owns it, add a regression + test, and rerun the affected rows. Record unavailable cases as skipped, never + as passed. + +Exit: all required emulator and software rows pass with native playback +evidence. HDR, Dolby Vision, HDMI passthrough, and physical-remote behavior +need capable hardware before any claim. Keep unqualified routes gated. + +### M7: Cleanup and enablement + +- [ ] Delete duplicated controller mechanics, the old entry policies, dead + readiness fields, and source-text tests that behavioral tests now cover. +- [ ] Remove `ClientSurfacePolicy`'s constant and replace its test with + per-platform, per-build-type policy tests. +- [ ] Update comments and docs that describe immediate selection, a fixed + selection mode, solo-host waiting, or readiness as buffering only. Update + `AGENTS.md`, `README.md`, `FEATURES.md`, and `docs/README.md` to match D1. +- [ ] Run the full checks and inspect the diff for unrelated player changes, + secrets, generated output, and legacy namespaces. +- [ ] Review against this plan and the server and Apple revisions current at + that time. Refresh the baselines table if they moved. +- [ ] Record revision-pinned validation, limitations, and rollout + prerequisites. Rollback: the server capability switch, and the toggle for + testers. Active engagements must tear down safely either way. + +## Validation matrix + +The existing tests assert the old solo-host and content-present rules and must +change. Prefer behavior through fake clocks, sockets, and players over +source-string assertions. + +| ID | Scenario | Required outcome | Evidence | +| --- | --- | --- | --- | +| T01 | Empty and staged lobby, restage, mode switch | No player before Playing; lobby Ready clears as the server dictates | Unit, phone and TV UI | +| T02 | Solo host start; guest joins a playing room | Room keeps playing; the guest syncs alone and is marked ready by a matching report | Unit, live pair | +| T03 | Host rejoins at a nonzero position | Starts at `anchor_position_seconds` as sent; no personal resume; no host report rewinds the room | Unit, live pair | +| T04 | Stop and Start the same title; a new title; a fallback revision | The new revision owns playback; old commands and sessions are inert | Unit, live pair | +| T05 | Ended snapshot without close; stale Playing afterward; `room_closed` `host_left`; idle expiry | Terminal cleanup; no resurrection or reconnect | Unit, socket fixture | +| T06 | Host drops for under 2 min, then for over 2 min | Returns within grace; otherwise the room ends; guests see the host notice | Live pair | +| T07 | Old or equal-generation HTTP after a socket update | The newer attachment, readiness, and selection survive | Unit, delayed transport | +| T08 | Joining the same room again with a new binding | Exactly one socket; the old owner exits | Unit, native rejoin | +| T09 | Five-minute rotation in an 8-minute soak | Immediate reconnect; no reconnect notice; same player session, reattached | Live soak | +| T10 | Ticket 401 at rotation; `expires_in: 0`; ticket 403, 404, 409, 422 | One refresh and a new ticket; 0 renews the expiring credential, then mints again; the others are terminal | Unit, API fixture | +| T11 | Proof renewal and adoption from responses | Membership survives renewal; terminal failure stops work | Accelerated clock | +| T12 | Same-profile replacement on the same API and on different APIs | The displaced client stops without retries; the winner continues; Rejoin works | Unit, live, cross-node | +| T13 | Room socket vs playback-control socket loss | Only the affected socket recovers; no duplicate player or false attachment | Unit, fault injection | +| T14 | Failed attach, stale echo, mid-play session replacement (quality, 404 renewal, replan) | Reports wait for the right echo; the new session reattaches; the room does not pause | Unit, native delivery | +| T15 | A second seek on the same session; a seek while already waiting | Readiness re-arms; guest within 1 s; host re-anchors within 15 s | Unit, real seeks | +| T16 | Lost or rejected `ready`; old `is_ready` snapshot | `state_report` healing; barrier released only on fresh acknowledgement | Unit, integration fixture | +| T17 | Duplicate, out-of-order, and late commands; superseded Play; missing or skewed clock | One current intent runs; late Play advanced; stale work cancelled | Clock and player tests, burst run | +| T18 | Stall under 2 s | No buffering report; local convergence | Player fixture, live shaping | +| T19 | First sustained stall | The room waits up to 10 s; the overlay names the viewer; resumes on recovery | Live shaping | +| T20 | Deadline passes | `self_ignore_wait`; `ready` in playing and paused rooms, retried every 500 ms; fresh target; no reports while unplayable | Unit, live shaping | +| T21 | Stall again within 5 min or within 1 min of the last pause; a lone viewer stalls | The room keeps playing; a lone viewer always pauses it and the room waits; a lone catching-up viewer resumes from its own position | Unit, live | +| T22 | Rate convergence; host drift of 2 s or less | 0.9–1.25x until within 0.35 s, then exactly 1x; host corrected, not re-anchored; passthrough falls back to seek; saved speed untouched | Unit, TV and phone | +| T23 | Unbuffered corrections on a slow link, including copy remux | One reload at a time; backoff from 10 s to 60 s; lead up to 10 s, capped at duration; explicit seeks bypass; no replan loop ([server #1259](https://github.com/Prairie-Server/prairie-server/issues/1259)) | Unit, live shaping | +| T24 | Two sustained stalls within 5 min | Offer one lower quality of the same file, once per quality | Unit, live | +| T25 | Host local suspensions (focus loss, noisy, sleep timer, TV pause, subtitle preparation, Cast attempt) | Room unaffected; the host resyncs before reporting | Unit, native | +| T26 | Guest controls under both policies on every input in the input table | Denied inputs change nothing and show a notice; permitted ones go to the room | Unit, touch, D-pad, MediaSession, PrairieCast | +| T27 | Transport tap during a reconnect | Immediate notice; no silent drop; no queued replay | Unit, native | +| T28 | Saved speed, hold-to-2x, PrairieCast speed, auto-skip, next-up, autoplay | The room keeps authority at 1x; preferences survive exit | Unit, native | +| T29 | Version and edition pickers | Disabled in rooms; same-file quality stays local | Native | +| T30 | Downloaded copy, active Cast, solo PiP before entry | Party streams; Cast asks to stop first; PiP ends | Phone | +| T31 | Detail Play, extras, deep link, PrairieCast launch while engaged | D5 prompt; no hidden departure | Phone and TV | +| T32 | End of file: natural, premature, seek after end, Play from end | Apple rules; premature end remounts without marking watched; remounts capped | Unit, live | +| T33 | Direct and HLS participants; nonzero origin; window-edge correction | One source; correct clock mapping; no replan loop | Live delivery matrix | +| T34 | Approved refusal with Force HDR on and off; output-route change; stale or failed fallback; access or network failure | Only approved reasons change the room source; no local alternate | API and player tests, real refusal | +| T35 | Suggestions in both modes: vote, unvote, more than 50, removal, promotion, Queue, failed hydration after a receipt | Correct ranking and personal flags; Queue stages "Up next"; no resubmission | Unit, native, cross-node | +| T36 | Restricted picker and member state; stale search; Continue Together alone | No access leakage; bounded reads; omitted IDs not shown as unplayed; empty rows hidden | API, native restricted profile | +| T37 | Uncertain mutations, 401, malformed 2xx, 409 reconcile, 429 | Stable ID and body where replay is allowed; non-retryable operations sent at most once | Request-count and body assertions | +| T38 | Profile, PIN, account, or server change during each async stage; PIN re-unlock | Old results never publish into the new authority; re-unlock keeps the recent party | Unit, native | +| T39 | Invitation with a base path; foreign server; code normalization; invitation while engaged; TV QR scan | Correct authority; no credential reuse; leave-first message; the token never appears in logs or saved state | Unit, phone and TV UI | +| T40 | Party panel, Back, Stop, End, Leave, and focus during live updates; rotation, font scale, TalkBack | Player kept where intended; complete teardown otherwise | Phone and TV UI, recordings | +| T41 | Background and foreground per the table; process death | Behavior matches the table; Rejoin prompt after death; no duplicate owner | Emulators | +| T42 | Experimental toggle, as guest and as host | Off hides entries, ignores invitations, and leaves the party (a host's room ends two minutes later); release defaults off, debug defaults on | Unit, native | +| T43 | Solo playback after leaving | Downloads, resume, speed, tracks, Cast, PiP, autoplay, next-up, and intro preferences behave normally | Focused regression runs | +| T44 | Large seek on a copy-remux delivery | Lands within tolerance or the host re-anchors; barrier duration recorded ([server #1345](https://github.com/Prairie-Server/prairie-server/pull/1345)) | Live | + +Run these pairings with distinct profiles: + +| Host | Guest | Purpose | +| --- | --- | --- | +| Android phone | Android TV | Core native flow | +| Android TV | Android phone | Reverse authority and TV controls | +| Android phone | Second Android phone | Same form factor | +| Web | Android phone, then TV | Reference host | +| Android phone, then TV | Web | Reference guest | +| Apple tvOS | Android phone | Cross-platform native | +| Android TV | Apple iOS | Cross-platform native | +| Android phone | Apple tvOS | Cross-platform native | +| Apple iOS | Android TV | Cross-platform native | + +Run core start, pause, seek, Stop, restart, and leave on every pairing. Run the +full recovery and catch-up rows on the phone and TV pair, plus representative +web and Apple rows. Add pairings when a difference or failure calls for them. +One three-viewer run (phone, TV, web) covers member changes, stragglers, and +left-behind notices. A scripted socket peer is useful for faults but does not +prove media decoded. At least two real players must advance in native and +interoperability evidence. + +Sync target for a stable fixture: at most 1 s spread between clients after a +5-second settling period, with no persistent pause mismatch or growing drift. +Measure from harness timestamps and record the sampling uncertainty; samples +too far apart are inconclusive. This is a project target, not a server SLA. +Record barrier duration, replan counts, reloads, and rate episodes. A healthy +cached seek that waits out the 10-second deadline is a failure. + +## Running the checks + +Use Java 21 and one Android SDK path. Run filtered suites during development and +the full set at the final gate: + +```sh +./scripts/test-check-build-supply-chain.sh +./scripts/check-build-supply-chain.sh +./gradlew :shared:testDebugUnitTest :android-shared:testDebugUnitTest \ + :androidApp:testDebugUnitTest :androidTvApp:testDebugUnitTest +./gradlew :android-shared:lintDebug :androidApp:lintDebug :androidTvApp:lintDebug \ + :androidApp:lintVitalRelease :androidTvApp:lintVitalRelease +./gradlew :androidApp:assembleDebug :androidTvApp:assembleDebug +``` + +CI treats `NewApi` and `InlinedApi` lint findings as fatal. Fix new findings +instead of regenerating the baseline. + +Runtime probes use the `prairie-runtime` helper. It authenticates as admin, so use +it for health, identity, and room capability checks, and use participant +credentials for playback capabilities and room behavior: + +```sh +~/.agents/skills/prairie-runtime/scripts/prairie-runtime dev doctor +~/.agents/skills/prairie-runtime/scripts/prairie-runtime dev api /api/v2/system/info +~/.agents/skills/prairie-runtime/scripts/prairie-runtime dev api /api/v2/watch-together/capabilities +``` + +Device observation after installing the matching APK on each claimed device: + +```sh +scripts/android-playback-test.sh -s "$WATCH_PARTY_PHONE_SERIAL" status +scripts/android-playback-test.sh -s "$WATCH_PARTY_TV_SERIAL" status +``` + +Fill the serials from current device-hub discovery. M0 adds the party fields +and the two-device runner; do not document commands before they exist. Use +`--quality original` for direct, HDR, and passthrough fixtures. Emulator +decoding is not evidence of HDR output or audio passthrough. + +Keep a run ledger outside tracked source: scenario ID, UTC times, Android, peer, +and server revisions with dirty state, device, API level and ABI, runtime image +and topology, fixture and delivery, roles, room, revision, session, and command +correlation, result, sampling uncertainty, and captures. Keep deployment URLs, +credentials, fixture IDs, and device IDs in protected local configuration. +Summarize durable results here as milestones finish; never commit raw logs or +captures. + +## Dependencies and open items + +| Item | Effect on Android | +| --- | --- | +| [Server #1307](https://github.com/Prairie-Server/prairie-server/issues/1307) (open) | Its acceptance needs Android, Apple, and web readiness evidence against the buffering policy. M6 supplies Android's | +| [Server #1345](https://github.com/Prairie-Server/prairie-server/pull/1345) (open) | Web-only pre-roll fix for copy-remux seeks. Android checks Media3's landing behavior in T44 | +| [Server #1259](https://github.com/Prairie-Server/prairie-server/issues/1259) (open) | Replan storms on copy remux. The reload budget is Android's side; T23 proves it | +| [Server #1284](https://github.com/Prairie-Server/prairie-server/issues/1284) (open tracker) | Suggestion broadcast and replacement fixes are merged (#1286, #1287). Confirm they are deployed on every API that Android testing uses | +| [Apple #412](https://github.com/Prairie-Server/prairie-apple/pull/412) (open), for [#409](https://github.com/Prairie-Server/prairie-apple/issues/409) | Proposes the `prairie://watch-party` app link that Android adopts (D4). The server still needs a public join page that offers the link; the web join page currently requires sign-in. Freeze the format before M5's link handling merges; tapping a shared link end to end (T39) needs the server page | +| [Apple #410](https://github.com/Prairie-Server/prairie-apple/issues/410) | Android uses an immediate notice on taps during reconnect (T27) | +| [Apple #411](https://github.com/Prairie-Server/prairie-apple/issues/411) | Android keys recent parties by `loginId` from sign-in. Confirm that sessions from older app versions carry it | +| [Server #1379](https://github.com/Prairie-Server/prairie-server/pull/1379) (merged) | Room-driven starts omit `first_frame_ms`; Rejoin is a viewer start. Android's general first-frame timing, which starts at plan adoption, is separate work | +| Picker `overlay_summary` | Server picker cards carry no overlay summary, so the watchlist row shows no quality badges until the server fills it | +| Access-token lifetime | Not confirmed. If it is shorter than 5 minutes, sockets rotate earlier; T09 and T10 measure it | + +## Completion record + +- [x] 2026-09-24: reviewed the September 21 draft against server `2812ac39`, + Apple `5d9dc764`, the web client, and Android `03ac5b71`; wrote this plan. +- [ ] M0: decisions recorded, environment and harness ready. +- [ ] M1–M2: wire contract and room engagement owner complete. +- [ ] M3: first live two-player checkpoint passes. +- [ ] M4–M5: phone and TV experiences pass rendered checks. +- [ ] M6: emulator, software, and interoperability matrix passes. +- [ ] Hardware and deployment gates recorded and met for each route being + enabled. +- [ ] M7: cleanup, final checks, and enablement per D1. diff --git a/fastlane/Fastfile b/fastlane/Fastfile index d64f9ab673..ee6c5decaa 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -1,6 +1,7 @@ require "base64" require "fileutils" require "tmpdir" +require_relative "play_bundle_manifest" # Root-level Fastfile — Android lane for Google Play releases. Mirrors the # structure of silo-apple's Fastfile: @@ -194,74 +195,110 @@ end # ── Android ────────────────────────────────────────────────────────────────── +def build_play_bundles(version:, base_code:, build:, track:) + PlayBundleManifest.identity(File.expand_path("..", __dir__), ENV) + require_signing_env! + ENV["PRAIRIE_RELEASE_KEYSTORE"] = resolve_release_keystore + materialize_google_services_json + yield if block_given? + + # One invocation builds both bundles so shared modules compile once. Any + # task name containing "bundle" also disables the sideload APK ABI splits + # (see isBuildingBundle in the app build scripts) — the AABs do their own + # per-ABI delivery. R8 mapping files are embedded in the bundles + # (BUNDLE-METADATA), so Play picks up deobfuscation automatically. + gradle( + tasks: [":androidApp:bundleRelease", ":androidTvApp:bundleRelease"], + properties: { + "siloVersionName" => version, + "siloVersionCode" => base_code, + # The build counter as its own value: it is already folded into + # base_code, but the app reports it verbatim to the server. + "siloBuildNumber" => build, + # The track these bundles are actually uploaded to, so a beta-track + # tester and a production user are distinguishable in admin Activity + # instead of both reporting a generic "release". + "siloReleaseChannel" => track, + }, + system_properties: { + "org.gradle.jvmargs" => "-Xmx4g -Dfile.encoding=UTF-8", + }, + flags: "--max-workers=2", + ) + + [PHONE_AAB, TV_AAB].each do |aab| + UI.user_error!("Expected App Bundle not found at #{aab}") unless File.exist?(aab) + end + + PlayBundleManifest.write!(root: File.expand_path("..", __dir__), + version: version, base_code: base_code, build: build, track: track) +end + +def publish_play_bundles(version:, base_code:, build:, track:, json_key: nil, + manifest_sha256: ENV["PRAIRIE_PLAY_BUNDLE_MANIFEST_SHA256"], changelog_prepared: false) + PlayBundleManifest.verify!(root: File.expand_path("..", __dir__), + version: version, base_code: base_code, build: build, track: track, + producer_attempt: ENV["PRAIRIE_PLAY_BUNDLE_ATTEMPT"], manifest_sha256: manifest_sha256) + json_key ||= play_json_key_data + write_play_changelog unless changelog_prepared + + # Both AABs go up in a single Play edit/release — phone and TV must ship + # together (see the header comment). Listing metadata and imagery stay + # managed in the Play Console; only the changelog is uploaded from here. + # If Google rejects the edit with "changes cannot be sent for review + # automatically", a manual review step is pending in the Play Console — + # pass changes_not_sent_for_review: true temporarily and submit there. + upload_to_play_store( + package_name: PACKAGE_NAME, + json_key_data: json_key, + track: track, + release_status: "completed", + version_name: version, + aab_paths: [PHONE_AAB, TV_AAB], + # Belt-and-braces: never let a stale APK from the gradle lane context + # conflict with the explicit aab_paths above. + skip_upload_apk: true, + skip_upload_metadata: true, + skip_upload_images: true, + skip_upload_screenshots: true, + skip_upload_changelogs: false, + changes_not_sent_for_review: false, + timeout: 900, + ) + + UI.success("Uploaded Prairie #{version} build #{build} (phone #{base_code * 2} + TV #{base_code * 2 + 1}) to the #{track} track.") +end + platform :android do + desc "Prepare signed phone and TV App Bundles without publishing" + lane :prepare_bundles do + version, base_code, build = resolve_release_version + track = play_track + build_play_bundles(version: version, base_code: base_code, build: build, track: track) + end + + desc "Upload the verified prepared phone and TV App Bundles" + lane :upload_bundles do + version, base_code, build = resolve_release_version + track = play_track + publish_play_bundles(version: version, base_code: base_code, build: build, track: track) + end + desc "Build both App Bundles (phone + TV) and upload to a Google Play track" lane :beta do version, base_code, build = resolve_release_version track = play_track + # Preserve the local lane's early publisher-key validation. json_key = play_json_key_data - require_signing_env! - ENV["PRAIRIE_RELEASE_KEYSTORE"] = resolve_release_keystore - materialize_google_services_json - write_play_changelog - - UI.message( - "Releasing Silo #{version} build #{build} to the #{track} track " \ - "(base code #{base_code} → phone #{base_code * 2}, TV #{base_code * 2 + 1})" - ) - - # One invocation builds both bundles so shared modules compile once. Any - # task name containing "bundle" also disables the sideload APK ABI splits - # (see isBuildingBundle in the app build scripts) — the AABs do their own - # per-ABI delivery. R8 mapping files are embedded in the bundles - # (BUNDLE-METADATA), so Play picks up deobfuscation automatically. - gradle( - tasks: [":androidApp:bundleRelease", ":androidTvApp:bundleRelease"], - properties: { - "siloVersionName" => version, - "siloVersionCode" => base_code, - # The build counter as its own value: it is already folded into - # base_code, but the app reports it verbatim to the server. - "siloBuildNumber" => build, - # The track these bundles are actually uploaded to, so a beta-track - # tester and a production user are distinguishable in admin Activity - # instead of both reporting a generic "release". - "siloReleaseChannel" => track, - }, - system_properties: { - "org.gradle.jvmargs" => "-Xmx4g -Dfile.encoding=UTF-8", - }, - flags: "--max-workers=2", - ) - - [PHONE_AAB, TV_AAB].each do |aab| - UI.user_error!("Expected App Bundle not found at #{aab}") unless File.exist?(aab) + build_play_bundles(version: version, base_code: base_code, build: build, track: track) do + write_play_changelog + UI.message( + "Releasing Prairie #{version} build #{build} to the #{track} track " \ + "(base code #{base_code} → phone #{base_code * 2}, TV #{base_code * 2 + 1})" + ) end - - # Both AABs go up in a single Play edit/release — phone and TV must ship - # together (see the header comment). Listing metadata and imagery stay - # managed in the Play Console; only the changelog is uploaded from here. - # If Google rejects the edit with "changes cannot be sent for review - # automatically", a manual review step is pending in the Play Console — - # pass changes_not_sent_for_review: true temporarily and submit there. - upload_to_play_store( - package_name: PACKAGE_NAME, - json_key_data: json_key, - track: track, - release_status: "completed", - version_name: version, - aab_paths: [PHONE_AAB, TV_AAB], - # Belt-and-braces: never let a stale APK from the gradle lane context - # conflict with the explicit aab_paths above. - skip_upload_apk: true, - skip_upload_metadata: true, - skip_upload_images: true, - skip_upload_screenshots: true, - skip_upload_changelogs: false, - changes_not_sent_for_review: false, - timeout: 900, - ) - - UI.success("Uploaded Silo #{version} build #{build} (phone #{base_code * 2} + TV #{base_code * 2 + 1}) to the #{track} track.") + manifest = File.join(File.expand_path("..", __dir__), PlayBundleManifest::FILE_NAME) + publish_play_bundles(version: version, base_code: base_code, build: build, track: track, json_key: json_key, + manifest_sha256: Digest::SHA256.file(manifest).hexdigest, changelog_prepared: true) end end diff --git a/fastlane/play_bundle_manifest.rb b/fastlane/play_bundle_manifest.rb new file mode 100644 index 0000000000..bc51df2f3e --- /dev/null +++ b/fastlane/play_bundle_manifest.rb @@ -0,0 +1,141 @@ +require "digest" +require "fileutils" +require "json" +require "open3" + +# The release producer and consumer exchange these two final files in one run. +# APK preparation remains separate because bundle task names disable APK splits. +module PlayBundleManifest + FILE_NAME = "build/play-bundles-manifest.json" + PATHS = %w[ + androidApp/build/outputs/bundle/release/androidApp-release.aab + androidTvApp/build/outputs/bundle/release/androidTvApp-release.aab + ].freeze + MAX_FILE_BYTES = 1_073_741_824 + MAX_MANIFEST_BYTES = 65_536 + REPOSITORY = "Prairie-Server/prairie-android" + + def self.require!(condition, message) + raise ArgumentError, message unless condition + end + + def self.positive_integer(value, name) + require!(value.to_s.match?(/\A[1-9]\d*\z/), "Invalid #{name}") + value.to_i + end + + def self.identity(root, context) + sha, status = Open3.capture2("git", "rev-parse", "HEAD", chdir: root) + sha = sha.strip + require!(status.success? && sha.match?(/\A[0-9a-f]{40}\z/), "Source revision unavailable") + names = %w[GITHUB_REPOSITORY GITHUB_RUN_ID GITHUB_RUN_ATTEMPT GITHUB_SHA] + return {"source_sha" => sha, "repository" => nil, "run_id" => nil, "producer_attempt" => nil} if + names.all? { |name| context[name].to_s.empty? } + + require!(context["GITHUB_REPOSITORY"] == REPOSITORY && context["GITHUB_SHA"] == sha, + "Bundle source does not match this release run") + tracked, tracked_status = Open3.capture2("git", "status", "--porcelain=v1", "--untracked-files=no", + "--ignore-submodules=none", chdir: root) + require!(tracked_status.success? && tracked.empty?, "CI bundle source has tracked changes") + {"source_sha" => sha, "repository" => REPOSITORY, + "run_id" => positive_integer(context["GITHUB_RUN_ID"], "run ID"), + "producer_attempt" => positive_integer(context["GITHUB_RUN_ATTEMPT"], "run attempt")} + end + + def self.bundle_record(root, relative) + root = File.realpath(root) + path = File.join(root, relative) + components = relative.split("/") + require!(components.each_index.none? { |index| File.symlink?(File.join(root, *components[0..index])) }, + "Bundle path must not contain a symlink") + require!(File.file?(path) && File.size(path).between?(1, MAX_FILE_BYTES), + "Expected nonempty App Bundle not found") + {"path" => relative, "bytes" => File.size(path), "sha256" => Digest::SHA256.file(path).hexdigest} + end + + def self.write!(root:, version:, base_code:, build:, track:, context: ENV) + manifest = {"schema" => 1, **identity(root, context), "version" => version, + "base_code" => base_code, "build" => build, "track" => track, + "bundles" => PATHS.map { |path| bundle_record(root, path) }} + path = File.join(root, FILE_NAME) + require!(!File.symlink?(File.dirname(path)) && !File.symlink?(path), "Manifest must not be a symlink") + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, JSON.pretty_generate(manifest) + "\n") + manifest + end + + def self.verify!(root:, version:, base_code:, build:, track:, context: ENV, producer_attempt: nil, manifest_sha256: nil) + path = File.join(root, FILE_NAME) + require!(!File.symlink?(File.dirname(path)) && !File.symlink?(path) && + File.file?(path) && File.size(path).between?(1, MAX_MANIFEST_BYTES), + "Play bundle manifest is missing or invalid") + source = identity(root, context) + if source["run_id"] || manifest_sha256 + require!(manifest_sha256.is_a?(String) && manifest_sha256.match?(/\A[0-9a-f]{64}\z/) && + Digest::SHA256.file(path).hexdigest == manifest_sha256, + "Play bundle manifest does not match its producer") + end + manifest = JSON.parse(File.read(path)) + require!(manifest.is_a?(Hash) && + %w[schema base_code build].all? { |key| manifest[key].is_a?(Integer) } && + %w[run_id producer_attempt].all? { |key| manifest[key].nil? || manifest[key].is_a?(Integer) } && + manifest["bundles"].is_a?(Array) && manifest["bundles"].all? { |bundle| + bundle.is_a?(Hash) && bundle["bytes"].is_a?(Integer) + }, "Play bundle manifest has invalid field types") + expected = {"schema" => 1, **source, "version" => version, + "base_code" => base_code, "build" => build, "track" => track, + "bundles" => PATHS.map { |relative| bundle_record(root, relative) }} + if expected["run_id"] + attempt = positive_integer(producer_attempt || expected["producer_attempt"], "producer attempt") + require!(attempt <= expected["producer_attempt"], "Producer attempt is newer than this consumer") + expected["producer_attempt"] = attempt + else + require!(producer_attempt.nil?, "Local bundle has no CI producer") + end + require!(manifest == expected, "Prepared Play bundles do not match this release") + manifest + rescue JSON::ParserError + raise ArgumentError, "Play bundle manifest is malformed" + end + + def self.verify_artifact!(artifact:, artifact_id:, artifact_name:, producer_attempt:, context: ENV) + id = positive_integer(artifact_id, "artifact ID") + attempt = positive_integer(producer_attempt, "producer attempt") + run = positive_integer(context["GITHUB_RUN_ID"], "run ID") + current_attempt = positive_integer(context["GITHUB_RUN_ATTEMPT"], "run attempt") + repository_id = positive_integer(context["GITHUB_REPOSITORY_ID"], "repository ID") + require!(context["GITHUB_REPOSITORY"] == REPOSITORY && + context["GITHUB_SHA"].to_s.match?(/\A[0-9a-f]{40}\z/) && attempt <= current_attempt, + "Unexpected release artifact context") + expected_name = "android-play-bundles-#{run}-#{attempt}" + workflow = artifact.is_a?(Hash) ? artifact["workflow_run"] : nil + require!(artifact_name == expected_name && artifact.is_a?(Hash) && + artifact["id"].is_a?(Integer) && artifact["id"] == id && + artifact["name"] == expected_name && artifact["expired"] == false && + artifact["size_in_bytes"].is_a?(Integer) && artifact["size_in_bytes"].between?(1, 2 * MAX_FILE_BYTES) && + workflow.is_a?(Hash) && + %w[id repository_id head_repository_id].all? { |key| workflow[key].is_a?(Integer) } && + workflow["id"] == run && workflow["repository_id"] == repository_id && + workflow["head_repository_id"] == repository_id && + workflow["head_sha"] == context["GITHUB_SHA"], + "Artifact does not belong to this release producer") + true + end +end + +if $PROGRAM_NAME == __FILE__ + begin + raise ArgumentError, "Unsupported manifest operation" unless ARGV == ["verify-artifact"] + input = STDIN.read(PlayBundleManifest::MAX_MANIFEST_BYTES + 1) + raise ArgumentError, "Artifact metadata exceeds its limit" if input.bytesize > PlayBundleManifest::MAX_MANIFEST_BYTES + PlayBundleManifest.verify_artifact!( + artifact: JSON.parse(input), artifact_id: ENV.fetch("PLAY_BUNDLE_ARTIFACT_ID", ""), + artifact_name: ENV.fetch("PLAY_BUNDLE_ARTIFACT_NAME", ""), + producer_attempt: ENV.fetch("PRAIRIE_PLAY_BUNDLE_ATTEMPT", "") + ) + puts "Verified Play bundle artifact provenance" + rescue ArgumentError, JSON::ParserError + warn "Play bundle artifact verification failed" + exit 1 + end +end diff --git a/gradle.properties b/gradle.properties index 364b345806..d4cb02ed41 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,5 @@ org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +org.gradle.caching=true android.useAndroidX=true kotlin.code.style=official android.nonTransitiveRClass=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c7835b8d22..183111f341 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,6 +32,7 @@ ksp = "2.1.20-2.0.1" room = "2.8.4" androidx-test-core = "1.6.1" androidx-webkit = "1.16.0" +androidx-browser = "1.10.0" bouncycastle = "1.84" profileinstaller = "1.4.1" benchmark = "1.3.4" @@ -62,6 +63,8 @@ androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "room" } androidx-test-core = { module = "androidx.test:core-ktx", version.ref = "androidx-test-core" } androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "androidx-webkit" } +# Custom Tabs for external sign-in (phone app only). +androidx-browser = { module = "androidx.browser:browser", version.ref = "androidx-browser" } # Ktor ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } @@ -96,6 +99,8 @@ navigation-compose = { module = "androidx.navigation:navigation-compose", versio # Coil coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } +# SVG sign-in provider icons (the first-party OIDC plugin ships one). +coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" } # Haze — backdrop blur for the phone app's floating tab bar (real blur on # API 31+, tinted scrim fallback below). diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 2aefeed13d..3d96d3f79c 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -314,6 +314,14 @@ <sha256 value="5583a599aaf8dfced2320a0992f826fd7be93b58d96a1ef595fdf526a30cfad3" origin="Generated by Gradle"/> </artifact> </component> + <component group="androidx.browser" name="browser" version="1.10.0"> + <artifact name="browser-1.10.0.aar"> + <sha256 value="fb40d1ee5fd910c9d112200868a9534156a7a999abc28fbb3c6815a2e3ac5150" origin="Generated by Gradle"/> + </artifact> + <artifact name="browser-1.10.0.module"> + <sha256 value="dddcac467d097d324d290440a45d97e90ddb447d688788820ba62b2066e466d1" origin="Generated by Gradle"/> + </artifact> + </component> <component group="androidx.collection" name="collection" version="1.0.0"> <artifact name="collection-1.0.0.jar"> <sha256 value="9c8d117b5c2bc120a1cdfeb857e05b495b16c36013570372a708f7827e3ac9f9" origin="Generated by Gradle"/> @@ -4813,6 +4821,14 @@ <sha256 value="0ee19d9d366629585ac405f446780f65a2470fdaa59f143bdad307185fb3cdd7" origin="Generated by Gradle"/> </artifact> </component> + <component group="com.caverock" name="androidsvg-aar" version="1.4"> + <artifact name="androidsvg-aar-1.4.aar"> + <sha256 value="02a5b08a2b35d2d58eb2eaca9d84ac00fb341da725fdbd653ea3ed130437e95a" origin="Generated by Gradle"/> + </artifact> + <artifact name="androidsvg-aar-1.4.pom"> + <sha256 value="a789196681aeff63a375ed4bf417c721f087729e9c90c5e4efccdbc9e97e924a" origin="Generated by Gradle"/> + </artifact> + </component> <component group="com.google.accompanist" name="accompanist-drawablepainter" version="0.36.0"> <artifact name="accompanist-drawablepainter-0.36.0.aar"> <sha256 value="599137c53f921c901ee40df056a7f940a6acdb0dd8f05763deec8cf495a40848" origin="Generated by Gradle"/> @@ -6347,6 +6363,30 @@ <sha256 value="5d48853a03cda8a9f318580853bca1053993acf00d50d6fc037a2778e6653ede" origin="Generated by Gradle"/> </artifact> </component> + <component group="io.coil-kt.coil3" name="coil-svg" version="3.1.0"> + <artifact name="coil-svg-3.1.0.module"> + <sha256 value="1067d41f99500cf01c6d70015e6ea579404788c349041c408487cc623a1fc7d9" origin="Generated by Gradle"/> + </artifact> + <artifact name="coil-svg-metadata-3.1.0.jar"> + <sha256 value="cd4c404d0d5e024eeaedd5987c23156f7dba7db2cef67fe05ef13db0a16f1444" origin="Generated by Gradle"/> + </artifact> + </component> + <component group="io.coil-kt.coil3" name="coil-svg-android" version="3.1.0"> + <artifact name="coil-svg-android-3.1.0.module"> + <sha256 value="f2bde0b0d2cd328b5208d878ada6e92e3e14e4f6c37b099ae446b5a167632220" origin="Generated by Gradle"/> + </artifact> + <artifact name="coil-svg-release.aar"> + <sha256 value="826b88234f17179600cd909993956d2365366d7a8b7b3b238699aeafd14d2dd5" origin="Generated by Gradle"/> + </artifact> + </component> + <component group="io.coil-kt.coil3" name="coil-svg-jvm" version="3.1.0"> + <artifact name="coil-svg-jvm-3.1.0.jar"> + <sha256 value="413a53b4b6e0a40b851d1a99a01a4cd91ac0dc68facefddd7adfdb2d5456588f" origin="Generated by Gradle"/> + </artifact> + <artifact name="coil-svg-jvm-3.1.0.module"> + <sha256 value="99abb66173cffebc8adb815e738a3feb443787290d51805968a7e49aafc28f5f" origin="Generated by Gradle"/> + </artifact> + </component> <component group="io.github.peerless2012" name="ass-kt" version="0.5.1"> <artifact name="ass-kt-0.5.1.aar"> <sha256 value="1051212faf98ef956e06992b002d43cfdc81b6c60dcd32662e8d3ff52d584d65" origin="Generated by Gradle"/> diff --git a/scripts/android-playback-test.sh b/scripts/android-playback-test.sh index e0d5a2375c..92b3a1416c 100755 --- a/scripts/android-playback-test.sh +++ b/scripts/android-playback-test.sh @@ -28,6 +28,12 @@ # long-running health check: cold-start playback, sample # every 10s, fail on crash/stall/error; report drops, # rebuffers, and any fatal exceptions at the end +# party Watch Party room and binding state (the "party" object +# of status); never tokens or tickets +# spread <serialA> <serialB> [samples] [intervalSec] +# sample two devices in a Watch Party and report the +# inter-client position spread on the room's server clock +# (defaults: 10 samples, 2s apart). Ignores -s. # # Environment: # PRAIRIE_DEVICE_SERIAL default adb serial (e.g. 192.0.2.10:5555 or a USB serial); @@ -147,6 +153,57 @@ case "$CMD" in status) status_json ;; + party) + status_json | python3 -c 'import json,sys; d=json.load(sys.stdin); print(json.dumps(d.get("party", {"party": "none"}), sort_keys=True))' + ;; + spread) + [[ $# -ge 2 ]] || die "spread needs two serials" + serial_a="$1"; serial_b="$2"; samples="${3:-10}"; interval="${4:-2}" + spread_sample() { # spread_sample <serial> -> one status JSON line + SERIAL="$1" status_json + } + for ((i = 1; i <= samples; i++)); do + a=$(spread_sample "$serial_a"); b=$(spread_sample "$serial_b") + python3 - "$a" "$b" <<'PY' +import json, sys +def project(raw): + d = json.loads(raw) + party = d.get("party") or {} + offset = party.get("serverOffsetMs") + # The screen position is the source timeline but only ticks every + # 500 ms on TV; the player position is fresh. Use the player position + # when both are on the same timeline, the screen position otherwise + # (a remux or transcode that starts mid-title). + pos = d.get("screenPositionSec") + player = d["positionMs"] / 1000.0 if d.get("positionMs") is not None else None + if player is not None and (pos is None or abs(player - pos) < 1.0): + pos = player + if offset is None or pos is None: + return None, d + server_ms = d["sampledWallMs"] + offset + return (pos, server_ms, bool(d.get("isPlaying"))), d +(a, da), (b, db) = project(sys.argv[1]), project(sys.argv[2]) +if a is None or b is None: + print("inconclusive: missing position or server clock on", "A" if a is None else "B") + sys.exit(0) +# Project the earlier sample forward to the later one's server time while playing. +ref = max(a[1], b[1]) +def at(sample): + pos, t, playing = sample + return pos + ((ref - t) / 1000.0 if playing else 0.0) +gap_ms = abs(a[1] - b[1]) +spread = abs(at(a) - at(b)) +print(json.dumps({ + "spreadSec": round(spread, 3), + "sampleGapMs": gap_ms, + "conclusive": gap_ms <= 1500, + "a": {"pos": a[0], "playing": a[2], "party": (da.get("party") or {}).get("playbackState")}, + "b": {"pos": b[0], "playing": b[2], "party": (db.get("party") or {}).get("playbackState")}, +})) +PY + (( i < samples )) && sleep "$interval" + done + ;; wait-playing) wait_playing "${1:-30}" ;; diff --git a/scripts/ci-build-metadata.py b/scripts/ci-build-metadata.py new file mode 100755 index 0000000000..c7e1bd8984 --- /dev/null +++ b/scripts/ci-build-metadata.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Record reproducible build inputs without publishing a Build Scan.""" + +import json +import os +import re +import subprocess +from pathlib import Path + +source_sha = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip() +wrapper = Path("gradle/wrapper/gradle-wrapper.properties").read_text() +catalog = Path("gradle/libs.versions.toml").read_text() +gradle = re.search(r"gradle-([\d.]+)-bin", wrapper).group(1) +agp = re.search(r'^agp = "([^"]+)"', catalog, re.MULTILINE).group(1) +cache = os.environ.get("PRAIRIE_CI_CACHE_FLAG", "--build-cache") +event_path = os.environ.get("GITHUB_EVENT_PATH") +event = json.loads(Path(event_path).read_text()) if event_path else {} +pull_request = event.get("pull_request") or {} +head_repository = (pull_request.get("head") or {}).get("repo") or {} +metadata = { + "source_sha": source_sha, + "workflow_sha": os.environ.get("GITHUB_WORKFLOW_SHA", os.environ.get("GITHUB_SHA", "local")), + "variant": "baseline" if cache == "--no-build-cache" else "optimized", + "cache_regime": "unverified", + "cache_namespace": os.environ.get("GITHUB_REF", "local"), + "profile": " ".join( + os.environ.get(key, "") + for key in ("PRAIRIE_CI_CACHE_FLAG", "PRAIRIE_CI_PARALLEL_FLAG", "PRAIRIE_CI_CONFIGURATION_FLAG") + ).strip(), + "toolchain": f"JDK 21 / Gradle {gradle} / AGP {agp}", + "java_version": subprocess.check_output(["java", "-version"], stderr=subprocess.STDOUT, text=True).splitlines()[0], + "runner_image": os.environ.get("ImageVersion", "unavailable"), + "cache_policy": { + "read_only": os.environ.get("PRAIRIE_CI_CACHE_READ_ONLY", "unavailable"), + "event_name": os.environ.get("GITHUB_EVENT_NAME", "local"), + "head_repository": head_repository.get("full_name", ""), + "author_association": pull_request.get("author_association", ""), + }, +} +print("PRAIRIE_CI_BENCHMARK " + json.dumps(metadata, sort_keys=True)) diff --git a/scripts/ci-result.py b/scripts/ci-result.py new file mode 100644 index 0000000000..677906bc68 --- /dev/null +++ b/scripts/ci-result.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Fail the stable Android CI check unless every selected job succeeded.""" + +import json +import os +import sys + + +JOBS = { + "unit-tests": "unit_tests", + "lint": "lint", + "release-readiness": "release_readiness", + "ffmpeg-aar": "ffmpeg_aar", +} + + +def check_results(needs): + if not isinstance(needs, dict) or set(needs) != {"changes", *JOBS}: + raise ValueError("Missing or unfamiliar dependency results") + classifier = needs["changes"] + if not isinstance(classifier, dict) or classifier.get("result") != "success": + raise ValueError("Change classification did not succeed") + selected = classifier.get("outputs") + if not isinstance(selected, dict): + raise ValueError("Missing classifier outputs") + mode = selected.get("mode") + if mode not in {"full", "docs", "ffmpeg"}: + raise ValueError("Invalid classifier mode") + expected = { + "schema": "1", + "mode": mode, + "unit_tests": "true" if mode == "full" else "false", + "lint": "true" if mode == "full" else "false", + "release_readiness": "true" if mode == "full" else "false", + "ffmpeg_aar": "true" if mode == "ffmpeg" else "false", + } + if selected != expected: + raise ValueError("Incomplete or inconsistent classifier outputs") + for job, output in JOBS.items(): + result = needs[job].get("result") if isinstance(needs[job], dict) else None + required_result = "success" if selected[output] == "true" else "skipped" + if result != required_result: + raise ValueError(f"{job}: expected {required_result}, got {result!r}") + return mode + + +def main(): + try: + mode = check_results(json.loads(os.environ["PRAIRIE_CI_NEEDS_JSON"])) + except (KeyError, ValueError, TypeError) as error: + print(f"Android CI failed: {error}", file=sys.stderr) + return 1 + print(f"Android CI passed ({mode})") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ci-routing.py b/scripts/ci-routing.py new file mode 100644 index 0000000000..9cada39d7d --- /dev/null +++ b/scripts/ci-routing.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Select Android CI work from a complete Git diff; uncertainty runs all jobs.""" + +import json +import os +from pathlib import Path, PurePosixPath +import re +import subprocess + + +# These are prose-only areas. Keep policies, API contracts, fixtures, native +# notices, scripts, and unfamiliar documentation locations on full CI. +DOC_FILES = { + "README.md", + "FEATURES.md", + "scripts/README-ffmpeg-aar.md", + "docs/playback/README.md", + "docs/playback/intro-skip.md", + "docs/playback/01-media3-only-player-architecture.md", + "docs/playback/02-migration-compatibility-validation.md", + "docs/playback/03-reference-implementation-review.md", + "docs/playback/04-implementation-status-and-dv-handoff.md", +} +DOC_DIRECTORIES = ( + "docs/notes/", + "docs/plans/", + "docs/superpowers/notes/", + "docs/superpowers/plans/", + "docs/superpowers/specs/", +) +SHA = re.compile(r"[0-9a-f]{40}") + + +def outputs(mode): + return { + "schema": "1", + "mode": mode, + "unit_tests": str(mode == "full").lower(), + "lint": str(mode == "full").lower(), + "release_readiness": str(mode == "full").lower(), + "ffmpeg_aar": str(mode == "ffmpeg").lower(), + } + + +def harmless_document(path): + parts = PurePosixPath(path).parts + if ( + not parts + or path.startswith("/") + or any(part in {"", ".", ".."} for part in path.split("/")) + or any(ord(character) < 32 for character in path) + or "\\" in path + ): + return False + # Contract and fixture documents are deliberately outside the prose route, + # even if a future one is added below an otherwise permitted directory. + lower_path = path.lower() + if any(word in lower_path for word in ("api-v2", "contract", "fixture")): + return False + return path in DOC_FILES or ( + path.endswith(".md") and path.startswith(DOC_DIRECTORIES) + ) + + +def classify_diff(data): + """Consume git diff --name-status -z, including rename source and target.""" + if not data or not data.endswith(b"\0"): + return "full", "Missing or incomplete diff" + try: + fields = data[:-1].decode("utf-8", errors="strict").split("\0") + except UnicodeDecodeError: + return "full", "Diff paths are not valid UTF-8" + index = 0 + while index < len(fields): + status = fields[index] + index += 1 + if status not in {"A", "M"}: + return "full", "Deletion, rename, or unfamiliar change status" + if index >= len(fields) or not harmless_document(fields[index]): + return "full", "Changed path requires Android validation" + index += 1 + return "docs", "Only prose documentation was added or modified" + + +def git(*arguments): + return subprocess.run( + ["git", *arguments], check=True, capture_output=True, timeout=30 + ).stdout + + +def classify_environment(environment): + event_name = environment.get("GITHUB_EVENT_NAME", "") + ffmpeg = environment.get("PRAIRIE_CI_FFMPEG_ONLY", "false") + if ffmpeg not in {"true", "false"}: + return "full", "Invalid manual FFmpeg input" + if event_name == "workflow_dispatch": + return ("ffmpeg", "Explicit FFmpeg-only dispatch") if ffmpeg == "true" else ( + "full", "Manual dispatch requests full Android validation" + ) + if event_name not in {"pull_request", "push"} or ffmpeg == "true": + return "full", "Unfamiliar event or invalid FFmpeg event" + try: + event = json.loads(Path(environment["GITHUB_EVENT_PATH"]).read_text()) + base = event["pull_request"]["base"]["sha"] if event_name == "pull_request" else event["before"] + head = environment["GITHUB_SHA"] + if not isinstance(base, str) or not SHA.fullmatch(base) or base == "0" * 40: + return "full", "No usable base commit" + if not SHA.fullmatch(head) or git("rev-parse", "HEAD").decode().strip() != head: + return "full", "Checkout does not match the requested commit" + # Checkout stays shallow. Fetch only the exact base/before commit if it + # is absent; failures keep all validation rather than trusting a file list + # from the API (which can be truncated). + try: + git("cat-file", "-e", f"{base}^{{commit}}") + except subprocess.CalledProcessError: + git("fetch", "--no-tags", "--depth=1", "origin", base) + data = git("diff", "--name-status", "-z", "--find-renames", base, head, "--") + mode, reason = classify_diff(data) + if mode == "docs": + paths = data[:-1].decode("utf-8").split("\0")[1::2] + tree = git("ls-tree", "-z", head, "--", *paths) + entries = tree[:-1].split(b"\0") if tree.endswith(b"\0") else [] + found = set() + for entry in entries: + metadata, path = entry.split(b"\t", 1) + if not metadata.startswith(b"100644 blob "): + return "full", "Documentation is not a regular prose file" + found.add(path.decode("utf-8")) + if found != set(paths): + return "full", "Could not verify every documentation file" + except (KeyError, TypeError, ValueError, OSError, subprocess.SubprocessError): + return "full", "Could not obtain a complete diff" + return mode, reason + + +def main(): + mode, reason = classify_environment(os.environ) + selected = outputs(mode) + print(json.dumps({**selected, "reason": reason}, sort_keys=True)) + if os.environ.get("GITHUB_OUTPUT"): + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + for key, value in selected.items(): + output.write(f"{key}={value}\n") + + +if __name__ == "__main__": + main() diff --git a/scripts/fixtures/ci-result.json b/scripts/fixtures/ci-result.json new file mode 100644 index 0000000000..522b76f792 --- /dev/null +++ b/scripts/fixtures/ci-result.json @@ -0,0 +1,23 @@ +{ + "full": { + "changes": {"result": "success", "outputs": {"schema": "1", "mode": "full", "unit_tests": "true", "lint": "true", "release_readiness": "true", "ffmpeg_aar": "false"}}, + "unit-tests": {"result": "success"}, + "lint": {"result": "success"}, + "release-readiness": {"result": "success"}, + "ffmpeg-aar": {"result": "skipped"} + }, + "docs": { + "changes": {"result": "success", "outputs": {"schema": "1", "mode": "docs", "unit_tests": "false", "lint": "false", "release_readiness": "false", "ffmpeg_aar": "false"}}, + "unit-tests": {"result": "skipped"}, + "lint": {"result": "skipped"}, + "release-readiness": {"result": "skipped"}, + "ffmpeg-aar": {"result": "skipped"} + }, + "ffmpeg": { + "changes": {"result": "success", "outputs": {"schema": "1", "mode": "ffmpeg", "unit_tests": "false", "lint": "false", "release_readiness": "false", "ffmpeg_aar": "true"}}, + "unit-tests": {"result": "skipped"}, + "lint": {"result": "skipped"}, + "release-readiness": {"result": "skipped"}, + "ffmpeg-aar": {"result": "success"} + } +} diff --git a/scripts/fixtures/ci-routing.json b/scripts/fixtures/ci-routing.json new file mode 100644 index 0000000000..dc44d7692c --- /dev/null +++ b/scripts/fixtures/ci-routing.json @@ -0,0 +1,38 @@ +[ + {"case": "modified readme", "diff": "M\u0000README.md\u0000", "mode": "docs"}, + {"case": "new prose plan", "diff": "A\u0000docs/plans/new-plan.md\u0000", "mode": "docs"}, + {"case": "multiple prose files", "diff": "M\u0000FEATURES.md\u0000A\u0000docs/notes/new note.md\u0000", "mode": "docs"}, + {"case": "native build guide", "diff": "M\u0000scripts/README-ffmpeg-aar.md\u0000", "mode": "docs"}, + {"case": "source change", "diff": "M\u0000androidApp/src/androidMain/kotlin/App.kt\u0000", "mode": "full"}, + {"case": "shared change", "diff": "M\u0000shared/src/commonMain/kotlin/App.kt\u0000", "mode": "full"}, + {"case": "native change", "diff": "M\u0000android-shared/src/native/dovi/build.sh\u0000", "mode": "full"}, + {"case": "new non-document", "diff": "A\u0000docs/plans/new-input.json\u0000", "mode": "full"}, + {"case": "unknown markdown", "diff": "A\u0000new-area/README.md\u0000", "mode": "full"}, + {"case": "unknown docs markdown", "diff": "A\u0000docs/new-area/note.md\u0000", "mode": "full"}, + {"case": "policy", "diff": "M\u0000AGENTS.md\u0000", "mode": "full"}, + {"case": "contribution policy", "diff": "M\u0000CONTRIBUTING.md\u0000", "mode": "full"}, + {"case": "legal document", "diff": "M\u0000TRADEMARK.md\u0000", "mode": "full"}, + {"case": "qualification plan", "diff": "M\u0000docs/watch-party-implementation-plan.md\u0000", "mode": "full"}, + {"case": "API document", "diff": "M\u0000docs/playback/sequenced-api-v2.md\u0000", "mode": "full"}, + {"case": "contract markdown in prose area", "diff": "A\u0000docs/plans/settings-contract.md\u0000", "mode": "full"}, + {"case": "fixture markdown in prose area", "diff": "A\u0000docs/notes/fixture.md\u0000", "mode": "full"}, + {"case": "vendored API fixture", "diff": "M\u0000shared/src/commonTest/resources/api/v2/fixtures/catalog/items.json\u0000", "mode": "full"}, + {"case": "vendored contract provenance", "diff": "M\u0000shared/src/commonTest/resources/diagnostics/v1/SOURCE.md\u0000", "mode": "full"}, + {"case": "Gradle catalog", "diff": "M\u0000gradle/libs.versions.toml\u0000", "mode": "full"}, + {"case": "Gradle build", "diff": "M\u0000build.gradle.kts\u0000", "mode": "full"}, + {"case": "manifest", "diff": "M\u0000androidTvApp/src/androidMain/AndroidManifest.xml\u0000", "mode": "full"}, + {"case": "workflow", "diff": "M\u0000.github/workflows/trusted-linux-ci.yml\u0000", "mode": "full"}, + {"case": "CI script", "diff": "M\u0000scripts/ci-routing.py\u0000", "mode": "full"}, + {"case": "mixed docs and code", "diff": "M\u0000README.md\u0000M\u0000shared/build.gradle.kts\u0000", "mode": "full"}, + {"case": "deleted documentation", "diff": "D\u0000README.md\u0000", "mode": "full"}, + {"case": "renamed documentation", "diff": "R100\u0000docs/plans/old.md\u0000docs/plans/new.md\u0000", "mode": "full"}, + {"case": "copied documentation", "diff": "C100\u0000README.md\u0000FEATURES.md\u0000", "mode": "full"}, + {"case": "unknown status", "diff": "Z\u0000README.md\u0000", "mode": "full"}, + {"case": "truncated path", "diff": "M\u0000README.md", "mode": "full"}, + {"case": "truncated record", "diff": "M\u0000README.md\u0000A\u0000", "mode": "full"}, + {"case": "empty path", "diff": "M\u0000\u0000", "mode": "full"}, + {"case": "empty diff", "diff": "", "mode": "full"}, + {"case": "path traversal", "diff": "A\u0000docs/plans/../../build.md\u0000", "mode": "full"}, + {"case": "absolute path", "diff": "M\u0000/README.md\u0000", "mode": "full"}, + {"case": "control character", "diff": "A\u0000docs/plans/new\nnote.md\u0000", "mode": "full"} +] diff --git a/scripts/prairie-invariants.txt b/scripts/prairie-invariants.txt index 019092f070..75dbbfbfd8 100644 --- a/scripts/prairie-invariants.txt +++ b/scripts/prairie-invariants.txt @@ -67,7 +67,7 @@ androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/theme/Color. androidApp/src/androidMain/res/values/colors.xml 1 #FFE0A84A phone Dusk XML accent # --- Quick Connect naming (12925ada). -androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/AccountSection.kt 1 "Quick Connect" phone settings row +androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsScreen.kt 1 "Quick Connect" phone settings row androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/DevicePairingScreen.kt 1 "Quick Connect" phone approval screen title androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvPairDeviceScreen.kt 1 "Quick Connect" TV approval screen title @@ -93,7 +93,7 @@ androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/serv # --- App update status (b3b04cbb). shared/src/commonMain/kotlin/org/prairieserver/prairie/di/NetworkModule.kt 1 AppUpdateChecker\( update checker binding -androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/ServerInfoSection.kt 1 AppUpdateStatusRow\(\) phone settings update row (dropped by a sync once) +androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsScreen.kt 1 AppUpdateStatusRow\(\) phone settings update row (dropped by a sync once) androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvSettingsScreen.kt 1 TvAppUpdateStatusRows\(\) TV settings update rows # --- Player: quality ladder, trickplay, stats for nerds (63055d17, 261d6f81). diff --git a/scripts/sync-apiv2-fixtures.sh b/scripts/sync-apiv2-fixtures.sh index 2feef5b9c0..c0235aa7ab 100755 --- a/scripts/sync-apiv2-fixtures.sh +++ b/scripts/sync-apiv2-fixtures.sh @@ -5,7 +5,8 @@ # Usage: scripts/sync-apiv2-fixtures.sh /path/to/silo-server # # Only the fixtures the Android client consumes are copied (the four pilot -# operations, the probe's system-info body, and the generic problem bodies) +# operations, the probe's system-info body, the device sign-in and server +# identity bodies, the external sign-in bodies, and the generic problem bodies) # plus fixtures.schema.json and an index.json filtered to the vendored # entries. The full OpenAPI document is never vendored. SOURCE records the # exact server commit in the repository's key=value convention. @@ -32,6 +33,28 @@ SELECTED=( rate_limited profile_verification_required not_acceptable + start_device_login_ok + poll_device_login_ok + poll_device_login_opened + cancel_device_login_ok + get_device_login_ok + get_device_login_capability_ok + server_identity_ok + server_connections_ok + list_auth_providers_ok + get_oauth_handshake_capabilities_ok + get_external_sign_in_capabilities_ok + complete_oauth_login_native_ok + complete_oauth_login_invalid_grant + list_account_identities_ok + create_account_identity_link_ticket_ok + create_account_identity_link_ticket_wrong_password + complete_account_identity_link_invalid_grant + delete_account_identity_last_sign_in_method + refresh_session_provider_unavailable + sign_in_with_network_identity_ok + sign_in_with_network_identity_off_overlay + link_account_identity_with_network_ok ) [ -d "$SRC" ] || { echo "no fixtures at $SRC" >&2; exit 1; } @@ -62,13 +85,26 @@ PY COMMIT="$(git -C "$SERVER_DIR" rev-parse HEAD)" REF="$(git -C "$SERVER_DIR" rev-parse --abbrev-ref HEAD)" +# A checkout whose fixtures differ from its HEAD is a local snapshot: the +# commit named below does not contain these bodies, so SOURCE says so. +SNAPSHOT=committed +if [ -n "$(git -C "$SERVER_DIR" status --porcelain -- contracts/api/v2/fixtures contracts/api/v2/fixtures.schema.json)" ]; then + SNAPSHOT=uncommitted + echo "warning: $SRC has uncommitted changes; SOURCE records a local snapshot" >&2 +fi cat > "$DEST/SOURCE" <<EOS repository=https://github.com/Silo-Server/silo-server path=contracts/api/v2/fixtures commit=$COMMIT ref=$REF +snapshot=$SNAPSHOT api_major=2 +snapshot=uncommitted means the bodies were copied from a server checkout with +uncommitted fixture changes on top of that commit, so the commit alone does +not reproduce them. Re-vendor from a server commit that contains them before +relying on this record. + Byte-identical copies of a subset of the server's native API v2 contract fixtures; do not hand-edit any of them. The server generates them through its real v2 router (make apiv2-fixtures) and validates the bodies against the diff --git a/scripts/test-ci-cache-invalidation.py b/scripts/test-ci-cache-invalidation.py new file mode 100755 index 0000000000..d5b786742d --- /dev/null +++ b/scripts/test-ci-cache-invalidation.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Prove source-reading tests fail after file-only edits in a warm workspace.""" + +import json +import re +import subprocess +from pathlib import Path +import xml.etree.ElementTree as ET + +root = Path(__file__).resolve().parents[1] +logs = root / "build/ci-cache-invalidation" +logs.mkdir(parents=True, exist_ok=True) +probes = [ + ( + "workflow", + ":androidTvApp:testDebugUnitTest", + "org.prairieserver.prairie.tv.ui.screens.player.TvFireTvRcFeedbackOwnershipTest", + "release display version keeps the complete tag", + root / ".github/workflows/release.yml", + lambda text: text.replace( + "PRAIRIE_DISPLAY_VERSION: ${{ needs.setup.outputs.version }}", + "PRAIRIE_DISPLAY_VERSION: ${{ needs.setup.outputs.version_name }}", + ), + ), + ( + "sibling-manifest", + ":androidApp:testDebugUnitTest", + "org.prairieserver.prairie.android.auth.NativeSignInManifestTest", + "tvNeverClaimsTheAppRedirect", + root / "androidTvApp/src/androidMain/AndroidManifest.xml", + lambda text: text + '\n<!-- org.prairieserver.prairie" -->\n', + ), + ( + "source-comment", + ":shared:testDebugUnitTest", + "org.prairieserver.prairie.network.apiv2.ApiV2NoV1TransportSourceTest", + "noV1PathLiteralUnderApiV2Package", + next((root / "shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2").glob("*.kt")), + lambda text: text + "\n// /api/v1\n", + ), +] + +results = [] +for name, task, test, method, source, mutate in probes: + command = [ + str(root / "gradlew"), task, "--tests", test, + "--build-cache", "--max-workers=2", "--console=plain", + '-Dorg.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8', + ] + report = root / task.split(":")[1] / "build/test-results/testDebugUnitTest" / f"TEST-{test}.xml" + + def run(label): + result = subprocess.run(command, cwd=root, capture_output=True, text=True) + output = result.stdout + result.stderr + (logs / f"{name}-{label}.log").write_text(output) + suffix = " FAILED" if label == "changed" else "" + if not re.search(r"^> Task " + re.escape(task) + suffix + r"$", output, re.MULTILINE): + raise RuntimeError(f"{name}/{label}: the target Test task did not execute") + if not report.exists(): + raise RuntimeError(f"{name}/{label}: the target test produced no report") + (logs / f"{name}-{label}.xml").write_bytes(report.read_bytes()) + cases = ET.parse(report).getroot().findall("testcase") + target = [case for case in cases if case.get("classname") == test and case.get("name") == method] + if len(target) != 1 or any(case.find("skipped") is not None for case in cases): + raise RuntimeError(f"{name}/{label}: the target test did not execute") + if label == "changed": + failures = target[0].findall("failure") + if len(failures) != 1 or failures[0].get("type") != "java.lang.AssertionError": + raise RuntimeError(f"{name}: mutation did not fail the expected assertion") + elif result.returncode or any(case.find("failure") is not None or case.find("error") is not None for case in cases): + raise RuntimeError(f"{name}/{label}: unchanged focused tests failed") + return result.returncode, output + + original = source.read_bytes() + changed = mutate(original.decode()) + if changed == original.decode(): + raise RuntimeError(f"{name}: mutation did not change the input") + try: + status, output = run("warm") + source.write_text(changed) + status, output = run("changed") + if not status or not re.search(r"^> Task " + re.escape(task) + r" FAILED$", output, re.MULTILINE): + raise RuntimeError(f"{name}: changed source was not rejected by the Test task") + finally: + source.write_bytes(original) + status, output = run("restored") + if status: + raise RuntimeError(f"{name}: restored focused test failed") + results.append({"probe": name, "changed_input": str(source.relative_to(root)), "result": "passed"}) + print(f"{name}: warm passed; file-only mutation failed; restoration passed", flush=True) + +(logs / "results.json").write_text(json.dumps(results, indent=2)) diff --git a/scripts/test-ci-result.py b/scripts/test-ci-result.py new file mode 100644 index 0000000000..7b38f07a4c --- /dev/null +++ b/scripts/test-ci-result.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 + +import copy +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import unittest + + +SCRIPT_DIR = Path(__file__).resolve().parent +spec = importlib.util.spec_from_file_location("ci_result", SCRIPT_DIR / "ci-result.py") +result = importlib.util.module_from_spec(spec) +spec.loader.exec_module(result) +FIXTURES = json.loads((SCRIPT_DIR / "fixtures/ci-result.json").read_text()) + + +class AggregateResultTest(unittest.TestCase): + def test_successful_full_docs_and_ffmpeg_modes(self): + for mode, needs in FIXTURES.items(): + with self.subTest(mode=mode): + self.assertEqual(mode, result.check_results(needs)) + + def test_each_selected_job_must_succeed(self): + for mode, jobs in (("full", ("unit-tests", "lint", "release-readiness")), ("ffmpeg", ("ffmpeg-aar",))): + for job in jobs: + for outcome in ("skipped", "failure", "cancelled", "unknown", None): + with self.subTest(mode=mode, job=job, outcome=outcome): + needs = copy.deepcopy(FIXTURES[mode]) + needs[job]["result"] = outcome + with self.assertRaises(ValueError): + result.check_results(needs) + + def test_unselected_jobs_must_be_intentionally_skipped(self): + for mode, jobs in (("full", ("ffmpeg-aar",)), ("docs", ("unit-tests", "lint", "release-readiness", "ffmpeg-aar")), + ("ffmpeg", ("unit-tests", "lint", "release-readiness"))): + for job in jobs: + for outcome in ("success", "failure", "cancelled", "unknown", None): + with self.subTest(mode=mode, job=job, outcome=outcome): + needs = copy.deepcopy(FIXTURES[mode]) + needs[job]["result"] = outcome + with self.assertRaises(ValueError): + result.check_results(needs) + + def test_classifier_must_succeed_in_every_mode(self): + for mode in FIXTURES: + for outcome in ("skipped", "failure", "cancelled", None): + with self.subTest(mode=mode, outcome=outcome): + needs = copy.deepcopy(FIXTURES[mode]) + needs["changes"]["result"] = outcome + with self.assertRaises(ValueError): + result.check_results(needs) + + def test_outputs_must_be_complete_and_consistent(self): + for mode in FIXTURES: + for key in FIXTURES[mode]["changes"]["outputs"]: + with self.subTest(mode=mode, missing=key): + needs = copy.deepcopy(FIXTURES[mode]) + del needs["changes"]["outputs"][key] + with self.assertRaises(ValueError): + result.check_results(needs) + for invalid in ("", "unknown", True, False, None): + with self.subTest(mode=mode, key=key, invalid=invalid): + needs = copy.deepcopy(FIXTURES[mode]) + needs["changes"]["outputs"][key] = invalid + with self.assertRaises(ValueError): + result.check_results(needs) + + def test_docs_mode_cannot_turn_on_a_validation_job(self): + for key in ("unit_tests", "lint", "release_readiness", "ffmpeg_aar"): + with self.subTest(key=key): + needs = copy.deepcopy(FIXTURES["docs"]) + needs["changes"]["outputs"][key] = "true" + with self.assertRaises(ValueError): + result.check_results(needs) + + def test_incomplete_dependency_payload_is_rejected(self): + for job in FIXTURES["full"]: + with self.subTest(job=job): + needs = copy.deepcopy(FIXTURES["full"]) + del needs[job] + with self.assertRaises(ValueError): + result.check_results(needs) + for needs in (None, [], {}, {**FIXTURES["full"], "unexpected": {"result": "success"}}): + with self.subTest(needs=needs): + with self.assertRaises(ValueError): + result.check_results(needs) + + def test_cli_exits_nonzero_for_failed_jobs_and_invalid_json(self): + failed = copy.deepcopy(FIXTURES["full"]) + failed["release-readiness"]["result"] = "failure" + for payload in (json.dumps(failed), "{", "null"): + with self.subTest(payload=payload): + process = self.run_cli(payload) + self.assertEqual(1, process.returncode) + self.assertIn("Android CI failed", process.stderr) + + def test_cli_accepts_only_valid_special_mode_results(self): + for mode in ("docs", "ffmpeg"): + with self.subTest(mode=mode): + process = self.run_cli(json.dumps(FIXTURES[mode])) + self.assertEqual(0, process.returncode, process.stderr) + self.assertIn(f"Android CI passed ({mode})", process.stdout) + + def run_cli(self, payload): + return subprocess.run( + ["python3", str(SCRIPT_DIR / "ci-result.py")], capture_output=True, text=True, + env={**os.environ, "PRAIRIE_CI_NEEDS_JSON": payload}, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test-ci-routing.py b/scripts/test-ci-routing.py new file mode 100644 index 0000000000..1a99316b70 --- /dev/null +++ b/scripts/test-ci-routing.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 + +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + + +SCRIPT_DIR = Path(__file__).resolve().parent +spec = importlib.util.spec_from_file_location("ci_routing", SCRIPT_DIR / "ci-routing.py") +routing = importlib.util.module_from_spec(spec) +spec.loader.exec_module(routing) + + +class DiffRoutingTest(unittest.TestCase): + def test_document_and_full_validation_fixtures(self): + fixtures = json.loads((SCRIPT_DIR / "fixtures/ci-routing.json").read_text()) + for fixture in fixtures: + with self.subTest(fixture["case"]): + self.assertEqual( + fixture["mode"], routing.classify_diff(fixture["diff"].encode())[0] + ) + + def test_invalid_path_encoding_runs_all_jobs(self): + self.assertEqual("full", routing.classify_diff(b"M\0docs/plans/\xff.md\0")[0]) + + def test_manual_dispatch_runs_all_jobs_without_needing_a_diff(self): + self.assertEqual("full", routing.classify_environment({"GITHUB_EVENT_NAME": "workflow_dispatch"})[0]) + + def test_only_explicit_ffmpeg_dispatch_selects_ffmpeg(self): + for event in ("workflow_dispatch", "pull_request", "push", "unknown"): + with self.subTest(event=event): + expected = "ffmpeg" if event == "workflow_dispatch" else "full" + self.assertEqual(expected, routing.classify_environment({ + "GITHUB_EVENT_NAME": event, "PRAIRIE_CI_FFMPEG_ONLY": "true" + })[0]) + + def test_invalid_dispatch_input_runs_all_jobs(self): + self.assertEqual("full", routing.classify_environment({ + "GITHUB_EVENT_NAME": "workflow_dispatch", "PRAIRIE_CI_FFMPEG_ONLY": "yes" + })[0]) + + def test_unavailable_event_runs_all_jobs(self): + for event in ("push", "pull_request", "unknown", ""): + with self.subTest(event=event): + self.assertEqual("full", routing.classify_environment({"GITHUB_EVENT_NAME": event})[0]) + + def test_timeout_runs_all_jobs(self): + with tempfile.TemporaryDirectory() as directory: + event = Path(directory) / "event.json" + event.write_text(json.dumps({"before": "a" * 40})) + with patch.object(routing, "git", side_effect=subprocess.TimeoutExpired("git", 30)): + self.assertEqual("full", routing.classify_environment({ + "GITHUB_EVENT_NAME": "push", "GITHUB_EVENT_PATH": str(event), "GITHUB_SHA": "b" * 40 + })[0]) + + +class GitRoutingTest(unittest.TestCase): + """Exercise the CLI against real commits and a shallow local checkout.""" + + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.source = self.root / "source" + self.source.mkdir() + self.run_git("init", "--quiet") + (self.source / "README.md").write_text("Original documentation\n") + (self.source / "build.gradle.kts").write_text("// build inputs\n") + self.run_git("add", ".") + self.base = self.commit("base") + + def run_git(self, *arguments, cwd=None, check=True): + return subprocess.run( + ["git", "-c", "user.name=CI Fixture", "-c", "user.email=ci@example.invalid", *arguments], + cwd=cwd or self.source, check=check, capture_output=True, text=True, + env={**os.environ, "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": os.devnull}, + ) + + def commit(self, message): + self.run_git("commit", "--quiet", "-am", message) + return self.run_git("rev-parse", "HEAD").stdout.strip() + + def classify(self, *, before=None, event_name="push", head_override=None, invalid_event=False): + checkout = self.root / "checkout" + self.run_git("clone", "--quiet", "--depth=1", self.source.as_uri(), str(checkout)) + head = self.run_git("rev-parse", "HEAD", cwd=checkout).stdout.strip() + event = self.root / "event.json" + base = self.base if before is None else before + payload = {"before": base} if event_name == "push" else {"pull_request": {"base": {"sha": base}}} + event.write_text("{" if invalid_event else json.dumps(payload)) + output = self.root / "output.txt" + process = subprocess.run( + ["python3", str(SCRIPT_DIR / "ci-routing.py")], cwd=checkout, + capture_output=True, text=True, check=True, + env={**os.environ, "GITHUB_EVENT_NAME": event_name, "GITHUB_EVENT_PATH": str(event), + "GITHUB_SHA": head_override or head, "GITHUB_OUTPUT": str(output), + "PRAIRIE_CI_FFMPEG_ONLY": "false"}, + ) + result = json.loads(process.stdout) + saved = dict(line.split("=", 1) for line in output.read_text().splitlines()) + self.assertEqual({key: value for key, value in result.items() if key != "reason"}, saved) + return result, checkout + + def test_fetches_exact_before_commit_from_shallow_checkout(self): + (self.source / "README.md").write_text("Updated documentation\n") + self.commit("docs") + result, checkout = self.classify() + self.assertEqual("docs", result["mode"]) + self.assertEqual(0, self.run_git("cat-file", "-e", f"{self.base}^{{commit}}", cwd=checkout).returncode) + self.assertEqual("false", result["unit_tests"]) + + def test_pull_request_uses_exact_base_commit(self): + (self.source / "README.md").write_text("Updated documentation\n") + self.commit("docs") + self.assertEqual("docs", self.classify(event_name="pull_request")[0]["mode"]) + + def test_mixed_code_and_documentation_runs_all_jobs(self): + (self.source / "README.md").write_text("Updated documentation\n") + (self.source / "build.gradle.kts").write_text("// changed build inputs\n") + self.commit("mixed") + result, _ = self.classify() + self.assertEqual("full", result["mode"]) + for key in ("unit_tests", "lint", "release_readiness"): + self.assertEqual("true", result[key]) + + def test_deleted_documentation_runs_all_jobs(self): + self.run_git("rm", "README.md") + self.commit("delete docs") + self.assertEqual("full", self.classify()[0]["mode"]) + + def test_renamed_documentation_runs_all_jobs(self): + self.run_git("mv", "README.md", "FEATURES.md") + self.commit("rename docs") + self.assertEqual("full", self.classify()[0]["mode"]) + + def test_missing_base_runs_all_jobs(self): + self.assertEqual("full", self.classify(before="f" * 40)[0]["mode"]) + + def test_new_branch_runs_all_jobs(self): + self.assertEqual("full", self.classify(before="0" * 40)[0]["mode"]) + + def test_wrong_checkout_runs_all_jobs(self): + self.assertEqual("full", self.classify(head_override="f" * 40)[0]["mode"]) + + def test_invalid_event_json_runs_all_jobs(self): + self.assertEqual("full", self.classify(invalid_event=True)[0]["mode"]) + + def test_added_document_symlink_runs_all_jobs(self): + (self.source / "docs/plans").mkdir(parents=True) + (self.source / "docs/plans/input.md").symlink_to("../../build.gradle.kts") + self.run_git("add", ".") + self.commit("symlink docs") + self.assertEqual("full", self.classify()[0]["mode"]) + + def test_executable_document_runs_all_jobs(self): + self.run_git("update-index", "--chmod=+x", "README.md") + self.run_git("commit", "--quiet", "-m", "executable docs") + self.assertEqual("full", self.classify()[0]["mode"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test-play-bundle-lanes.rb b/scripts/test-play-bundle-lanes.rb new file mode 100644 index 0000000000..ec99511a92 --- /dev/null +++ b/scripts/test-play-bundle-lanes.rb @@ -0,0 +1,752 @@ +#!/usr/bin/env ruby + +require "base64" +require "fileutils" +require "json" +require "open3" +require "rbconfig" +require "tmpdir" + +# Execute the real Fastfile and manifest module with temporary source and bytes. +# The only action implementations below are inert; unexpected actions fail. +class FixtureFailure < StandardError; end +class LaneRejected < StandardError; end +class SyntheticBuildFailure < StandardError; end +class SyntheticKeyFailure < StandardError; end +class SyntheticChangelogFailure < StandardError; end + +module FixtureUI + class << self + attr_accessor :messages + + def user_error!(message) + raise LaneRejected, message + end + + %i[message important success].each do |name| + define_method(name) { |message| messages << [name, message] } + end + end +end + +class LaneFixture + UI = FixtureUI + PHONE = "androidApp/build/outputs/bundle/release/androidApp-release.aab" + TV = "androidTvApp/build/outputs/bundle/release/androidTvApp-release.aab" + KEY_DATA = '{"fixture":"publisher"}' + + class << self + attr_reader :lanes + + def default_platform(name) + raise FixtureFailure, "Unexpected default platform" unless name == :android + end + + def platform(name) + raise FixtureFailure, "Unexpected platform" unless name == :android + yield + end + + def desc(_description); end + + def lane(name, &block) + @lanes ||= {} + raise FixtureFailure, "Duplicate lane" if @lanes.key?(name) + @lanes[name] = block + end + end + + attr_reader :root, :events, :gradle_calls, :uploads, :messages_before_gradle + attr_accessor :fail_build, :fail_key, :fail_changelog, :produce + + def initialize(root) + @root = root + @events, @gradle_calls, @uploads = [], [], [] + @produce = [PHONE, TV] + end + + def invoke(name) + instance_exec(&self.class.lanes.fetch(name)) + end + + def gradle(**options) + events << :gradle + gradle_calls << options + @messages_before_gradle = FixtureUI.messages.map(&:dup) + raise SyntheticBuildFailure, "Deliberate fixture build failure" if fail_build + produce.each do |relative| + path = File.join(root, relative) + FileUtils.mkdir_p(File.dirname(path)) + File.binwrite(path, relative == PHONE ? "phone AAB fixture\0" : "TV AAB fixture\0") + end + end + + def upload_to_play_store(**options) + events << :upload + uploads << options + end + + def sh(command, log:) + unless command == "git log -1 --pretty=format:%s" && log == false + raise FixtureFailure, "Unexpected shell action" + end + events << :changelog_fallback + "Fixture commit subject\n" + end + + def method_missing(name, *_arguments, **_keywords, &_block) + raise FixtureFailure, "Unexpected Fastlane action: #{name}" + end + + def respond_to_missing?(_name, _private = false) + false + end +end + +module FixtureHelpers + def play_json_key_data + events << :publisher_key + raise SyntheticKeyFailure, "Deliberate fixture key failure" if fail_key + LaneFixture::KEY_DATA + end + + def require_signing_env! + events << :signing_env + super + end + + def resolve_release_keystore + events << :keystore + # Never permit the real helper's local credential fallback in this harness. + path = ENV["PRAIRIE_RELEASE_KEYSTORE"].to_s + unless ENV["PRAIRIE_RELEASE_KEYSTORE_B64"].to_s.empty? && path == File.join(root, "fixture.jks") + raise FixtureFailure, "Unexpected keystore lookup" + end + super + end + + def materialize_google_services_json + events << :fcm + super + end + + def write_play_changelog + events << :changelog + raise SyntheticChangelogFailure, "Deliberate fixture changelog failure" if fail_changelog + super + end +end + +class BundleFixtureSuite + def initialize(root, runner_class, source_sha) + @root, @runner_class, @source_sha = root, runner_class, source_sha + @cases, @assertions, @failures = 0, 0, [] + @process_environment = ENV.to_h.select { |key, _| %w[PATH HOME TMPDIR LANG LC_ALL].include?(key) } + @process_environment.merge!("GIT_CONFIG_NOSYSTEM" => "1", "GIT_CONFIG_GLOBAL" => File::NULL) + end + + def check(condition, message) + @assertions += 1 + raise FixtureFailure, message unless condition + end + + def rejected(*types) + types = [ArgumentError, LaneRejected] if types.empty? + begin + yield + rescue *types + @assertions += 1 + return + end + raise FixtureFailure, "Expected rejection" + end + + def fixture(name) + @cases += 1 + previous = ENV.to_h + ENV.replace(@process_environment) + git("reset", "--hard", @source_sha) + %w[androidApp androidTvApp build manifest-target bundle-target fixture.jks].each do |relative| + FileUtils.rm_rf(File.join(@root, relative)) + end + FileUtils.rm_rf(File.join(@root, "fastlane/metadata")) + %w[androidApp androidTvApp].each { |relative| FileUtils.mkdir_p(File.join(@root, relative)) } + File.binwrite(File.join(@root, "fixture.jks"), "disposable keystore bytes") + ENV.update("PRAIRIE_VERSION_NAME" => "1.2.3", "PRAIRIE_BUILD_NUMBER" => "2", "PLAY_TRACK" => "beta", + "PRAIRIE_RELEASE_KEYSTORE" => File.join(@root, "fixture.jks"), + "PRAIRIE_RELEASE_KEYSTORE_PASSWORD" => "fixture-password", "PRAIRIE_RELEASE_KEY_ALIAS" => "fixture-key", + "PLAY_RELEASE_NOTES" => "Fixture release notes") + FixtureUI.messages = [] + runner = @runner_class.new(@root) + yield runner + rescue StandardError => error + @failures << "#{name}: #{error.class}: #{error.message}" + ensure + ENV.replace(previous) if previous + end + + def git(*arguments) + out, err, status = Open3.capture3(@process_environment, "git", *arguments, chdir: @root, unsetenv_others: true) + raise FixtureFailure, "Fixture git failed: #{err}" unless status.success? + out.strip + end + + def manifest_path + File.join(@root, PlayBundleManifest::FILE_NAME) + end + + def expected_gradle(version: "1.2.3", build: 2, track: "beta") + major, minor, patch = version.split(".").map(&:to_i) + { + tasks: [":androidApp:bundleRelease", ":androidTvApp:bundleRelease"], + properties: {"siloVersionName" => version, "siloVersionCode" => 100_000_000 + + (major * 10_000 + minor * 100 + patch) * 1_000 + build, + "siloBuildNumber" => build, "siloReleaseChannel" => track}, + system_properties: {"org.gradle.jvmargs" => "-Xmx4g -Dfile.encoding=UTF-8"}, + flags: "--max-workers=2" + } + end + + def expected_upload(version: "1.2.3", track: "beta") + {package_name: "org.prairieserver.prairie", json_key_data: LaneFixture::KEY_DATA, track: track, + release_status: "completed", version_name: version, + aab_paths: [LaneFixture::PHONE, LaneFixture::TV].map { |path| File.join(@root, path) }, + skip_upload_apk: true, skip_upload_metadata: true, skip_upload_images: true, + skip_upload_screenshots: true, skip_upload_changelogs: false, + changes_not_sent_for_review: false, timeout: 900} + end + + def prepare(runner) + runner.invoke(:prepare_bundles) + trust_manifest unless ENV["GITHUB_RUN_ID"].to_s.empty? + runner.events.clear + runner.gradle_calls.clear + end + + def trust_manifest + ENV["PRAIRIE_PLAY_BUNDLE_MANIFEST_SHA256"] = Digest::SHA256.file(manifest_path).hexdigest + end + + def upload_rejected(runner) + rejected { runner.invoke(:upload_bundles) } + check(runner.events.empty? && runner.uploads.empty?, "Invalid handoff reached credentials or actions") + end + + def change_manifest + value = JSON.parse(File.read(manifest_path)) + yield value + File.write(manifest_path, JSON.generate(value)) + end + + def ci_context(attempt = "1") + {"GITHUB_REPOSITORY" => "Prairie-Server/prairie-android", "GITHUB_REPOSITORY_ID" => "7001", + "GITHUB_RUN_ID" => "9001", "GITHUB_RUN_ATTEMPT" => attempt, "GITHUB_SHA" => @source_sha} + end + + def artifact + {"id" => 8001, "name" => "android-play-bundles-9001-1", "expired" => false, + "size_in_bytes" => 1000, "workflow_run" => {"id" => 9001, "repository_id" => 7001, + "head_repository_id" => 7001, "head_sha" => @source_sha}} + end + + def verify_artifact(value = artifact, **overrides) + PlayBundleManifest.verify_artifact!(artifact: value, artifact_id: "8001", + artifact_name: "android-play-bundles-9001-1", producer_attempt: "1", context: ci_context, **overrides) + end + + def run + fixture("preparation action and manifest contract") do |runner| + runner.invoke(:prepare_bundles) + check(runner.gradle_calls == [expected_gradle], "Preparation changed the Gradle contract") + check(runner.events == %i[signing_env keystore fcm gradle], "Preparation reached publishing work") + check(runner.uploads.empty? && !File.exist?(File.join(@root, "fastlane/metadata")), "Preparation wrote publishing data") + value = PlayBundleManifest.verify!(root: @root, version: "1.2.3", base_code: 110_203_002, build: 2, track: "beta") + check(value["source_sha"] == @source_sha && value.values_at("repository", "run_id", "producer_attempt") == [nil, nil, nil], + "Local identity was not bound to actual source") + check(value["bundles"].map { |entry| entry["path"] } == [LaneFixture::PHONE, LaneFixture::TV], "Wrong bundle paths") + end + + fixture("upload uses both verified bundles without build secrets") do |runner| + prepare(runner) + ENV.keys.select { |key| key.start_with?("PRAIRIE_RELEASE_", "PRAIRIE_GOOGLE_SERVICES_") }.each { |key| ENV.delete(key) } + runner.invoke(:upload_bundles) + check(runner.events == %i[publisher_key changelog upload], "Upload performed preparation work") + check(runner.gradle_calls.empty? && runner.uploads == [expected_upload], "Upload changed its two-bundle options") + check(File.read(File.join(@root, "fastlane/metadata/android/en-US/changelogs/default.txt")) == "Fixture release notes", + "Upload changed changelog bytes") + end + fixture("normalized nondefault track survives preparation and upload") do |runner| + ENV["PLAY_TRACK"] = " ALPHA " + runner.invoke(:prepare_bundles) + check(runner.gradle_calls == [expected_gradle(track: "alpha")], "Preparation changed the selected channel") + runner.events.clear + runner.invoke(:upload_bundles) + check(runner.uploads == [expected_upload(track: "alpha")], "Upload changed the prepared track") + end + + fixture("beta preserves early publisher lookup and one build/upload") do |runner| + runner.invoke(:beta) + check(runner.events == %i[publisher_key signing_env keystore fcm changelog gradle upload], "Beta changed baseline action order") + check(runner.gradle_calls == [expected_gradle] && runner.uploads == [expected_upload], "Beta duplicated or changed actions") + check(runner.messages_before_gradle.include?([:message, + "Releasing Prairie 1.2.3 build 2 to the beta track (base code 110203002 → phone 220406004, TV 220406005)"]), + "Beta changed or delayed its baseline release message") + check(FixtureUI.messages.last == [:success, + "Uploaded Prairie 1.2.3 build 2 (phone 220406004 + TV 220406005) to the beta track."], + "Beta changed its baseline success message") + end + fixture("beta uses its fresh manifest digest") do |runner| + ENV["PRAIRIE_PLAY_BUNDLE_MANIFEST_SHA256"] = "0" * 64 + runner.invoke(:beta) + check(runner.events == %i[publisher_key signing_env keystore fcm changelog gradle upload], "Beta changed baseline action order") + check(runner.gradle_calls == [expected_gradle] && runner.uploads == [expected_upload], "Beta used a stale caller digest") + end + fixture("beta rejects key before preparation") do |runner| + runner.fail_key = true + rejected(SyntheticKeyFailure) { runner.invoke(:beta) } + check(runner.events == [:publisher_key] && runner.uploads.empty?, "Failed key reached build or upload") + end + fixture("beta build failure prevents publication") do |runner| + runner.fail_build = true + rejected(SyntheticBuildFailure) { runner.invoke(:beta) } + check(runner.events == %i[publisher_key signing_env keystore fcm changelog gradle] && runner.uploads.empty? && + !File.exist?(manifest_path), "Failed build produced a handoff or upload") + check(File.read(File.join(@root, "fastlane/metadata/android/en-US/changelogs/default.txt")) == "Fixture release notes", + "Failed beta build lost its baseline prepared changelog") + check(runner.messages_before_gradle.include?([:message, + "Releasing Prairie 1.2.3 build 2 to the beta track (base code 110203002 → phone 220406004, TV 220406005)"]), + "Failed beta build lost its baseline release message") + end + fixture("beta changelog failure precedes build") do |runner| + runner.fail_changelog = true + rejected(SyntheticChangelogFailure) { runner.invoke(:beta) } + check(runner.events == %i[publisher_key signing_env keystore fcm changelog] && + runner.gradle_calls.empty? && runner.uploads.empty? && !File.exist?(manifest_path), + "Failed beta changelog reached build or upload") + end + [LaneFixture::PHONE, LaneFixture::TV].each do |missing| + fixture("successful action with missing #{missing}") do |runner| + runner.produce -= [missing] + rejected { runner.invoke(:prepare_bundles) } + check(runner.uploads.empty? && !File.exist?(manifest_path), "Missing bundle created an accepted handoff") + end + end + + [[nil, "beta"], ["", "beta"], ["internal", "internal"], ["alpha", "alpha"], + ["beta", "beta"], ["production", "production"], [" BETA ", "beta"]].each do |input, expected| + fixture("track #{input.inspect}") do |runner| + input.nil? ? ENV.delete("PLAY_TRACK") : ENV["PLAY_TRACK"] = input + runner.invoke(:prepare_bundles) + check(runner.gradle_calls == [expected_gradle(track: expected)], "Track normalization changed") + end + end + ["sideload", "dev", "unknown", "beta; command"].each do |track| + fixture("invalid track #{track}") do |runner| + ENV["PLAY_TRACK"] = track + rejected { runner.invoke(:prepare_bundles) } + check(runner.events.empty?, "Invalid track reached actions") + end + end + [["0.0.0", "1", 1], ["94.99.99", "999", 999], ["1.2.3", nil, 1], ["1.2.3", "", 1]].each do |version, input, build| + fixture("valid version/build #{version}/#{input.inspect}") do |runner| + ENV["PRAIRIE_VERSION_NAME"] = version + input.nil? ? ENV.delete("PRAIRIE_BUILD_NUMBER") : ENV["PRAIRIE_BUILD_NUMBER"] = input + runner.invoke(:prepare_bundles) + check(runner.gradle_calls == [expected_gradle(version: version, build: build)], "Version/build derivation changed") + end + end + ["", "v1.2.3", "1.2.3-rc.1", "95.0.0", "1.100.0", "1.0.100", "1.2.3; command"].each do |version| + fixture("invalid version #{version.inspect}") do |runner| + ENV["PRAIRIE_VERSION_NAME"] = version + rejected { runner.invoke(:prepare_bundles) } + check(runner.events.empty?, "Invalid version reached actions") + end + end + %w[0 1000 -1 1.2 invalid].each do |build| + fixture("invalid build #{build}") do |runner| + ENV["PRAIRIE_BUILD_NUMBER"] = build + rejected { runner.invoke(:prepare_bundles) } + check(runner.events.empty?, "Invalid build reached actions") + end + end + %w[PRAIRIE_RELEASE_KEYSTORE_PASSWORD PRAIRIE_RELEASE_KEY_ALIAS].product([nil, " "]).each do |name, value| + fixture("missing signing #{name}/#{value.inspect}") do |runner| + value.nil? ? ENV.delete(name) : ENV[name] = value + rejected { runner.invoke(:prepare_bundles) } + check(runner.events == [:signing_env] && runner.gradle_calls.empty?, "Missing signing input reached Gradle") + end + end + fixture("key password remains optional") do |runner| + ENV.delete("PRAIRIE_RELEASE_KEY_PASSWORD") + runner.invoke(:prepare_bundles) + check(runner.gradle_calls == [expected_gradle], "Optional key password blocked preparation") + end + fixture("FCM absent warns and remains absent") do |runner| + runner.invoke(:prepare_bundles) + check(!File.exist?(File.join(@root, "androidApp/google-services.json")) && + FixtureUI.messages.any? { |type, text| type == :important && text.include?("without FCM push support") }, + "Absent optional FCM behavior changed") + end + fixture("FCM synthetic input materializes in phone only") do |runner| + bytes = "{\"fixture\":\"FCM\"}\n" + ENV["PRAIRIE_GOOGLE_SERVICES_JSON_B64"] = Base64.strict_encode64(bytes) + runner.invoke(:prepare_bundles) + check(File.binread(File.join(@root, "androidApp/google-services.json")) == bytes && + !File.exist?(File.join(@root, "androidTvApp/google-services.json")), "FCM materialization changed") + end + fixture("existing FCM configuration is preserved") do |runner| + FileUtils.mkdir_p(File.join(@root, "androidApp")) + File.binwrite(File.join(@root, "androidApp/google-services.json"), "existing fixture FCM") + ENV["PRAIRIE_GOOGLE_SERVICES_JSON_B64"] = Base64.strict_encode64("replacement fixture FCM") + runner.invoke(:prepare_bundles) + check(File.binread(File.join(@root, "androidApp/google-services.json")) == "existing fixture FCM", "Existing FCM was replaced") + end + fixture("changelog truncation") do |runner| + prepare(runner) + ENV["PLAY_RELEASE_NOTES"] = "n" * 501 + runner.invoke(:upload_bundles) + check(File.read(File.join(@root, "fastlane/metadata/android/en-US/changelogs/default.txt")) == "n" * 500, + "Changelog length changed") + end + fixture("changelog fallback is inert") do |runner| + prepare(runner) + ENV.delete("PLAY_RELEASE_NOTES") + runner.invoke(:upload_bundles) + check(runner.events == %i[publisher_key changelog changelog_fallback upload] && + File.read(File.join(@root, "fastlane/metadata/android/en-US/changelogs/default.txt")) == "Fixture commit subject", + "Changelog fallback changed") + end + + [LaneFixture::PHONE, LaneFixture::TV].product(%w[missing empty corrupt]).each do |relative, defect| + fixture("handoff #{defect} #{relative}") do |runner| + prepare(runner) + path = File.join(@root, relative) + defect == "missing" ? File.unlink(path) : File.binwrite(path, defect == "empty" ? "" : "corrupt fixture") + upload_rejected(runner) + end + end + fixture("handoff swapped phone and TV bytes") do |runner| + prepare(runner) + phone, tv = [LaneFixture::PHONE, LaneFixture::TV].map { |relative| File.join(@root, relative) } + first, second = File.binread(phone), File.binread(tv) + File.binwrite(phone, second) + File.binwrite(tv, first) + upload_rejected(runner) + end + {"schema" => 2, "source_sha" => "0" * 40, "version" => "1.2.4", "base_code" => 110_203_003, + "build" => 3, "track" => "alpha", "repository" => "other/repo", "run_id" => 1, + "producer_attempt" => 1, "unexpected" => true}.each do |key, value| + fixture("manifest mismatch #{key}") do |runner| + prepare(runner) + change_manifest { |manifest| manifest[key] = value } + upload_rejected(runner) + end + end + %w[missing malformed null array empty oversized symlink unexpected-path extra-bundle].each do |defect| + fixture("manifest #{defect}") do |runner| + prepare(runner) + case defect + when "missing" then File.unlink(manifest_path) + when "malformed" then File.write(manifest_path, "{") + when "null" then File.write(manifest_path, "null") + when "array" then File.write(manifest_path, "[]") + when "empty" then File.write(manifest_path, "") + when "oversized" then File.write(manifest_path, " " * 65_537) + when "symlink" + FileUtils.mv(manifest_path, File.join(@root, "manifest-target")) + File.symlink(File.join(@root, "manifest-target"), manifest_path) + when "unexpected-path" then change_manifest { |value| value["bundles"][0]["path"] = "other.aab" } + when "extra-bundle" then change_manifest { |value| value["bundles"] << value["bundles"].first.dup } + end + upload_rejected(runner) + end + end + fixture("bundle file symlink") do |runner| + prepare(runner) + path = File.join(@root, LaneFixture::PHONE) + FileUtils.mv(path, File.join(@root, "bundle-target")) + File.symlink(File.join(@root, "bundle-target"), path) + upload_rejected(runner) + end + fixture("bundle ancestor symlink") do |runner| + prepare(runner) + path = File.join(@root, "androidApp/build/outputs/bundle") + FileUtils.mv(path, File.join(@root, "bundle-target")) + File.symlink(File.join(@root, "bundle-target"), path) + upload_rejected(runner) + end + fixture("manifest parent symlink") do |runner| + prepare(runner) + FileUtils.mv(File.join(@root, "build"), File.join(@root, "manifest-target")) + File.symlink(File.join(@root, "manifest-target"), File.join(@root, "build")) + upload_rejected(runner) + end + fixture("checkout changed after preparation") do |runner| + prepare(runner) + File.write(File.join(@root, "fixture-source.txt"), "changed source") + git("add", "fixture-source.txt") + git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "-c", "commit.gpgsign=false", + "commit", "--quiet", "-m", "Changed fixture source") + upload_rejected(runner) + end + + fixture("same-attempt CI handoff") do |runner| + ENV.update(ci_context) + prepare(runner) + ENV["PRAIRIE_PLAY_BUNDLE_ATTEMPT"] = "1" + runner.invoke(:upload_bundles) + check(runner.uploads == [expected_upload], "Valid CI handoff rejected") + end + [false, true].each do |staged| + fixture("CI preparation rejects #{staged ? 'staged' : 'unstaged'} tracked changes") do |runner| + ENV.update(ci_context) + File.write(File.join(@root, "fixture-source.txt"), "modified tracked build input") + git("add", "fixture-source.txt") if staged + rejected { runner.invoke(:prepare_bundles) } + check(runner.events.empty? && runner.gradle_calls.empty? && !File.exist?(manifest_path) && runner.uploads.empty?, + "Known dirty CI source reached credentials, build or handoff") + end + fixture("CI upload rejects #{staged ? 'staged' : 'unstaged'} tracked changes") do |runner| + ENV.update(ci_context) + prepare(runner) + File.write(File.join(@root, "fixture-source.txt"), "modified tracked build input") + git("add", "fixture-source.txt") if staged + ENV["PRAIRIE_PLAY_BUNDLE_ATTEMPT"] = "1" + upload_rejected(runner) + end + fixture("local beta accepts #{staged ? 'staged' : 'unstaged'} tracked changes") do |runner| + File.write(File.join(@root, "fixture-source.txt"), "modified local source") + git("add", "fixture-source.txt") if staged + runner.invoke(:beta) + check(runner.gradle_calls == [expected_gradle] && runner.uploads == [expected_upload], + "Local beta lost its dirty-checkout compatibility") + end + end + fixture("CI permits ignored build FCM and changelog files") do |runner| + ENV.update(ci_context) + prepare(runner) + paths = [LaneFixture::PHONE, LaneFixture::TV, PlayBundleManifest::FILE_NAME, + "androidApp/google-services.json", "fastlane/metadata/android/en-US/changelogs/default.txt"] + paths.last(2).each do |relative| + path = File.join(@root, relative) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, "owned ignored fixture input") + end + check(git("check-ignore", *paths).lines.map(&:chomp).sort == paths.sort, + "Positive CI fixture did not exercise ignored release outputs") + check(git("status", "--porcelain=v1", "--untracked-files=no").empty?, "Positive CI fixture changed tracked source") + ENV["PRAIRIE_PLAY_BUNDLE_ATTEMPT"] = "1" + runner.invoke(:upload_bundles) + check(runner.uploads == [expected_upload], "Ignored release outputs blocked a valid CI handoff") + end + fixture("earlier producer accepted by later consumer") do |runner| + ENV.update(ci_context) + prepare(runner) + ENV["GITHUB_RUN_ATTEMPT"] = "3" + ENV["PRAIRIE_PLAY_BUNDLE_ATTEMPT"] = "1" + runner.invoke(:upload_bundles) + check(runner.uploads == [expected_upload] && verify_artifact(context: ci_context("3")), "Prior producer handoff rejected") + end + [nil, "", "invalid", "A" * 64, "0" * 64].each do |digest| + fixture("CI manifest digest rejected #{digest.inspect}") do |runner| + ENV.update(ci_context) + prepare(runner) + digest.nil? ? ENV.delete("PRAIRIE_PLAY_BUNDLE_MANIFEST_SHA256") : ENV["PRAIRIE_PLAY_BUNDLE_MANIFEST_SHA256"] = digest + ENV["PRAIRIE_PLAY_BUNDLE_ATTEMPT"] = "1" + upload_rejected(runner) + end + end + fixture("CI rejects changed manifest whitespace") do |runner| + ENV.update(ci_context) + prepare(runner) + original = File.read(manifest_path) + File.write(manifest_path, original + " ") + check(JSON.parse(File.read(manifest_path)) == JSON.parse(original), "Whitespace fixture changed parsed metadata") + ENV["PRAIRIE_PLAY_BUNDLE_ATTEMPT"] = "1" + upload_rejected(runner) + end + [LaneFixture::PHONE, LaneFixture::TV].each do |relative| + fixture("CI rejects rewritten bundle and matching manifest #{relative}") do |runner| + ENV.update(ci_context) + prepare(runner) + producer_digest = ENV.fetch("PRAIRIE_PLAY_BUNDLE_MANIFEST_SHA256") + File.binwrite(File.join(@root, relative), "rewritten fixture bundle") + change_manifest do |value| + record = value["bundles"].find { |entry| entry["path"] == relative } + record["bytes"] = File.size(File.join(@root, relative)) + record["sha256"] = Digest::SHA256.file(File.join(@root, relative)).hexdigest + end + check(Digest::SHA256.file(manifest_path).hexdigest != producer_digest, "Tamper fixture kept producer digest") + ENV["PRAIRIE_PLAY_BUNDLE_ATTEMPT"] = "1" + upload_rejected(runner) + end + end + fixture("local absent digest keeps compatible manifest parsing") do |runner| + prepare(runner) + File.open(manifest_path, "a") { |file| file.write(" ") } + check(ENV["PRAIRIE_PLAY_BUNDLE_MANIFEST_SHA256"].nil?, "Local fixture unexpectedly had a producer digest") + runner.invoke(:upload_bundles) + check(runner.events == %i[publisher_key changelog upload] && runner.uploads == [expected_upload], + "Local upload requires a CI digest") + end + fixture("earlier producer requires an explicit retained attempt") do |runner| + ENV.update(ci_context) + prepare(runner) + ENV["GITHUB_RUN_ATTEMPT"] = "3" + ENV.delete("PRAIRIE_PLAY_BUNDLE_ATTEMPT") + upload_rejected(runner) + end + ["", "0", "2", "01", "invalid"].each do |attempt| + fixture("CI invalid producer attempt #{attempt.inspect}") do |runner| + ENV.update(ci_context) + prepare(runner) + ENV["PRAIRIE_PLAY_BUNDLE_ATTEMPT"] = attempt + upload_rejected(runner) + end + end + {"GITHUB_REPOSITORY" => "other/repo", "GITHUB_RUN_ID" => "9002", "GITHUB_SHA" => "0" * 40}.each do |key, value| + fixture("CI changed #{key}") do |runner| + ENV.update(ci_context) + prepare(runner) + ENV[key] = value + ENV["PRAIRIE_PLAY_BUNDLE_ATTEMPT"] = "1" + upload_rejected(runner) + end + end + %w[GITHUB_REPOSITORY GITHUB_RUN_ID GITHUB_RUN_ATTEMPT GITHUB_SHA].each do |missing| + fixture("partial CI context missing #{missing}") do |runner| + ENV.update(ci_context) + ENV.delete(missing) + rejected { runner.invoke(:prepare_bundles) } + check(runner.events.empty? && runner.gradle_calls.empty? && !File.exist?(manifest_path) && runner.uploads.empty?, + "Partial CI context reached credentials, build or handoff") + end + end + + fixture("artifact identity positive") { check(verify_artifact, "Valid API artifact rejected") } + [nil, "", "0", "01", "invalid"].each do |id| + fixture("artifact bad selector #{id.inspect}") { rejected { verify_artifact(artifact_id: id) } } + end + {"id" => 8002, "name" => "wrong", "expired" => true, "size_in_bytes" => 0, + "workflow_run" => nil}.each do |key, value| + fixture("artifact mismatch #{key}") do + value_with_defect = artifact + value_with_defect[key] = value + rejected { verify_artifact(value_with_defect) } + end + end + {"id" => 9002, "repository_id" => 7002, "head_repository_id" => 7002, "head_sha" => "0" * 40}.each do |key, value| + fixture("artifact workflow mismatch #{key}") do + value_with_defect = artifact + value_with_defect["workflow_run"][key] = value + rejected { verify_artifact(value_with_defect) } + end + end + %w[schema run_id producer_attempt base_code build].each do |key| + fixture("manifest noninteger #{key}") do |runner| + ENV.update(ci_context) + prepare(runner) + change_manifest { |value| value[key] = value.fetch(key).to_f } + trust_manifest + ENV["PRAIRIE_PLAY_BUNDLE_ATTEMPT"] = "1" + upload_rejected(runner) + end + end + fixture("manifest noninteger bundle bytes") do |runner| + prepare(runner) + change_manifest { |value| value["bundles"][0]["bytes"] = value["bundles"][0]["bytes"].to_f } + upload_rejected(runner) + end + %w[id repository_id head_repository_id].each do |key| + [nil, true, :string, :float].each do |invalid| + fixture("artifact workflow noninteger #{key}/#{invalid.inspect}") do + value = artifact + original = value["workflow_run"].fetch(key) + value["workflow_run"][key] = case invalid + when :float then original.to_f + when :string then original.to_s + else invalid + end + rejected { verify_artifact(value) } + end + end + end + [nil, true, "8001", 8001.0].each do |invalid| + fixture("artifact ID noninteger #{invalid.inspect}") do + value = artifact + value["id"] = invalid + rejected { verify_artifact(value) } + end + end + fixture("artifact wrong output name") { rejected { verify_artifact(artifact_name: "android-play-bundles-9001-2") } } + fixture("artifact future producer") { rejected { verify_artifact(producer_attempt: "2") } } + fixture("earlier artifact requires exact producer name and ID") do + rejected { verify_artifact(producer_attempt: "2", context: ci_context("3")) } + rejected { verify_artifact(artifact_name: "android-play-bundles-9001-2", context: ci_context("3")) } + rejected { verify_artifact(artifact_id: "8002", context: ci_context("3")) } + end + [nil, "false"].each do |invalid| + fixture("artifact expired flag type #{invalid.inspect}") do + value = artifact + value["expired"] = invalid + rejected { verify_artifact(value) } + end + end + ["1000", 1000.0, -1, 2_147_483_649].each do |invalid| + fixture("artifact size rejected #{invalid.inspect}") do + value = artifact + value["size_in_bytes"] = invalid + rejected { verify_artifact(value) } + end + end + [nil, [], "metadata"].each do |value| + fixture("artifact malformed #{value.inspect}") { rejected { verify_artifact(value) } } + end + fixture("artifact CLI bounded metadata") do + env = ci_context.merge("PLAY_BUNDLE_ARTIFACT_ID" => "8001", "PLAY_BUNDLE_ARTIFACT_NAME" => "android-play-bundles-9001-1", + "PRAIRIE_PLAY_BUNDLE_ATTEMPT" => "1") + command = [RbConfig.ruby, File.join(@root, "fastlane/play_bundle_manifest.rb"), "verify-artifact"] + out, err, status = Open3.capture3(env, *command, stdin_data: JSON.generate(artifact)) + check(status.success? && out == "Verified Play bundle artifact provenance\n" && err.empty?, "Valid artifact CLI rejected") + ["{", "x" * 65_537].each do |input| + out, err, status = Open3.capture3(env, *command, stdin_data: input) + check(!status.success? && out.empty? && err == "Play bundle artifact verification failed\n", "Malformed CLI metadata accepted or exposed") + end + out, err, status = Open3.capture3(env.merge("PLAY_BUNDLE_ARTIFACT_ID" => nil), *command, + stdin_data: JSON.generate(artifact)) + check(!status.success? && out.empty? && err == "Play bundle artifact verification failed\n", "Missing CLI selector accepted") + end + + unless @failures.empty? + warn @failures.first(20).map { |message| "FAIL: #{message}" }.join("\n") + warn "#{@failures.length} Play bundle fixture(s) failed" + exit 1 + end + puts "All Play bundle lane/manifest fixtures passed (#{@cases} cases; #{@assertions} assertions; no native build or publishing)" + end +end + +repo_root = File.expand_path("..", __dir__) +Dir.mktmpdir("play-bundle-lanes-") do |root| + FileUtils.mkdir_p(File.join(root, "fastlane")) + %w[Fastfile play_bundle_manifest.rb].each do |name| + FileUtils.cp(File.join(repo_root, "fastlane", name), File.join(root, "fastlane", name)) + end + File.write(File.join(root, "fixture-source.txt"), "original fixture source") + File.write(File.join(root, ".gitignore"), "**/build/\nandroidApp/google-services.json\nfastlane/metadata/\n") + git_env = ENV.to_h.select { |key, _| %w[PATH HOME TMPDIR LANG LC_ALL].include?(key) } + git_env.merge!("GIT_CONFIG_NOSYSTEM" => "1", "GIT_CONFIG_GLOBAL" => File::NULL) + [["init", "--quiet"], ["config", "core.hooksPath", File.join(root, "empty-hooks")], ["add", "."], + ["-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "-c", "commit.gpgsign=false", + "commit", "--quiet", "-m", "Fixture source"]].each do |arguments| + _out, err, status = Open3.capture3(git_env, "git", *arguments, chdir: root, unsetenv_others: true) + raise FixtureFailure, "Fixture initialization failed: #{err}" unless status.success? + end + source_sha, status = Open3.capture2(git_env, "git", "rev-parse", "HEAD", chdir: root, unsetenv_others: true) + raise FixtureFailure, "Fixture source unavailable" unless status.success? + runner_class = Class.new(LaneFixture) + fastfile = File.join(root, "fastlane/Fastfile") + runner_class.class_eval(File.read(fastfile), fastfile, 1) + runner_class.prepend(FixtureHelpers) + BundleFixtureSuite.new(root, runner_class, source_sha.strip).run +end diff --git a/scripts/test-release-workflow.sh b/scripts/test-release-workflow.sh index 9da85f981e..001d4fad0a 100755 --- a/scripts/test-release-workflow.sh +++ b/scripts/test-release-workflow.sh @@ -4,57 +4,266 @@ set -euo pipefail script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" repo_root="$(cd "${script_dir}/.." && pwd)" -workflow_file="${repo_root}/.github/workflows/release.yml" -failures=0 -publish_job="$({ - awk ' - /^ publish-release:[[:space:]]*$/ { - in_publish_job = 1 - } - in_publish_job && - /^ [[:alnum:]_-]+:[[:space:]]*$/ && - $0 !~ /^ publish-release:/ { - exit +ruby - "${repo_root}" <<'RUBY' +require "fileutils" +require "json" +require "open3" +require "psych" +require "strscan" +require "tmpdir" + +repo_root = ARGV.fetch(0) +load_workflow = lambda do |name| + Psych.safe_load( + File.read(File.join(repo_root, ".github/workflows", name)), aliases: true + ).fetch("jobs") +end +jobs = load_workflow.call("release.yml") +failures = [] +check = lambda { |condition, message| failures << message unless condition } + +# Evaluate the actual workflow condition using its supported expression subset. +# GitHub compares strings without case sensitivity and supplies success() when +# an if condition has no status function. Only literals/operators reach eval; +# reject any new expression syntax until this evaluator explicitly supports it. +job_runs = lambda do |job, needs, cancelled| + implicit_success = Array(job.fetch("needs", [])).all? do |name| + needs.dig(name, "result") == "success" + end && !cancelled + condition = job["if"] + next implicit_success unless condition + + expression = condition.strip.sub(/\A\$\{\{\s*/, "").sub(/\s*\}\}\z/, "") + scanner = StringScanner.new(expression) + translated = [] + status_function = false + until scanner.eos? + next if scanner.skip(/\s+/) + token = scanner.scan( + /needs\.[\w-]+\.(?:result|outputs\.[\w-]+)|cancelled\(\)|'[^']*'|==|&&|\|\||[!()]/ + ) + raise "Unsupported job expression: #{scanner.rest}" unless token + + translated << case token + when "cancelled()" + status_function = true + cancelled.to_s + when /\Aneeds\./ + needs.dig(*token.split(".").drop(1)).to_s.downcase.dump + when /\A'/ + token[1...-1].downcase.dump + else + token + end + end + (status_function || implicit_success) && eval(translated.join(" ")) +end + +check.call(jobs.fetch("unit-tests").fetch("needs", []) == ["setup"], + "Unit tests must wait for the shared supply-chain gate") +setup_steps = jobs.fetch("setup").fetch("steps") +checkout, supply_chain = setup_steps.first(2) +check.call(checkout["uses"] == "actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803" && + checkout.dig("with", "persist-credentials") == false, + "Setup must check out sources without persisted credentials before validation") +check.call(supply_chain["name"] == "Check build supply chain" && + supply_chain["run"].lines.map(&:strip) == %w[ + ./scripts/test-release-workflow.sh + ./scripts/test-check-build-supply-chain.sh + ./scripts/check-build-supply-chain.sh + ] && !supply_chain.key?("if") && !supply_chain.key?("continue-on-error") && + !jobs.fetch("setup").key?("continue-on-error"), + "Setup must run all supply-chain checks before other setup or signing actions") +check.call(jobs.fetch("unit-tests").fetch("steps").none? { |step| step["name"] == "Check build supply chain" }, + "The shared supply-chain gate must run once per release") + +check.call(jobs.fetch("apks").fetch("needs").sort == %w[setup unit-tests].sort, + "APK preparation must wait for setup/tests and overlap Play") +check.call(jobs.fetch("play-bundles").fetch("needs") == ["setup"], + "Bundle preparation must overlap tests after setup") +check.call(jobs.fetch("play").fetch("needs").sort == %w[setup unit-tests play-bundles].sort, + "Play must remain gated on setup/tests/bundles") +check.call(jobs.fetch("publish-release").fetch("needs").sort == %w[setup unit-tests play-bundles play apks].sort, + "Publication must directly wait for setup/tests/Play/APKs") + +statuses = %w[success failure cancelled skipped] +flags = ["true", "false", "", "unexpected"] +condition_cases = 0 +statuses.product([false, true]).each do |setup, cancelled| + needs = {"setup" => {"result" => setup}} + check.call(job_runs.call(jobs.fetch("unit-tests"), needs, cancelled) == (!cancelled && setup == "success"), + "Unit-test setup gate mismatch: #{[setup, cancelled].inspect}") + condition_cases += 1 +end +statuses.repeated_permutation(5) do |setup, tests, bundles, play, apks| + flags.product([false, true]).each do |flag, cancelled| + needs = { + "setup" => {"result" => setup, "outputs" => {"play_publish" => flag}}, + "unit-tests" => {"result" => tests}, + "play-bundles" => {"result" => bundles}, + "play" => {"result" => play}, + "apks" => {"result" => apks} } - in_publish_job { - print + expected = !cancelled && [setup, tests, apks].all? { |result| result == "success" } && + ((flag == "true" && bundles == "success" && play == "success") || + (flag == "false" && bundles == "skipped" && play == "skipped")) + actual = job_runs.call(jobs.fetch("publish-release"), needs, cancelled) + check.call(actual == expected, + "Publication gate mismatch: #{[setup, tests, bundles, play, apks, flag, cancelled].inspect}") + condition_cases += 1 + end +end + +statuses.repeated_permutation(3) do |setup, tests, bundles| + flags.product([false, true]).each do |flag, cancelled| + needs = { + "setup" => {"result" => setup, "outputs" => {"play_publish" => flag}}, + "unit-tests" => {"result" => tests}, + "play-bundles" => {"result" => bundles} } - ' "${workflow_file}" -} | sed '/^[[:space:]]*#/d')" -publish_job_header="$( - awk '/^ steps:[[:space:]]*$/ { exit } { print }' <<< "${publish_job}" -)" - -expect_publish_job_header_to_contain() { - local description="$1" - local expected="$2" - - if ! grep -Fq "${expected}" <<< "${publish_job_header}"; then - printf 'FAIL: publish-release must %s\n' "${description}" >&2 - failures=$((failures + 1)) - fi -} - -expect_publish_job_header_to_contain \ - "depend on setup and APK artifacts" \ - "needs: [setup, apks]" -expect_publish_job_header_to_contain \ - "define an explicit job condition" \ - "if: >-" -expect_publish_job_header_to_contain \ - "stop when the workflow is cancelled" \ - "!cancelled()" -expect_publish_job_header_to_contain \ - "require successful setup" \ - "needs.setup.result == 'success'" -expect_publish_job_header_to_contain \ - "require successful APK builds" \ - "needs.apks.result == 'success'" - -if ((failures > 0)); then - printf '%d release workflow self-test(s) failed\n' "${failures}" >&2 - exit 1 -fi + prerequisites_pass = !cancelled && setup == "success" && tests == "success" + check.call(job_runs.call(jobs.fetch("apks"), needs, cancelled) == prerequisites_pass, + "APK preparation gate mismatch: #{[setup, tests, flag, cancelled].inspect}") + check.call(job_runs.call(jobs.fetch("play-bundles"), needs, cancelled) == (!cancelled && setup == "success" && flag == "true"), + "Bundle preparation gate mismatch: #{[setup, tests, bundles, flag, cancelled].inspect}") + check.call(job_runs.call(jobs.fetch("play"), needs, cancelled) == (prerequisites_pass && bundles == "success" && flag == "true"), + "Play gate mismatch: #{[setup, tests, flag, cancelled].inspect}") + condition_cases += 3 + end +end + +find_step = lambda do |job, name| + job.fetch("steps").find { |step| step["name"] == name } || raise("Missing step: #{name}") +end +cache_name = "Cache Robolectric Android runtimes" +release_cache = find_step.call(jobs.fetch("unit-tests"), cache_name) +ci_cache = find_step.call(load_workflow.call("trusted-linux-ci.yml").fetch("unit-tests"), cache_name) +check.call(release_cache.values_at("uses", "with") == ci_cache.values_at("uses", "with"), + "Release tests must share the normal CI Robolectric cache") +test_run = find_step.call(jobs.fetch("unit-tests"), "Run unit tests").fetch("run") +check.call(test_run.match?(/^\s+test\s*\\$/), "Release tests must keep the aggregate test task") + +matrix = jobs.fetch("apks").fetch("strategy").fetch("matrix").fetch("include") +check.call(matrix.map { |entry| entry.fetch("module") }.sort == %w[androidApp androidTvApp], + "APK preparation must include both phone and TV") +collect_step = find_step.call(jobs.fetch("apks"), "Collect release APKs") +publish_step = find_step.call(jobs.fetch("publish-release"), "Create or update release") +check.call(collect_step.fetch("env").fetch("PRAIRIE_VERSION_NAME") == "${{ needs.setup.outputs.version }}" && + publish_step.fetch("env").fetch("RELEASE_VERSION") == "${{ needs.setup.outputs.version }}", + "Artifact names and aliases must use the same full version, including tag suffixes") + +run_step = lambda do |step, root, env, entry = {}| + script = step.fetch("run").gsub(/\$\{\{\s*matrix\.([\w-]+)\s*\}\}/) do + entry.fetch(Regexp.last_match(1)) + end + raise "Unresolved workflow expression in shell fixture" if script.include?("${{") + Open3.capture3(env, "bash", "-c", script, chdir: root) +end + +prepare_apks = lambda do |root, version| + expected = {} + matrix.each do |entry| + apk_dir = File.join(root, File.dirname(entry.fetch("apk-path"))) + FileUtils.mkdir_p(apk_dir) + %w[arm64-v8a armeabi-v7a x86_64 universal].each do |abi| + bytes = "#{entry.fetch('module')} #{abi} APK fixture\0" + File.binwrite(File.join(apk_dir, "#{entry.fetch('module')}-#{abi}-release.apk"), bytes) + expected["#{entry.fetch('release-prefix')}-#{version}-#{abi}-release.apk"] = bytes + end + out, err, status = run_step.call(collect_step, root, {"PRAIRIE_VERSION_NAME" => version}, entry) + check.call(status.success?, "APK collection failed: #{out}#{err}") + end + files = Dir.glob(File.join(root, "release-artifacts/**/*.apk")) + check.call(files.map { |path| File.basename(path) }.sort == expected.keys.sort, + "Preparation must transfer exactly eight unique versioned APKs") + expected +end -printf 'All release workflow self-tests passed\n' +mock_gh = lambda do |root, version, existing| + bin_dir = File.join(root, "bin") + FileUtils.mkdir_p(bin_dir) + executable = File.join(bin_dir, "gh") + File.write(executable, <<~MOCK) + #!/usr/bin/env ruby + require "json" + File.open(ENV.fetch("GH_FIXTURE_CALLS"), "a") { |file| file.puts(ARGV.to_json) } + if ARGV.first(2) == ["release", "view"] + exit(ENV.fetch("GH_FIXTURE_EXISTING") == "true" ? 0 : 1) + end + MOCK + FileUtils.chmod(0755, executable) + { + "PATH" => "#{bin_dir}:#{ENV.fetch('PATH')}", + "GH_FIXTURE_CALLS" => File.join(root, "gh-calls.jsonl"), + "GH_FIXTURE_EXISTING" => existing.to_s, + "GH_REPO" => "fixture/prairie-android", + "RELEASE_TAG" => "v#{version}", + "RELEASE_VERSION" => version, + "GITHUB_SHA" => "0" * 40 + } +end + +["1.2.3", "1.2.3-rc.1+2"].product([false, true]).each do |version, existing| + Dir.mktmpdir("release-workflow-") do |root| + expected = prepare_apks.call(root, version) + %w[prairie-android prairie-android-tv].each do |prefix| + bytes = expected.fetch("#{prefix}-#{version}-universal-release.apk") + expected["#{prefix}-latest-universal-release.apk"] = bytes + expected["#{prefix}-latest-universal-debug.apk"] = bytes + end + env = mock_gh.call(root, version, existing) + out, err, status = run_step.call(publish_step, root, env) + check.call(status.success?, "Publication fixture failed: #{out}#{err}") + next unless status.success? + calls = File.readlines(env.fetch("GH_FIXTURE_CALLS")).map { |line| JSON.parse(line) } + mutation = existing ? "edit" : "create" + check.call(calls.map { |call| call.first(2) } == [["release", "view"], ["release", mutation], ["release", "upload"]], + "Publication must retain the create/update/upload sequence") + upload = calls.last + check.call(upload.first(3) == ["release", "upload", "v#{version}"] && upload.last == "--clobber", + "Publication must retain its release tag and replacement behavior") + upload_paths = upload[3...-1] + check.call(upload_paths.map { |path| File.basename(path) }.sort == expected.keys.sort, + "Publication must upload the twelve expected asset filenames") + upload_paths.each do |path| + check.call(File.binread(File.join(root, path)) == expected[File.basename(path)], + "Publication changed APK bytes: #{path}") + end + end +end + +%w[prairie-android prairie-android-tv].product(%w[missing empty duplicate]).each do |prefix, defect| + Dir.mktmpdir("release-workflow-invalid-") do |root| + version = "1.2.3" + prepare_apks.call(root, version) + universal = Dir.glob(File.join(root, "release-artifacts/**/#{prefix}-#{version}-universal-release.apk")).fetch(0) + case defect + when "missing" + FileUtils.rm(universal) + when "empty" + File.binwrite(universal, "") + when "duplicate" + duplicate_dir = File.join(root, "release-artifacts/duplicate") + FileUtils.mkdir_p(duplicate_dir) + FileUtils.cp(universal, duplicate_dir) + end + env = mock_gh.call(root, version, false) + out, _err, status = run_step.call(publish_step, root, env) + check.call(!status.success? && out.include?("Expected one nonempty universal APK for #{prefix}."), + "Publication must reject a #{defect} universal APK for #{prefix}") + check.call(!File.exist?(env.fetch("GH_FIXTURE_CALLS")), + "Invalid APKs must fail before any release API call") + end +end + +unless failures.empty? + warn failures.first(20).map { |message| "FAIL: #{message}" }.join("\n") + warn "#{failures.length} release workflow self-test(s) failed" + exit 1 +end +out, err, status = Open3.capture3("ruby", File.join(repo_root, "scripts/test-play-bundle-lanes.rb")) +raise "Play bundle lane fixtures failed: #{out}#{err}" unless status.success? +puts out +puts "All release workflow self-tests passed (#{condition_cases} gate cases; APK filenames and bytes verified)" +RUBY diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 9ca97f2734..aacca68672 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -152,6 +152,15 @@ kover { "org.prairieserver.prairie.player.SubtitleTrackDisplayLabelKt", "org.prairieserver.prairie.discovery.LocalIpv4Addresses_androidKt", "org.prairieserver.prairie.util.Dispatchers_androidKt", + // Platform OkHttp glue that lives in androidMain; the source-set filter leaks it. + "org.prairieserver.prairie.network.PrairieOkHttp", + "org.prairieserver.prairie.network.PrairieOkHttp$*", + // Live websocket frame I/O helpers in the same file as the realtime clients above. + "org.prairieserver.prairie.network.WatchTogetherRealtimeClientKt", + "org.prairieserver.prairie.network.WatchTogetherRealtimeClientKt$*", + // Live server identity/connection probe (needs a real server). + "org.prairieserver.prairie.network.api.DefaultServerIdentityApi", + "org.prairieserver.prairie.network.api.DefaultServerIdentityApi$*", ) } } diff --git a/shared/src/androidMain/kotlin/org/prairieserver/prairie/network/AndroidServerRegistry.kt b/shared/src/androidMain/kotlin/org/prairieserver/prairie/network/AndroidServerRegistry.kt index 7ed7c59253..cbf816fc00 100644 --- a/shared/src/androidMain/kotlin/org/prairieserver/prairie/network/AndroidServerRegistry.kt +++ b/shared/src/androidMain/kotlin/org/prairieserver/prairie/network/AndroidServerRegistry.kt @@ -112,6 +112,17 @@ class AndroidServerRegistry( } } + override suspend fun setVerifiedServerId(serverId: String, verifiedServerId: String?) { + mutex.withLock { + val current = _entries.value.firstOrNull { it.id == serverId } ?: return + if (current.verifiedServerId == verifiedServerId) return + val updated = _entries.value.map { entry -> + if (entry.id == serverId) entry.copy(verifiedServerId = verifiedServerId) else entry + } + persistAndApplyLocked(updated, _activeServerId.value) + } + } + override suspend fun setFetchedName(serverId: String, fetchedName: String?) { mutex.withLock { val updated = _entries.value.map { entry -> diff --git a/shared/src/androidMain/kotlin/org/prairieserver/prairie/network/EncryptedTokenManagerImpl.kt b/shared/src/androidMain/kotlin/org/prairieserver/prairie/network/EncryptedTokenManagerImpl.kt index d9c1bd06d3..a8a891a5e0 100644 --- a/shared/src/androidMain/kotlin/org/prairieserver/prairie/network/EncryptedTokenManagerImpl.kt +++ b/shared/src/androidMain/kotlin/org/prairieserver/prairie/network/EncryptedTokenManagerImpl.kt @@ -506,22 +506,39 @@ class EncryptedTokenManagerImpl( override suspend fun snapshotDurableLoginAuthority(): DurableLoginAuthority? = tokenWriteMutex.withLock { mutex.withLock { val scope = snapshotCurrentScopeLocked() ?: return@withLock null - if (scope.credentialGenerationId != null || scope.profileId.isNullOrBlank() || - accessToken.isNullOrBlank() || refreshToken.isNullOrBlank() || - scope.serverId in failedAuthorityWrites) return@withLock null - val key = serverScopedKey(scope.serverId, KEY_LOGIN_ID) - var loginId = prefs.getString(key, null) - if (loginId.isNullOrBlank()) { - loginId = java.util.UUID.randomUUID().toString() - pendingAuthorityBootstrap.add(scope.serverId) - } - // A failed SharedPreferences commit may still change its in-memory map. - // Retry that commit before exposing the marker, never trust the map alone. - if (scope.serverId in pendingAuthorityBootstrap) { - if (!prefs.edit().putString(key, loginId).commit()) return@withLock null - pendingAuthorityBootstrap.remove(scope.serverId) - } - DurableLoginAuthority(loginId, scope) + if (scope.credentialGenerationId != null || scope.profileId.isNullOrBlank()) return@withLock null + DurableLoginAuthority(durableLoginIdLocked(scope.serverId) ?: return@withLock null, scope) + } + } + + /** + * The login id of [serverId]'s saved slot, which every account + * replacement writes afresh and every sign-out removes. A slot signed in + * before the id existed gets one here. Null without saved credentials or + * while the slot's last write failed. + */ + private fun durableLoginIdLocked(serverId: String): String? { + if (persistentAccessToken(serverId).isNullOrBlank() || persistentRefreshToken(serverId).isNullOrBlank() || + serverId in failedAuthorityWrites) return null + val key = serverScopedKey(serverId, KEY_LOGIN_ID) + var loginId = prefs.getString(key, null) + if (loginId.isNullOrBlank()) { + loginId = java.util.UUID.randomUUID().toString() + pendingAuthorityBootstrap.add(serverId) + } + // A failed SharedPreferences commit may still change its in-memory map. + // Retry that commit before exposing the marker, never trust the map alone. + if (serverId in pendingAuthorityBootstrap) { + if (!prefs.edit().putString(key, loginId).commit()) return null + pendingAuthorityBootstrap.remove(serverId) + } + return loginId + } + + override suspend fun loginSessionId(serverId: String): String? = tokenWriteMutex.withLock { + mutex.withLock { + ensureCacheMatchesRegistryLocked() + durableLoginIdLocked(serverId) } } @@ -564,6 +581,36 @@ class EncryptedTokenManagerImpl( ) } + /** + * Answers for a saved account's slot only, active or not. A temporary + * overlay's scope answers false: its deadline lives with the overlay and + * [accessTokenExpiresWithin] already covers it. + */ + override suspend fun accessTokenExpiresWithin(scope: AuthScopeSnapshot, marginMs: Long): Boolean { + if (scope.credentialGenerationId != null) return false + return mutex.withLock { + ensureCacheMatchesRegistryLocked() + val serverId = scope.serverId + if (persistentAccessToken(serverId) == null) return@withLock false + val expiry: Long? + val lifetime: Long? + if (serverId == activeServerId) { + expiry = tokenExpiryEpochMs + lifetime = tokenLifetimeMs + } else { + val expiryKey = serverScopedKey(serverId, KEY_TOKEN_EXPIRY) + val lifetimeKey = serverScopedKey(serverId, KEY_TOKEN_LIFETIME) + expiry = if (prefs.contains(expiryKey)) prefs.getLong(expiryKey, 0L) else null + lifetime = if (prefs.contains(lifetimeKey)) prefs.getLong(lifetimeKey, 0L) else null + } + shouldRefreshProactively( + remainingMs = (expiry ?: return@withLock false) - System.currentTimeMillis(), + lifetimeMs = lifetime, + marginMs = marginMs, + ) + } + } + override suspend fun getAccessTokenForScope(serverId: String): String? = mutex.withLock { if (serverId == activeServerId) accessToken else prefs.getString(serverScopedKey(serverId, KEY_ACCESS_TOKEN), null) diff --git a/shared/src/androidMain/kotlin/org/prairieserver/prairie/network/PrairieHttpClient.android.kt b/shared/src/androidMain/kotlin/org/prairieserver/prairie/network/PrairieHttpClient.android.kt index f2158f08ce..b08213ca0a 100644 --- a/shared/src/androidMain/kotlin/org/prairieserver/prairie/network/PrairieHttpClient.android.kt +++ b/shared/src/androidMain/kotlin/org/prairieserver/prairie/network/PrairieHttpClient.android.kt @@ -12,12 +12,13 @@ import io.ktor.client.engine.okhttp.* * the process), and per-client dispatchers keep API, diagnostics, and image * traffic from queueing behind each other. */ -actual fun createPlatformHttpClient(): HttpClient { +actual fun createPlatformHttpClient(retryOnConnectionFailure: Boolean): HttpClient { return HttpClient(OkHttp) { engine { config { dispatcher(PrairieOkHttp.newDispatcher()) connectionPool(PrairieOkHttp.connectionPool) + retryOnConnectionFailure(retryOnConnectionFailure) } } } diff --git a/shared/src/androidMain/kotlin/org/prairieserver/prairie/player/SubtitleTrackDisplayLabel.kt b/shared/src/androidMain/kotlin/org/prairieserver/prairie/player/SubtitleTrackDisplayLabel.kt index 537f93d696..0f535e524d 100644 --- a/shared/src/androidMain/kotlin/org/prairieserver/prairie/player/SubtitleTrackDisplayLabel.kt +++ b/shared/src/androidMain/kotlin/org/prairieserver/prairie/player/SubtitleTrackDisplayLabel.kt @@ -44,36 +44,66 @@ fun formatSubtitleTrackDisplayLabel( isForced: Boolean, index: Int, ): String { + val label = subtitleTrackLabelParts(rawLabel, language, codecOrMime, isForced) + val parts = buildList { + label.languageName?.let { add(it) } + if (label.aiGenerated) add("AI Translation") + if (label.forced) add("Forced") + if (label.sdh) add("SDH") + label.descriptor?.let { add(it) } + label.format?.let { add(it) } + label.provider?.let { add(it) } + }.distinctBy { it.lowercase(Locale.US) } + + return parts.takeIf { it.isNotEmpty() }?.joinToString(" • ") + ?: "Subtitle ${index + 1}" +} + +/** + * The facts [formatSubtitleTrackDisplayLabel] joins into one string, kept + * apart so a picker can lay them out: the language as the row title, Forced + * and SDH as chips, the format and provider as a detail line. The provider + * and descriptor are null for an AI translation, which is labelled as one. + */ +data class SubtitleTrackLabelParts( + val languageName: String?, + val forced: Boolean, + val sdh: Boolean, + val aiGenerated: Boolean, + val descriptor: String?, + val format: String?, + val provider: String?, +) + +fun subtitleTrackLabelParts( + rawLabel: String?, + language: String?, + codecOrMime: String?, + isForced: Boolean, +): SubtitleTrackLabelParts { val cleanRaw = rawLabel?.trim().takeUnless { it.isNullOrBlank() } val providerId = providerIdentifier(cleanRaw) - val provider = providerDisplayName(cleanRaw) val labelWithoutProvider = cleanRaw?.replace(providerSuffix, "")?.trim() val inferredLanguage = languageCodeFor(language) ?: inferLanguageCode(labelWithoutProvider) val languageName = languageDisplayName(inferredLanguage) - val format = subtitleFormatLabel(codecOrMime ?: labelWithoutProvider) val forced = isForced || labelWithoutProvider?.let { forcedToken.containsMatchIn(it) } == true val sdh = labelWithoutProvider?.let { sdhToken.containsMatchIn(it) } == true val aiGenerated = providerId == "translated" || labelWithoutProvider?.contains(Regex("""(?i)\bAI\b""")) == true - val descriptor = meaningfulDescriptor( - rawLabel = labelWithoutProvider, + return SubtitleTrackLabelParts( languageName = languageName, forced = forced, sdh = sdh, - ).takeUnless { aiGenerated } - - val parts = buildList { - languageName?.let { add(it) } - if (aiGenerated) add("AI Translation") - if (forced) add("Forced") - if (sdh) add("SDH") - descriptor?.let { add(it) } - format?.let { add(it) } - if (!aiGenerated) provider?.let { add(it) } - }.distinctBy { it.lowercase(Locale.US) } - - return parts.takeIf { it.isNotEmpty() }?.joinToString(" • ") - ?: "Subtitle ${index + 1}" + aiGenerated = aiGenerated, + descriptor = meaningfulDescriptor( + rawLabel = labelWithoutProvider, + languageName = languageName, + forced = forced, + sdh = sdh, + ).takeUnless { aiGenerated }, + format = subtitleFormatLabel(codecOrMime ?: labelWithoutProvider), + provider = providerDisplayName(cleanRaw).takeUnless { aiGenerated }, + ) } private fun providerIdentifier(rawLabel: String?): String? = diff --git a/shared/src/androidMain/kotlin/org/prairieserver/prairie/util/WallClock.android.kt b/shared/src/androidMain/kotlin/org/prairieserver/prairie/util/WallClock.android.kt new file mode 100644 index 0000000000..03aaea4f0f --- /dev/null +++ b/shared/src/androidMain/kotlin/org/prairieserver/prairie/util/WallClock.android.kt @@ -0,0 +1,3 @@ +package org.prairieserver.prairie.util + +actual fun wallClockMillis(): Long = System.currentTimeMillis() diff --git a/shared/src/androidMain/kotlin/org/prairieserver/prairie/watchtogether/SharedPreferencesRecentWatchPartyStorage.kt b/shared/src/androidMain/kotlin/org/prairieserver/prairie/watchtogether/SharedPreferencesRecentWatchPartyStorage.kt new file mode 100644 index 0000000000..2dd467b6bb --- /dev/null +++ b/shared/src/androidMain/kotlin/org/prairieserver/prairie/watchtogether/SharedPreferencesRecentWatchPartyStorage.kt @@ -0,0 +1,22 @@ +package org.prairieserver.prairie.watchtogether + +import android.content.SharedPreferences + +/** + * Keeps the recent party in the app's encrypted preferences (see + * `createSecureSharedPrefs`). The value holds a room code and display + * metadata, never a room or join token. + */ +class SharedPreferencesRecentWatchPartyStorage( + private val prefs: SharedPreferences, +) : RecentWatchPartyStorage { + override fun read(): String? = prefs.getString(KEY, null) + + override fun write(value: String?) { + prefs.edit().apply { if (value == null) remove(KEY) else putString(KEY, value) }.apply() + } + + private companion object { + const val KEY = "watch_party.recent" + } +} diff --git a/shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/model/settings/PlaybackSettingsKeysContractTest.kt b/shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/model/settings/PlaybackSettingsKeysContractTest.kt new file mode 100644 index 0000000000..f018e1b657 --- /dev/null +++ b/shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/model/settings/PlaybackSettingsKeysContractTest.kt @@ -0,0 +1,48 @@ +package org.prairieserver.prairie.model.settings + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * "Use profile setting" is only honest for a device setting the profile can + * also hold; for the rest, clearing the device value falls to the default. + * Pins [PlaybackSettingsKeys.ProfileLayeredDeviceSettings] to the vendored + * manifest. + */ +class PlaybackSettingsKeysContractTest { + + private val definitions by lazy { + val raw = checkNotNull(javaClass.classLoader?.getResource("settings/v1/manifest.json")) { + "Missing test resource settings/v1/manifest.json" + }.readText() + Json.parseToJsonElement(raw).jsonObject.getValue("definitions").jsonArray + .map { it.jsonObject } + .associateBy { it.getValue("key").jsonPrimitive.content } + } + + private fun scopes(key: String): List<String> = + definitions.getValue(key).getValue("allowed_scopes").jsonArray.map { it.jsonPrimitive.content } + + @Test + fun profileLayeredKeysAreExactlyTheDeviceKeysWithAProfileScope() { + val expected = PlaybackSettingsKeys.DeviceSettings + .filter { it in definitions } + .filter { "profile_device" in scopes(it) && "profile" in scopes(it) } + .toSet() + assertEquals(expected, PlaybackSettingsKeys.ProfileLayeredDeviceSettings) + } + + @Test + fun overrideGroupsStayWithinTheProfileLayer() { + for (key in PlaybackSettingsKeys.ProfileLayeredDeviceSettings) { + val group = PlaybackSettingsKeys.deviceOverrideGroup(key) + assertTrue(key in group, key) + assertTrue(group.all { it in PlaybackSettingsKeys.ProfileLayeredDeviceSettings }, key) + } + } +} diff --git a/shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/model/settings/PlayerSettingsContractTest.kt b/shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/model/settings/PlayerSettingsContractTest.kt new file mode 100644 index 0000000000..29a0945a04 --- /dev/null +++ b/shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/model/settings/PlayerSettingsContractTest.kt @@ -0,0 +1,56 @@ +package org.prairieserver.prairie.model.settings + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.double +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +/** + * Pins the client-side restatements of manifest facts to the vendored + * manifest, so a re-vendor that changes them fails here. + */ +class PlayerSettingsContractTest { + + private val definitions by lazy { + val raw = checkNotNull(javaClass.classLoader?.getResource("settings/v1/manifest.json")) { + "Missing test resource settings/v1/manifest.json" + }.readText() + Json.parseToJsonElement(raw).jsonObject.getValue("definitions").jsonArray + .map { it.jsonObject } + .associateBy { it.getValue("key").jsonPrimitive.content } + } + + @Test + fun playbackSpeedBoundsMatchTheManifest() { + val schema = definitions.getValue(SettingKeys.PLAYER_PLAYBACK_SPEED) + .getValue("value_schema").jsonObject + assertEquals(PlaybackSpeedRange.MIN, schema.getValue("minimum").jsonPrimitive.double) + assertEquals(PlaybackSpeedRange.MAX, schema.getValue("maximum").jsonPrimitive.double) + assertEquals(PlaybackSpeedRange.STEP, schema.getValue("step").jsonPrimitive.double) + } + + @Test + fun profile7FallbackDefaultsOff() { + // AndroidPlayerSettingsStore falls back to this before the first + // refresh; it has to agree with what the server would resolve. + assertFalse( + definitions.getValue(SettingKeys.PLAYER_DV_PROFILE7_HDR10_FALLBACK) + .getValue("default_value").jsonPrimitive.boolean, + ) + } + + @Test + fun normalizeClampsAndSnapsToTheStep() { + assertEquals(3.0, PlaybackSpeedRange.normalize(4.0)) + assertEquals(0.25, PlaybackSpeedRange.normalize(0.01)) + assertEquals(1.35, PlaybackSpeedRange.normalize(1.33)) + assertEquals(1.25, PlaybackSpeedRange.normalize(1.25)) + assertEquals(2.5, PlaybackSpeedRange.normalize(2.5)) + assertEquals(1.0, PlaybackSpeedRange.normalize(Double.NaN)) + } +} diff --git a/shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/model/watchtogether/WatchPartyFixtures.android.kt b/shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/model/watchtogether/WatchPartyFixtures.android.kt new file mode 100644 index 0000000000..8bae470eab --- /dev/null +++ b/shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/model/watchtogether/WatchPartyFixtures.android.kt @@ -0,0 +1,6 @@ +package org.prairieserver.prairie.model.watchtogether + +actual fun readWatchPartyFixture(name: String): String = + checkNotNull(WatchPartyFixtureAnchor::class.java.classLoader?.getResource("watchtogether/v2/$name")) { + "Missing Watch Party fixture watchtogether/v2/$name" + }.readText() diff --git a/shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/network/EncryptedTokenManagerScopeGenerationTest.kt b/shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/network/EncryptedTokenManagerScopeGenerationTest.kt index 9613c05707..ff7e7cf042 100644 --- a/shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/network/EncryptedTokenManagerScopeGenerationTest.kt +++ b/shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/network/EncryptedTokenManagerScopeGenerationTest.kt @@ -395,6 +395,55 @@ class EncryptedTokenManagerScopeGenerationTest { assertTrue(observed.isEmpty()) } + // accessTokenExpiresWithin: whether the approver renews a scope's token + // before looking a TV's code up (TV sign-in spec). + + private fun key(serverId: String, name: String) = AndroidServerRegistry.serverScopedKey(serverId, name) + + private fun savedSession(serverId: String, expiresInMs: Long?): Array<Pair<String, Any?>> = listOfNotNull( + key(serverId, EncryptedTokenManagerImpl.KEY_ACCESS_TOKEN) to "$serverId-access", + key(serverId, EncryptedTokenManagerImpl.KEY_REFRESH_TOKEN) to "$serverId-refresh", + expiresInMs?.let { key(serverId, EncryptedTokenManagerImpl.KEY_TOKEN_EXPIRY) to System.currentTimeMillis() + it }, + expiresInMs?.let { key(serverId, EncryptedTokenManagerImpl.KEY_TOKEN_LIFETIME) to 3_600_000L }, + ).toTypedArray() + + private fun scope(serverId: String, credentialGenerationId: String? = null) = + AuthScopeSnapshot(serverId, null, "https://$serverId.example", null, credentialGenerationId = credentialGenerationId) + + @Test + fun theActiveServersNearExpiryAsksForARefresh() = runTest { + val near = EncryptedTokenManagerImpl(inMemoryPreferences(*savedSession("server-a", 10_000L)), FakeServerRegistry()) + assertTrue(near.accessTokenExpiresWithin(scope("server-a"), marginMs = 60_000L)) + + val far = EncryptedTokenManagerImpl(inMemoryPreferences(*savedSession("server-a", 3_000_000L)), FakeServerRegistry()) + assertFalse(far.accessTokenExpiresWithin(scope("server-a"), marginMs = 60_000L)) + } + + @Test + fun aNonActiveServersNearExpiryIsReadFromItsOwnSlot() = runTest { + val manager = EncryptedTokenManagerImpl( + inMemoryPreferences(*savedSession("server-a", 3_000_000L), *savedSession("server-b", 10_000L)), + FakeServerRegistry(), + ) + assertTrue(manager.accessTokenExpiresWithin(scope("server-b"), marginMs = 60_000L)) + assertFalse(manager.accessTokenExpiresWithin(scope("server-a"), marginMs = 60_000L), "the active slot is separate") + } + + @Test + fun aNonActiveServerWithoutAStoredExpiryDoesNotAsk() = runTest { + val manager = EncryptedTokenManagerImpl( + inMemoryPreferences(*savedSession("server-a", 10_000L), *savedSession("server-b", expiresInMs = null)), + FakeServerRegistry(), + ) + assertFalse(manager.accessTokenExpiresWithin(scope("server-b"), marginMs = 60_000L)) + } + + @Test + fun anOverlayScopeDoesNotAsk() = runTest { + val manager = EncryptedTokenManagerImpl(inMemoryPreferences(*savedSession("server-a", 10_000L)), FakeServerRegistry()) + assertFalse(manager.accessTokenExpiresWithin(scope("server-a", credentialGenerationId = "overlay-1"), marginMs = 60_000L)) + } + private class FakeServerRegistry : ServerRegistry { private val serverA = ServerEntry(id = "server-a", url = "https://server-a.example") private val serverB = ServerEntry(id = "server-b", url = "https://server-b.example") diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/RoomSyncEngine.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/RoomSyncEngine.kt index 5047aed3be..d3e68d072b 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/RoomSyncEngine.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/RoomSyncEngine.kt @@ -49,6 +49,11 @@ class RoomSyncEngine { private var lastCommandId: String? = null + /** Adopt the room owner's server clock estimate (see `WatchTogetherRepository.clock`). */ + fun setServerTimeOffset(offsetMs: Long?) { + serverTimeOffsetMs = offsetMs + } + /** Record a pong round-trip sample; updates [serverTimeOffsetMs]. */ fun recordPongSample( clientSentMs: Long, diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/cast/PrairieCastMessage.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/cast/PrairieCastMessage.kt index b7bc529dd2..42fb1ec3c4 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/cast/PrairieCastMessage.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/cast/PrairieCastMessage.kt @@ -4,7 +4,13 @@ import kotlinx.serialization.EncodeDefault import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.elementNames +import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonClassDiscriminator +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull /** * Wire model for the `_prairiecast._tcp` phone→TV control channel. @@ -103,6 +109,28 @@ sealed class PrairieCastMessage { data class Close( @EncodeDefault(EncodeDefault.Mode.ALWAYS) override val v: Int = PrairieCastProtocol.version, ) : PrairieCastMessage() + + companion object { + /** Every `type` this build understands, read from the serializer so it can't drift. */ + val knownTypes: Set<String> by lazy { + serializer().descriptor.getElementDescriptor(1).elementNames.toSet() + } + + /** + * Decodes one frame, or returns null for a `type` this build doesn't + * know. A newer peer may send kinds added after this build; dropping + * them keeps the session up where a decode error would tear it down + * (Apple ignores them the same way). A known type with a malformed + * payload still throws. + */ + fun decodeOrNull(json: Json, text: String): PrairieCastMessage? { + val frame = json.parseToJsonElement(text) as? JsonObject + ?: throw SerializationException("PrairieCast frame is not a JSON object") + val type = (frame["type"] as? JsonPrimitive)?.contentOrNull + if (type != null && type !in knownTypes) return null + return json.decodeFromJsonElement(serializer(), frame) + } + } } @Serializable @@ -155,6 +183,8 @@ data class PrairieCastHandoffOffer( val serverName: String? = null, val profileId: String, val profileName: String? = null, + /** Display-only: what the phone is about to launch. Older peers omit and ignore it. */ + val title: String? = null, ) @Serializable @@ -232,8 +262,8 @@ data class PrairieCastPlaybackState( /** * Command envelope: a [name] from Apple's closed Name enum plus the sparse - * argument fields. Apple's decoder REJECTS unknown names, so never send a - * string outside this set. + * argument fields. Current Apple builds ignore a name they don't know, but + * older ones reject the whole frame, so never send a string outside this set. */ @Serializable data class PrairieCastControlCommand( @@ -257,13 +287,18 @@ data class PrairieCastControlCommand( const val SetPlaybackSpeed = "set_playback_speed" const val SetQuality = "set_quality" const val SetVideoGravity = "set_video_gravity" - const val SetHdrEnabled = "set_hdr_enabled" const val SetSubtitleSyncMs = "set_subtitle_sync_ms" const val SetSubtitlePosition = "set_subtitle_position" const val SetVolume = "set_volume" const val SetMuted = "set_muted" const val PlayNext = "play_next" + /** Every command name this build implements. */ + val knownNames: Set<String> = setOf( + Play, Pause, PlayPause, Seek, Stop, SelectAudioTrack, SelectSubtitleTrack, SetPlaybackSpeed, + SetQuality, SetVideoGravity, SetSubtitleSyncMs, SetSubtitlePosition, SetVolume, SetMuted, PlayNext, + ) + fun play(): PrairieCastControlCommand = PrairieCastControlCommand(name = Play) fun pause(): PrairieCastControlCommand = PrairieCastControlCommand(name = Pause) @@ -290,9 +325,6 @@ data class PrairieCastControlCommand( fun setVideoGravity(value: String): PrairieCastControlCommand = PrairieCastControlCommand(name = SetVideoGravity, value = value) - fun setHdrEnabled(enabled: Boolean): PrairieCastControlCommand = - PrairieCastControlCommand(name = SetHdrEnabled, enabled = enabled) - fun setSubtitleSyncMs(milliseconds: Int): PrairieCastControlCommand = PrairieCastControlCommand(name = SetSubtitleSyncMs, milliseconds = milliseconds) diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/catalog/LibraryMediaScope.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/catalog/LibraryMediaScope.kt new file mode 100644 index 0000000000..b9d679b4ef --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/catalog/LibraryMediaScope.kt @@ -0,0 +1,67 @@ +package org.prairieserver.prairie.catalog + +import kotlinx.serialization.encodeToString +import org.prairieserver.prairie.model.catalog.CatalogResponse +import org.prairieserver.prairie.model.section.ResolvedSection +import org.prairieserver.prairie.model.section.SectionItem +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.PrairieJson +import org.prairieserver.prairie.network.apiv2.CatalogContinuationV2 + +fun matchesLibraryMediaScope(type: String, scope: String?): Boolean = when (scope) { + "movie" -> type.trim().lowercase() in setOf("movie", "movies", "film") + "series" -> type.trim().lowercase() in setOf("series", "show", "shows", "tv", "episode") + else -> true +} + +data class ScopedLibrarySection(val section: ResolvedSection, val incomplete: Boolean = false) + +/** + * Section sources reject type overlays. Page their original order and filter locally. + * Shelves with profile overrides keep their scoped inline slice: a refill would page the admin definition. + */ +suspend fun scopeLibrarySection( + section: ResolvedSection, + mediaScope: String?, + loadPage: suspend (CatalogContinuationV2?) -> ApiResult<CatalogResponse>, +): ScopedLibrarySection { + if (mediaScope == null) return ScopedLibrarySection(section) + val initial = section.items.filter { matchesLibraryMediaScope(it.type, mediaScope) } + val target = (section.itemLimit.takeIf { it > 0 } ?: section.items.size.coerceAtLeast(20)).coerceAtMost(100) + fun result(items: List<SectionItem>, incomplete: Boolean = false) = + ScopedLibrarySection(section.copy(items = items.take(target), totalCount = items.size.coerceAtMost(target)), incomplete) + if (initial.size >= target) return result(initial) + // Recently Added/Released report total_count = len(items) after LIMIT item_limit, so the count only + // proves "more" when it exceeds the slice. Only an under-filled slice proves the shelf is exhausted. + val exhausted = section.totalCount <= section.items.size && + section.itemLimit > 0 && section.items.size < section.itemLimit + if (exhausted) return result(initial) + // The catalog section source pages the stored admin definition, not the profile-resolved one, so a + // customized or profile-added shelf cannot be refilled without dropping the profile's overrides. + if (section.customized || section.isCustom) return result(initial, incomplete = true) + + val originals = section.items.associateBy { it.contentId } + val visible = linkedMapOf<String, SectionItem>() + var continuation: CatalogContinuationV2? = null + repeat(8) { + when (val page = loadPage(continuation)) { + is ApiResult.Success -> { + page.data.items.filter { matchesLibraryMediaScope(it.type, mediaScope) }.forEach { item -> + visible.putIfAbsent(item.contentId, originals[item.contentId] + ?: PrairieJson.decodeFromString<SectionItem>(PrairieJson.encodeToString(item))) + } + // Inline items come first: a changing shelf (Random) can page a sample without them, and + // they are the start of an ordered shelf anyway, so the trim to target never evicts them. + // They also count toward the target, so a page that completes the shelf ends the paging. + val merged = (initial + visible.values).distinctBy { it.contentId } + if (merged.size >= target || !page.data.hasMore) return result(merged) + val next = page.data.continuation + if (next == null || next === continuation || page.data.items.isEmpty()) + return result(merged, true) + continuation = next + } + else -> return result((initial + visible.values).distinctBy { it.contentId }, true) + } + } + return result((initial + visible.values).distinctBy { it.contentId }, true) +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/di/NetworkModule.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/di/NetworkModule.kt index e10fe47642..5d3ba33503 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/di/NetworkModule.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/di/NetworkModule.kt @@ -14,7 +14,16 @@ import org.koin.dsl.module val networkModule = module { single<IdentityTransitionBarrier> { DefaultIdentityTransitionBarrier() } single<TokenManager> { TokenManagerImpl(get()) } - single { createPrairieClient(get(), getOrNull(), getOrNull(), getOrNull()) } + single { org.prairieserver.prairie.network.AccessChangeSignals() } + single { + createPrairieClient( + tokenManager = get(), + deviceMetadataProvider = getOrNull(), + diagnosticsObserver = getOrNull(), + cleartextOriginConsent = getOrNull(), + accessChangeSignals = get(), + ) + } single { ApiV2Gate(getOrNull()) } single { MembershipV2Api(get(), get(), get()) } single { ApiV2Probe(get()) } @@ -23,6 +32,10 @@ val networkModule = module { single { AuthApi(get(), get()) } single { OnboardingApi(get(), get(), get()) } single<DeviceLoginApi> { DefaultDeviceLoginApi(get(), get()) } + single<ServerIdentityApi> { DefaultServerIdentityApi(get()) } + single<org.prairieserver.prairie.network.api.ExternalSignInApi> { + org.prairieserver.prairie.network.api.DefaultExternalSignInApi(get(), get()) + } single { CatalogV2Api(get(), get(), get()) } single { PersonRefreshV2Api(get(), get(), get()) } single { WatchDetailV2Api(get(), get(), get()) } @@ -43,12 +56,13 @@ val networkModule = module { single { SimilarCardsV2Api(get(), get(), get()) } single { TasteProfileV2Api(get(), get(), get()) } single { DiscoverV2Api(get(), get(), get()) } + single { ShufflesV2Api(get(), get(), get()) } single { RecommendationApi(get(), get(), get(), get()) } single<RequestsApi> { DefaultRequestsApi(get(), get(), get()) } // Prairie-only: Live TV (Jellyfin-compat endpoints on prairie-server). single<LiveTvApi> { DefaultLiveTvApi(get()) } single<MetadataAiApi> { DefaultMetadataAiApi(get(), get(), get()) } - single { EventsSocketV2Api(get(), get(), get()) } + single { EventsSocketV2Api(get(), get(), get(), getOrNull()) } single<HomeRealtimeClient> { DefaultHomeRealtimeClient(get()) } single<CalendarApi> { DefaultCalendarApi(get(), get(), get()) } single { HealthApi(get()) } @@ -67,9 +81,25 @@ val networkModule = module { single { SubtitleDownloadV2Api(get(), get(), get()) } single { SubtitleReadsV2Api(get(), get(), get()) } single { SubtitleAiCreateV2Api(get(), get(), get()) } + single { SubtitleSyncV2Api(get(), get(), get()) } single<SubtitlesApi> { DefaultSubtitlesApi(get(), get(), get(), get()) } single<NotificationsApi> { NotificationsV2Api(get(), get(), get()) } single<PushRegistrationApi> { DefaultPushRegistrationApi(get(), get(), get()) } - single<WatchTogetherApi> { DefaultWatchTogetherApi(get(), get()) } + single<WatchTogetherApi> { + DefaultWatchTogetherApi( + client = get(), + gate = get(), + tokens = get(), + // Start, select, and promote must never be resent by the engine. + nonReplayingClient = createPrairieClient( + tokenManager = get(), + deviceMetadataProvider = getOrNull(), + diagnosticsObserver = getOrNull(), + cleartextOriginConsent = getOrNull(), + retryOnConnectionFailure = false, + accessChangeSignals = get(), + ), + ) + } single<DiagnosticsApi> { DefaultDiagnosticsApi(get(), gate = get()) } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/di/RepositoryModule.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/di/RepositoryModule.kt index 16992464b7..1b462f6edd 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/di/RepositoryModule.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/di/RepositoryModule.kt @@ -21,6 +21,7 @@ import org.prairieserver.prairie.repository.PlaybackRepository import org.prairieserver.prairie.repository.ProfileRepository import org.prairieserver.prairie.repository.PushRegistrationRepository import org.prairieserver.prairie.repository.RecommendationRepository +import org.prairieserver.prairie.repository.RequestDetailCache import org.prairieserver.prairie.repository.RequestsRepository import org.prairieserver.prairie.repository.SectionRepository import org.prairieserver.prairie.repository.SettingsRepository @@ -28,7 +29,6 @@ import org.prairieserver.prairie.repository.WatchTogetherRepository import org.prairieserver.prairie.network.TokenManager import org.prairieserver.prairie.network.api.PlaybackApi import org.prairieserver.prairie.watchtogether.RoomSession -import org.prairieserver.prairie.watchtogether.WatchTogetherEntryGateway import org.koin.dsl.module import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -64,9 +64,13 @@ val repositoryModule = module { } single { OnboardingRepository(get()) } single { DeviceLoginRepository(get()) } + single { org.prairieserver.prairie.repository.ServerIdentityRepository(get(), get()) } + single { org.prairieserver.prairie.repository.ExternalSignInRepository(get()) } single { CatalogRepository( catalogApi = get(), + catalogLanguage = get(), + identityTransitions = get(), catalogCache = getOrNull<org.prairieserver.prairie.repository.port.CatalogCachePort>() ?: org.prairieserver.prairie.repository.port.NoOpCatalogCachePort, ) @@ -85,9 +89,25 @@ val repositoryModule = module { ?: org.prairieserver.prairie.repository.port.NoOpUserItemStatePort, catalogCache = getOrNull<org.prairieserver.prairie.repository.port.CatalogCachePort>() ?: org.prairieserver.prairie.repository.port.NoOpCatalogCachePort, + hiddenLibraries = get(), ) } + single { org.prairieserver.prairie.repository.HiddenLibrariesStore(get(), get()) } single { ProfileRepository(get(), get(), getOrNull(), get(), get(), get()) } + // Resolved by each Application right after startKoin (guarded there, like + // the other starters) so a stale-profile refusal from background work + // (downloads, outbox replay) is handled before any screen exists. Clears + // only the stale profile selection; navigation acts on its pending prompt. + single { + org.prairieserver.prairie.repository.ProfileVerificationRecovery( + signals = get(), + tokenManager = get(), + profileRepository = get(), + identityTransitions = get(), + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + ) + } + single { org.prairieserver.prairie.repository.ForegroundAccessCheck(get(), get()) } single { CollectionRepository(get()) } single { SectionRepository( @@ -97,22 +117,39 @@ val repositoryModule = module { ) } single { RecommendationRepository(get()) } - single { RequestsRepository(get()) } + single { + RequestsRepository( + api = get(), + cache = RequestDetailCache( + prefetchScope = kotlinx.coroutines.CoroutineScope( + kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.Main.immediate, + ), + ), + ) + } single { RequestsFeatureStore(get()) } single { LiveTvRepository(get()) } single { LiveTvFeatureStore(get()) } single { ActiveProfileStore(get()) } single { org.prairieserver.prairie.repository.MetadataAiRepository(get()) } single { org.prairieserver.prairie.model.feature.MetadataAiFeatureStore(get()) } - single { org.prairieserver.prairie.repository.HomeRealtimeCoordinator(get(), get()) } + single { org.prairieserver.prairie.model.feature.ShuffleFeatureStore(get()) } + single { org.prairieserver.prairie.repository.HomeRealtimeCoordinator(get(), get(), getOrNull()) } single { SettingsRepository(get()) } // Profile-scoped canonical settings, shared by the phone and TV screens so // one platform cannot grow a behavior the other lacks. - single { org.prairieserver.prairie.domain.settings.ProfileSettingsController(get()) } + single { org.prairieserver.prairie.domain.settings.CatalogLanguageRevision() } + single { + org.prairieserver.prairie.domain.settings.ProfileSettingsController( + get(), get(), + captureAuthority = { get<TokenManager>().snapshotCurrentScope() }, + invalidateCatalogMetadata = { get<CatalogRepository>().invalidateLocalizedMetadata() }, + ) + } single { LibraryPlaybackPrefsRepository(get()) } single { DownloadsRepository(get(), getOrNull<org.prairieserver.prairie.repository.port.DownloadDeletionPort>() ?: org.prairieserver.prairie.repository.port.NoOpDownloadDeletionPort, get(), get(), get()) } single { EbookReaderRepository(get(), get()) } - single { SubtitlesRepository(get(), get()) } + single { SubtitlesRepository(get(), get(), get()) } single { PushRegistrationRepository(get()) } // REST-backed inbox state plus a realtime factory that builds the default @@ -148,7 +185,6 @@ val repositoryModule = module { }, ) } - single<WatchTogetherEntryGateway> { get<WatchTogetherRepository>() } // Eager so the identity-transition privacy gate is installed before any // profile/server/token mutation can occur. This process-lifetime scope, // rather than a screen scope, owns connection replacement and teardown. diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/domain/settings/CatalogLanguageRevision.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/domain/settings/CatalogLanguageRevision.kt new file mode 100644 index 0000000000..b86bae2a17 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/domain/settings/CatalogLanguageRevision.kt @@ -0,0 +1,36 @@ +package org.prairieserver.prairie.domain.settings + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet +import org.prairieserver.prairie.repository.port.CatalogCacheScope + +/** + * Bumps when the active profile's metadata language changes on this device. + * + * The server localizes titles and descriptions per profile, so every catalog + * list on screen holds text in the old language until it is read again. Home, + * For You and library surfaces collect this and re-read quietly; a profile + * switch needs no signal because it rebuilds those screens. + */ +class CatalogLanguageRevision { + internal data class Changes( + val revision: Long = 0L, + val scopes: Map<CatalogCacheScope?, Long> = emptyMap(), + ) + + private val _changes = MutableStateFlow(Changes()) + internal val changes: Changes get() = _changes.value + private val _revision = MutableStateFlow(0L) + val revision: StateFlow<Long> = _revision.asStateFlow() + + fun changed(scope: CatalogCacheScope? = null) { + val next = _changes.updateAndGet { current -> + val revision = current.revision + 1 + Changes(revision, current.scopes + (scope to revision)) + } + _revision.update { maxOf(it, next.revision) } + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/domain/settings/ProfileSettingsController.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/domain/settings/ProfileSettingsController.kt index d240454faa..979680fd30 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/domain/settings/ProfileSettingsController.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/domain/settings/ProfileSettingsController.kt @@ -1,9 +1,13 @@ package org.prairieserver.prairie.domain.settings +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext import org.prairieserver.prairie.model.settings.EffectiveSettingValue import org.prairieserver.prairie.model.settings.SettingKeys import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot import org.prairieserver.prairie.repository.SettingsRepository +import org.prairieserver.prairie.repository.port.CatalogCacheScope import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonPrimitive @@ -28,6 +32,9 @@ import kotlinx.serialization.json.jsonPrimitive */ class ProfileSettingsController( private val repository: SettingsRepository, + private val catalogLanguage: CatalogLanguageRevision = CatalogLanguageRevision(), + private val captureAuthority: (suspend () -> AuthScopeSnapshot?)? = null, + private val invalidateCatalogMetadata: suspend () -> Unit = {}, ) { /** Whether the connected server answered the canonical settings probe. */ @@ -110,8 +117,8 @@ class ProfileSettingsController( * caller should surface: the write landed, and the optimistic value is * still the best guess until the next load. */ - private suspend fun reresolve(): Snapshot? = - when (val result = repository.getEffectiveValues(PROFILE_KEYS)) { + private suspend fun reresolve(authority: AuthScopeSnapshot? = null): Snapshot? = + when (val result = repository.getEffectiveValues(PROFILE_KEYS, authority = authority)) { is ApiResult.Success -> snapshotOf(result.data) is ApiResult.Error, is ApiResult.NetworkError -> null } @@ -128,27 +135,40 @@ class ProfileSettingsController( resolved(write(SettingKeys.PLAYBACK_SHOW_FORCED_SUBTITLES, JsonPrimitive(enabled))) /** [language] is a BCP 47 tag, or "" to inherit the library's language. */ - suspend fun setMetadataLanguage(language: String): WriteResult = - resolved(writeLanguage(SettingKeys.CATALOG_METADATA_LANGUAGE, language)) + suspend fun setMetadataLanguage(language: String): WriteResult { + val authority = captureAuthority?.invoke() + if (captureAuthority != null && authority?.profileId == null) return WriteResult(false, null) + val result = writeLanguage(SettingKeys.CATALOG_METADATA_LANGUAGE, language, authority) + // The write committed even if the following settings read is canceled. + if (result is ApiResult.Success) { + catalogLanguage.changed(authority?.let { CatalogCacheScope(it.serverId, requireNotNull(it.profileId)) }) + // Complete durable invalidation even if the post-write reload is + // canceled, so a later app launch cannot revive the old language. + withContext(NonCancellable) { + invalidateCatalogMetadata() + } + } + return resolved(result, authority) + } - private suspend fun resolved(write: ApiResult<Unit>): WriteResult = + private suspend fun resolved(write: ApiResult<Unit>, authority: AuthScopeSnapshot? = null): WriteResult = if (write is ApiResult.Success) { - WriteResult(succeeded = true, snapshot = reresolve()) + WriteResult(succeeded = true, snapshot = reresolve(authority)) } else { WriteResult(succeeded = false, snapshot = null) } - private suspend fun writeLanguage(key: String, language: String): ApiResult<Unit> { + private suspend fun writeLanguage(key: String, language: String, authority: AuthScopeSnapshot? = null): ApiResult<Unit> { val tag = language.trim() // The store spells "no preference" as the empty string; the contract // spells it as no row at all (the server's language_tag validator // refuses ""). Clearing rather than writing null keeps the two the // same statement and matches how the server mirrors the legacy column. - return if (tag.isEmpty()) repository.clearProfileValue(key) else write(key, JsonPrimitive(tag)) + return if (tag.isEmpty()) repository.clearProfileValue(key, authority) else write(key, JsonPrimitive(tag), authority) } - private suspend fun write(key: String, value: JsonElement): ApiResult<Unit> = - when (val result = repository.setProfileValue(key, value)) { + private suspend fun write(key: String, value: JsonElement, authority: AuthScopeSnapshot? = null): ApiResult<Unit> = + when (val result = repository.setProfileValue(key, value, authority)) { is ApiResult.Success -> ApiResult.Success(Unit) is ApiResult.Error -> result is ApiResult.NetworkError -> result diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/domain/settings/TitleArtController.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/domain/settings/TitleArtController.kt new file mode 100644 index 0000000000..dd2b22dd58 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/domain/settings/TitleArtController.kt @@ -0,0 +1,172 @@ +package org.prairieserver.prairie.domain.settings + +import org.prairieserver.prairie.model.settings.EffectiveSettingValue +import org.prairieserver.prairie.model.settings.SettingKeys +import org.prairieserver.prairie.model.settings.SettingScope +import org.prairieserver.prairie.model.settings.SettingsContractCapabilities +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.repository.SettingsRepository +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.jsonPrimitive + +/** + * `ui.title_art` as resolved for this device. + * + * [appliesToAllDevices] is the "Apply to all devices" switch: the contract + * resolves `profile` ahead of `profile_device` for this key, so a profile value + * is the profile's all-devices choice and its presence is the switch's state. + */ +data class TitleArtPreference( + val showTitleArt: Boolean = TitleArtController.DEFAULT_SHOW_TITLE_ART, + val appliesToAllDevices: Boolean = false, +) + +/** + * Stateless wire half of the "Show title art" setting, shared by the phone and + * TV apps. The client never reimplements precedence: reads take the server's + * effective value for this device, and writes follow the `ui.title_art` + * manifest notes. + * + * Every call takes the caller's captured `authority` and passes it to each + * request it makes, so one change (up to a PUT, a DELETE and a re-read) is + * bound to one profile: after a profile switch the remaining requests fail as + * `identity_changed` instead of addressing the new profile. + */ +class TitleArtController( + private val repository: SettingsRepository, +) { + + sealed interface LoadResult { + data class Supported(val preference: TitleArtPreference) : LoadResult + + /** Definitive: the server predates the key. Never write. */ + data object Unsupported : LoadResult + + /** Transient failure (offline, 5xx). Keep whatever state was known. */ + data class Failed(val message: String?) : LoadResult + } + + sealed interface WriteResult { + /** + * Every request landed. [resolved] is what the server resolves now, or + * null when that re-read failed; the caller then keeps its optimistic + * value, because the write did take effect. + */ + data class Saved(val resolved: TitleArtPreference?) : WriteResult + + data class Failed(val message: String?) : WriteResult + } + + suspend fun load(authority: AuthScopeSnapshot? = null): LoadResult { + when (val caps = repository.contractCapabilities()) { + is ApiResult.Success -> if (!isSupported(caps.data)) return LoadResult.Unsupported + // No contract routes: an older server, not a transient failure. + is ApiResult.Error -> + return if (caps.code == 404) LoadResult.Unsupported else LoadResult.Failed(caps.message) + is ApiResult.NetworkError -> return LoadResult.Failed(caps.exception.message) + } + return when (val result = resolve(authority)) { + is ApiResult.Success -> LoadResult.Supported(result.data) + is ApiResult.Error -> LoadResult.Failed(result.message) + is ApiResult.NetworkError -> LoadResult.Failed(result.exception.message) + } + } + + /** + * The main switch. While the value applies to all devices it is the + * profile's value; otherwise it is this device's own. + */ + suspend fun setShowTitleArt( + show: Boolean, + appliesToAllDevices: Boolean, + authority: AuthScopeSnapshot? = null, + ): WriteResult { + val scope = if (appliesToAllDevices) SettingScope.PROFILE else SettingScope.PROFILE_DEVICE + return put(scope, show, authority) ?: saved(authority) + } + + /** + * The "Apply to all devices" switch. [showTitleArt] is the value on screen, + * which becomes the profile's value (on) or this device's value (off). + * + * Turning it off pins this device first, so it keeps its look once the + * profile value stops winning, and then clears the profile value so every + * other device returns to its own value or the default. A missing profile + * value already is the state asked for (the repository reports that 404 as + * success). + */ + suspend fun setAppliesToAllDevices( + enabled: Boolean, + showTitleArt: Boolean, + authority: AuthScopeSnapshot? = null, + ): WriteResult { + if (enabled) return put(SettingScope.PROFILE, showTitleArt, authority) ?: saved(authority) + put(SettingScope.PROFILE_DEVICE, showTitleArt, authority)?.let { return it } + return when (val cleared = repository.clearProfileValue(KEY, authority)) { + is ApiResult.Success -> saved(authority) + is ApiResult.Error -> WriteResult.Failed(cleared.message) + is ApiResult.NetworkError -> WriteResult.Failed(cleared.exception.message) + } + } + + /** Null when the write landed, else the failure to report. */ + private suspend fun put( + scope: SettingScope, + value: Boolean, + authority: AuthScopeSnapshot?, + ): WriteResult.Failed? { + val result = when (scope) { + SettingScope.PROFILE -> repository.setProfileValue(KEY, JsonPrimitive(value), authority) + SettingScope.PROFILE_DEVICE -> repository.setProfileDeviceValue(KEY, JsonPrimitive(value), authority) + else -> error("ui.title_art allows only profile and profile_device") + } + return when (result) { + is ApiResult.Success -> null + is ApiResult.Error -> WriteResult.Failed(result.message) + is ApiResult.NetworkError -> WriteResult.Failed(result.exception.message) + } + } + + private suspend fun saved(authority: AuthScopeSnapshot?): WriteResult = + WriteResult.Saved((resolve(authority) as? ApiResult.Success)?.data) + + private suspend fun resolve(authority: AuthScopeSnapshot?): ApiResult<TitleArtPreference> = + when (val result = repository.getEffectiveValues(listOf(KEY), authority = authority)) { + is ApiResult.Success -> { + val entry = result.data[KEY] + if (entry == null) { + ApiResult.Error(0, "missing_key", "The server did not resolve $KEY") + } else { + ApiResult.Success(preferenceOf(entry)) + } + } + is ApiResult.Error -> result + is ApiResult.NetworkError -> result + } + + companion object { + const val KEY = SettingKeys.UI_TITLE_ART + + /** Settings manifest revision that introduced `ui.title_art`. */ + const val MIN_CONTRACT_REVISION = 16 + + const val DEFAULT_SHOW_TITLE_ART = true + + private const val SETTINGS_API_VERSION = 1 + + fun isSupported(capabilities: SettingsContractCapabilities): Boolean = + capabilities.apiVersion == SETTINGS_API_VERSION && + capabilities.manifestRevision >= MIN_CONTRACT_REVISION && + capabilities.supportsBatchedEffective + + /** A non-boolean value is not a choice; it resolves to the default. */ + fun preferenceOf(entry: EffectiveSettingValue): TitleArtPreference = + TitleArtPreference( + showTitleArt = runCatching { entry.value.jsonPrimitive.booleanOrNull }.getOrNull() + ?: DEFAULT_SHOW_TITLE_ART, + appliesToAllDevices = entry.source == SettingScope.PROFILE.wire, + ) + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/metadata/DescriptionTranslationController.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/metadata/DescriptionTranslationController.kt index a8ab591f95..4b6e3ab9e5 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/metadata/DescriptionTranslationController.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/metadata/DescriptionTranslationController.kt @@ -28,6 +28,15 @@ class DescriptionTranslationController( private val _phase = MutableStateFlow(DescriptionTranslationPhase.Idle) val phase: StateFlow<DescriptionTranslationPhase> = _phase.asStateFlow() + private val _runningContentId = MutableStateFlow<String?>(null) + + /** + * The item whose translation [phase] describes, so a surface that switches + * items (the TV focus marquee) never shows one item's progress on another. + * Kept after a failure so the failed item can offer a retry. + */ + val runningContentId: StateFlow<String?> = _runningContentId.asStateFlow() + private val autoFired = mutableSetOf<String>() /** @@ -35,14 +44,19 @@ class DescriptionTranslationController( * current `pending_translation_language`; null means the translation * landed (the refetch has already delivered the new overview). * @param onTranslated invoked once the pending language clears. + * @param onPoll invoked on every poll tick before the refetch, for data + * the job also changes but the detail does not carry (a season job + * translates its episodes, so the episode list refreshes here). */ suspend fun translate( contentId: String, targetLanguage: String, refetchPendingLanguage: suspend (org.prairieserver.prairie.network.AuthScopeSnapshot?) -> String?, onTranslated: suspend () -> Unit, + onPoll: suspend (org.prairieserver.prairie.network.AuthScopeSnapshot?) -> Unit = {}, ) { if (_phase.value == DescriptionTranslationPhase.Translating) return + _runningContentId.value = contentId _phase.value = DescriptionTranslationPhase.Translating val owner = repository.captureAuthority() @@ -71,6 +85,11 @@ class DescriptionTranslationController( _phase.value = DescriptionTranslationPhase.Failed return } + onPoll(owner) + if (!repository.isCurrent(owner)) { + _phase.value = DescriptionTranslationPhase.Failed + return + } val pending = refetchPendingLanguage(owner) if (!repository.isCurrent(owner)) { _phase.value = DescriptionTranslationPhase.Failed @@ -95,6 +114,29 @@ class DescriptionTranslationController( } } + private var deferredAuto = false + + /** + * `auto` on-view mode: claim (content, language) for a translation that + * will start now. Returns false without claiming when it already fired, or + * while another translation runs — that one would make [translate] a no-op + * and the claim would block every later try. In the busy case + * [takeDeferredAuto] reports it, so the caller tries again once the + * running translation ends. + */ + fun claimAutoFire(contentId: String, targetLanguage: String): Boolean { + if (_phase.value == DescriptionTranslationPhase.Translating) { + deferredAuto = true + return false + } + if (!shouldAutoFire(contentId, targetLanguage)) return false + markAutoFired(contentId, targetLanguage) + return true + } + + /** True once, after an auto claim was refused because a translation was running. */ + fun takeDeferredAuto(): Boolean = deferredAuto.also { deferredAuto = false } + /** `auto` on-view mode: fire at most once per (content, language) per session. */ fun shouldAutoFire(contentId: String, targetLanguage: String): Boolean = "$contentId|$targetLanguage" !in autoFired @@ -110,7 +152,9 @@ class DescriptionTranslationController( } private companion object { - // Apple DescriptionTranslationCoordinator schedule (~31s cap). - val POLL_BACKOFF_SECONDS = listOf(1L, 2L, 3L, 5L, 5L, 5L, 5L, 5L) + // Apple DescriptionTranslationCoordinator's opening backoff, held at + // 5s to the web client's 45s budget: a season job also translates its + // episodes before the season's pending language clears. + val POLL_BACKOFF_SECONDS = listOf(1L, 2L, 3L, 4L, 5L, 5L, 5L, 5L, 5L, 5L, 5L) } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/auth/DeviceCodeFormat.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/auth/DeviceCodeFormat.kt new file mode 100644 index 0000000000..c0ad4c1637 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/auth/DeviceCodeFormat.kt @@ -0,0 +1,45 @@ +package org.prairieserver.prairie.model.auth + +/** + * How device sign-in codes are shown and read. The server issues eight digits + * (`4821-7730`); older servers issue eight letters and digits (`ABCD-EFGH`). + * Both are shown grouped 4+4 with a space and typed without separators; the + * server ignores spaces and dashes on lookup. + */ +object DeviceCodeFormat { + /** Maximum characters in a typed code, separators excluded. */ + const val LENGTH = 8 + + /** `4821-7730` → `4821 7730`. Anything that isn't eight characters is shown as sent, dashes as spaces. */ + fun display(userCode: String): String { + val compact = normalize(userCode) + return if (compact.length == LENGTH) { + compact.substring(0, 4) + " " + compact.substring(4) + } else { + userCode.trim().replace('-', ' ') + } + } + + /** Strips spaces and dashes and uppercases, the form the server compares. */ + fun normalize(input: String): String = + input.filter { it.isLetterOrDigit() }.uppercase() + + /** One character per word, so a screen reader reads `4 8 2 1 7 7 3 0`, not a number. */ + fun spoken(userCode: String): String = normalize(userCode).toList().joinToString(" ") + + /** + * The typed-URL form of the verification address: `prairie.example.com/activate`, + * without the scheme (an `http://` address keeps it, since the browser + * needs it). Falls back to the complete URI without its query. + */ + fun activateText(verificationUri: String, verificationUriComplete: String): String { + val raw = verificationUri.takeIf { it.isNotBlank() } + ?: verificationUriComplete.substringBefore('?') + val trimmed = raw.trim().trimEnd('/') + return if (trimmed.startsWith("https://", ignoreCase = true)) trimmed.substring("https://".length) else trimmed + } + + /** `prairie.example.com` from a verification address, for "Can't reach …" copy. */ + fun host(url: String): String = + url.trim().substringAfter("://").substringBefore('/').substringBefore('?') +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/auth/DeviceLoginModels.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/auth/DeviceLoginModels.kt index 4df0a158bc..e160202c94 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/auth/DeviceLoginModels.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/auth/DeviceLoginModels.kt @@ -57,6 +57,17 @@ data class DeviceLoginPollResponse( @SerialName("profile_token") val profileToken: String? = null, val temporary: Boolean? = null, @SerialName("session_expires_at") val sessionExpiresAt: String? = null, + /** + * A pending request an approver has looked up. Servers that predate the + * opened signal never send it, so absent reads as not opened. + */ + val opened: Boolean = false, + /** + * The request's current expiry on a pending answer. A lookup on the + * approving device extends it, so the device moves its local deadline to + * it rather than replacing a code someone is approving. + */ + @SerialName("expires_at") val expiresAt: String? = null, ) @Serializable @@ -68,14 +79,34 @@ data class DeviceLoginLookupResponse( @SerialName("device_platform") val devicePlatform: String? = null, @SerialName("ip_address_hint") val ipAddressHint: String? = null, @SerialName("expires_at") val expiresAt: String? = null, + /** When the TV asked, for the approval card's "just now". */ + @SerialName("requested_at") val requestedAt: String? = null, @SerialName("client_purpose") val clientPurpose: String? = null, val temporary: Boolean? = null, + /** Deployment identity of the issuing server, when it knows one. */ + @SerialName("server_id") val serverId: String? = null, + /** Display name of the issuing server, for the approval card. */ + @SerialName("server_name") val serverName: String? = null, ) +/** + * `GET /auth/device/capability`. [deviceLoginAvailable] is false when the + * server reports device sign-in is not configured; [cancel] and + * [openedSignal] report the two Phase 1 additions and read false on servers + * that predate them. + */ @Serializable data class DeviceLoginCapabilityResponse( @SerialName("remote_playback_handoff") val remotePlaybackHandoff: Boolean = false, @SerialName("protocol_versions") val protocolVersions: List<Int> = emptyList(), + val deviceLoginAvailable: Boolean = true, + val cancel: Boolean = false, + val openedSignal: Boolean = false, +) + +@Serializable +data class DeviceLoginCancelResponse( + val status: String, ) @Serializable @@ -90,7 +121,7 @@ data class DeviceLoginDecisionResponse( ) enum class DeviceLoginStatus { - Pending, Approved, Denied, Expired, Consumed, Unknown; + Pending, Approved, Denied, Expired, Consumed, Canceled, Unknown; companion object { fun fromWire(raw: String): DeviceLoginStatus = when (raw) { @@ -99,6 +130,7 @@ enum class DeviceLoginStatus { "denied" -> Denied "expired" -> Expired "consumed" -> Consumed + "canceled" -> Canceled else -> Unknown } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/auth/ExternalSignInModels.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/auth/ExternalSignInModels.kt new file mode 100644 index 0000000000..db3d4d4b5e --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/auth/ExternalSignInModels.kt @@ -0,0 +1,299 @@ +package org.prairieserver.prairie.model.auth + +/** + * One way to sign in that the server lists before login + * (`GET /api/v2/auth/providers`). See prairie-server `docs/auth-api.md` + * ("External sign-in") and `docs/architecture/external-sign-in.md`. + */ +data class SignInProvider( + /** The value `login` takes as `provider`; apps never send it (the server routes by account). */ + val id: String, + val displayName: String, + val mode: Mode, + val isDefault: Boolean, + /** Absolute icon URL, or null when the provider ships none. */ + val iconUrl: String?, + val installationId: String?, + /** + * `startNativeOAuthLogin` relative to the server base + * (`/api/v2/auth/oauth/{id}/native/start`); oauth providers only. Apps + * open it on their own saved base URL, never on the origin the server + * listed it with. + */ + val nativeStartPath: String?, + /** + * `signInWithNetworkIdentity` relative to the server base + * (`/api/v2/auth/network/{id}/sign-in`); network providers only. Apps + * POST `{}` to it on their own saved base URL, like [nativeStartPath]. + */ + val networkSignInPath: String? = null, + /** Who the network provider says owns this device; network providers only. It authorizes nothing. */ + val networkIdentity: NetworkIdentity? = null, +) { + /** + * How the provider signs people in: [Network] providers (such as the + * Tailscale plugin) are listed only to a request that came through their + * own network, and sign in the owner of the device with no password and + * no browser. + */ + enum class Mode { Credentials, OAuth, Network, Unknown } +} + +/** A network provider's `network_identity`: the person who owns the device that asked. */ +data class NetworkIdentity( + val displayName: String, + val username: String, +) { + /** The name for "Continue as": the display name, else the username; null when the provider gave neither. */ + val label: String? + get() = displayName.trim().ifEmpty { username.trim() }.ifEmpty { null } +} + +/** `listAuthProviders`: the providers plus whether any of them takes a password. */ +data class SignInProviders( + val providers: List<SignInProvider>, + val passwordLogin: Boolean, +) + +/** `getOAuthHandshakeCapabilities`, reduced to what the apps use. */ +data class OAuthHandshakeCapabilities( + val available: Boolean, + /** `startNativeOAuthLogin` serves apps (the `org.prairieserver.prairie:/auth/callback` handoff). */ + val native: Boolean, + /** Link tickets and `completeAccountIdentityLink` are served. */ + val linking: Boolean, + /** Sign-in starts take `prompt=select_account` ("Use a different account", "Not you? Switch account"). */ + val selectAccount: Boolean = false, +) { + companion object { + val None = OAuthHandshakeCapabilities(available = false, native = false, linking = false) + } +} + +/** `getExternalSignInCapabilities`, reduced to what the apps use. */ +data class ExternalSignInCapabilities( + /** `listAccountIdentities` and `deleteAccountIdentity` are served: Settings shows "Sign-in". */ + val identities: Boolean, + /** + * `linkAccountIdentityWithCredentials` is served: an account links a + * directory (LDAP) identity with its directory username and password. + * Directory linking needs no OAuth handshake, so this document reports it. + */ + val credentialsLinking: Boolean = false, + /** + * `signInWithNetworkIdentity` and `linkAccountIdentityWithNetwork` are + * served. Whether this device may use them is answered by provider + * discovery, which lists a network provider only through its own network. + */ + val networkSignIn: Boolean = false, + /** + * Linking a network identity keeps the account's local password sign-in. + * False when network sign-in is not served; servers without the field + * turn the password off on such a link, as for other providers. + */ + val networkLinkKeepsPassword: Boolean = false, +) + +/** + * What a sign-in screen offers for one server. + * + * - [oauthProviders]: a "Sign in with <name>" button each. Only providers the + * app can run natively: an oauth provider with a native start path, on a + * server whose handshake capability says `native`. + * - [showPasswordForm]: the username and password form. Shown when any listed + * provider takes a password (the local one, or a directory such as LDAP, + * which the server reaches without a provider field) and hidden only when + * the server says no provider does. + * - [networkProvider]: "Continue as <owner>" above everything else. The + * server lists a network provider only when this request came through that + * provider's network; it never changes what else is offered. + */ +data class SignInOptions( + val oauthProviders: List<SignInProvider>, + val showPasswordForm: Boolean, + /** A non-local credentials provider (LDAP) is enabled: its people use the password form too. */ + val directoryProvider: SignInProvider?, + /** A provider sign-in may ask the provider to offer another account (`prompt=select_account`). */ + val selectAccount: Boolean = false, + /** A network provider (such as Tailscale) that signs in this device's owner, with its sign-in path. */ + val networkProvider: SignInProvider? = null, +) { + companion object { + /** A server that predates provider discovery: the password form alone. */ + val PasswordOnly = SignInOptions(oauthProviders = emptyList(), showPasswordForm = true, directoryProvider = null) + + /** + * What [providers] offer through the app, given the server's OAuth + * [handshake]. The account's Sign-in settings build on the same + * filters (linking adds its own capability gates). + */ + fun of(providers: SignInProviders, handshake: OAuthHandshakeCapabilities): SignInOptions { + val oauth = if (handshake.available && handshake.native) { + providers.providers.filter { + it.mode == SignInProvider.Mode.OAuth && !it.nativeStartPath.isNullOrBlank() + } + } else { + emptyList() + } + val credentials = providers.providers.filter { it.mode == SignInProvider.Mode.Credentials } + return SignInOptions( + oauthProviders = oauth, + showPasswordForm = providers.passwordLogin || credentials.isNotEmpty(), + directoryProvider = credentials.firstOrNull { it.id != LOCAL_PROVIDER_ID }, + selectAccount = oauth.isNotEmpty() && handshake.selectAccount, + networkProvider = providers.providers.firstOrNull { + it.mode == SignInProvider.Mode.Network && !it.networkSignInPath.isNullOrBlank() + }, + ) + } + + const val LOCAL_PROVIDER_ID = "local" + } +} + +/** One external identity linked to the signed-in account (`listAccountIdentities`). */ +data class AccountIdentity( + val id: String, + val installationId: String, + /** Empty while that provider is not enabled. */ + val providerId: String, + /** Empty while that provider is not enabled. */ + val providerName: String, + val username: String, + val email: String, + val displayName: String, + /** RFC 3339 instant. */ + val linkedAt: String, + /** RFC 3339 instant, or null before the first sign-in through it. */ + val lastSignInAt: String?, +) { + /** The name to show for the person at the provider: username, then email, then display name. */ + val accountLabel: String + get() = username.ifBlank { email.ifBlank { displayName } } +} + +/** `listAccountIdentities`: the account's identities and whether one may be disconnected now. */ +data class AccountIdentities( + val items: List<AccountIdentity>, + /** + * Whether `deleteAccountIdentity` would disconnect an identity now (the + * account has another identity, or its local password still signs in). + * Null from a server that doesn't say; Disconnect is then offered and the + * server's refusal explains. + */ + val canUnlink: Boolean?, +) + +/** A single-use ticket that starts one linking flow (`createAccountIdentityLinkTicket`). */ +data class AccountIdentityLinkTicket( + val ticket: String, + val expiresAt: String, +) { + override fun toString(): String = "AccountIdentityLinkTicket(ticket=<redacted>, expiresAt=$expiresAt)" +} + +/** + * Why a password sign-in (`login`) was refused, from the problem code first + * and the status second (prairie-server `docs/auth-api.md`, "External sign-in": + * `local_login_disabled`, `not_permitted` and `password_expired` are 403, + * `provider_unavailable` 503). Phone and TV map each to their own copy. + */ +enum class PasswordLoginFailure { + InvalidCredentials, + + /** Password sign-in is off on this server and the account is not break-glass. */ + LocalLoginDisabled, + + /** The directory accepted the password but its group rules don't admit the account. */ + NotPermitted, + + /** The directory says the password has expired. */ + PasswordExpired, + AccountDisabled, + + /** The directory (LDAP) could not be reached. */ + ProviderUnavailable, + + /** A first directory sign-in found another account with the same email (409). */ + EmailInUse, + + /** The directory identity is linked to another account (409). */ + IdentityLinkedElsewhere, + + /** Too many attempts (429). */ + RateLimited, + + /** A first directory sign-in found no account and the server doesn't create one (`account_required`). */ + AccountRequired, + Other; + + companion object { + fun of(code: Int, problem: String): PasswordLoginFailure = when { + problem == "local_login_disabled" -> LocalLoginDisabled + problem == "not_permitted" -> NotPermitted + problem == "password_expired" -> PasswordExpired + problem == "provider_unavailable" -> ProviderUnavailable + problem == "email_in_use" -> EmailInUse + problem == "identity_linked_elsewhere" -> IdentityLinkedElsewhere + problem == "account_required" -> AccountRequired + problem == "rate_limited" || code == 429 -> RateLimited + code == 401 -> InvalidCredentials + code == 403 -> AccountDisabled + code == 503 -> ProviderUnavailable + else -> Other + } + } +} + +/** + * Why a network identity sign-in (`signInWithNetworkIdentity`) was refused, + * from the problem code first and the status second (prairie-server + * `docs/auth-api.md`, "External sign-in"). Phone and TV map each to their own copy. + */ +enum class NetworkSignInFailure { + /** + * The request didn't come through the provider's network + * (`network_identity_required`): the app uses another address for the + * server, or the device left that network. + */ + NetworkIdentityRequired, + + /** The provider refuses this device (`not_permitted`): a tagged device, or one its policy leaves out. */ + NotPermitted, + + /** No account matches and the server doesn't create one (`account_required`). */ + AccountRequired, + + /** The account is disabled (`permission_denied`). */ + AccountDisabled, + + /** A first sign-in found another account with the same email (409): link from that account instead. */ + EmailInUse, + + /** The network identity is linked to another account (409). */ + IdentityLinkedElsewhere, + + /** Not an enabled network provider any more (404). */ + NotFound, + ProviderUnavailable, + + /** Too many attempts (429). */ + RateLimited, + Other; + + companion object { + fun of(code: Int, problem: String): NetworkSignInFailure = when { + problem == "network_identity_required" -> NetworkIdentityRequired + problem == "not_permitted" -> NotPermitted + problem == "account_required" -> AccountRequired + problem == "permission_denied" -> AccountDisabled + problem == "email_in_use" -> EmailInUse + problem == "identity_linked_elsewhere" -> IdentityLinkedElsewhere + problem == "provider_unavailable" -> ProviderUnavailable + problem == "rate_limited" || code == 429 -> RateLimited + problem == "not_found" || code == 404 -> NotFound + code == 503 -> ProviderUnavailable + else -> Other + } + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/CatalogModels.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/CatalogModels.kt index 4134575347..d54a264430 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/CatalogModels.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/CatalogModels.kt @@ -40,6 +40,12 @@ data class OverlaySummary( @SerialName("multi_sub") val multiSub: Boolean? = null ) +/** A watched mutation clears resume progress, including optimistic unwatched resets. */ +fun BrowseItem.withWatched(watched: Boolean): BrowseItem = copy( + userState = (userState ?: MediaItemUserState()).copy(played = watched), + positionSeconds = 0.0, +) + @Serializable data class BrowseItem( @SerialName("content_id") val contentId: String, @@ -59,6 +65,8 @@ data class BrowseItem( val networks: List<String> = emptyList(), @SerialName("show_status") val showStatus: String? = null, val overview: String? = null, + @SerialName("poster_is_episode_still") val posterIsEpisodeStill: Boolean? = null, + @SerialName("backdrop_is_episode_still") val backdropIsEpisodeStill: Boolean? = null, @SerialName("poster_url") val posterUrl: String? = null, @SerialName("poster_thumbhash") val posterThumbhash: String? = null, @SerialName("backdrop_url") val backdropUrl: String? = null, @@ -67,7 +75,19 @@ data class BrowseItem( @SerialName("release_date") val releaseDate: String? = null, @SerialName("last_air_date") val lastAirDate: String? = null, @SerialName("user_state") val userState: MediaItemUserState? = null, - @SerialName("overlay_summary") val overlaySummary: OverlaySummary? = null + @SerialName("overlay_summary") val overlaySummary: OverlaySummary? = null, + @SerialName("series_id") val seriesId: String? = null, + @SerialName("series_title") val seriesTitle: String? = null, + @SerialName("season_number") val seasonNumber: Int? = null, + @SerialName("episode_number") val episodeNumber: Int? = null, + @SerialName("item_source") val itemSource: String? = null, + @SerialName("position_seconds") val positionSeconds: Double? = null, + @SerialName("duration_seconds") val durationSeconds: Double? = null, + @SerialName("progress_updated_at") val progressUpdatedAt: String? = null, + /** This profile's metadata language while the card's description is not available in it yet. */ + @SerialName("pending_translation_language") val pendingTranslationLanguage: String? = null, + /** Localized fields ("overview", "tagline") whose text was machine-translated by AI. */ + @SerialName("machine_translated_fields") val machineTranslatedFields: List<String> = emptyList(), ) @Serializable @@ -163,6 +183,8 @@ data class ItemDetail( // Non-null while a viewer-facing description translation job is queued or // running for this item; clears when the translated overview lands. @SerialName("pending_translation_language") val pendingTranslationLanguage: String? = null, + /** Localized fields ("overview", "tagline") whose text was machine-translated by AI. */ + @SerialName("machine_translated_fields") val machineTranslatedFields: List<String> = emptyList(), val tagline: String? = null, val runtime: Int = 0, @SerialName("content_rating") val contentRating: String? = null, @@ -171,6 +193,12 @@ data class ItemDetail( @SerialName("rating_tmdb") val ratingTmdb: Double? = null, @SerialName("rating_rt_critic") val ratingRtCritic: Int? = null, @SerialName("rating_rt_audience") val ratingRtAudience: Int? = null, + /** + * The server's title-page ratings row, in display order. `null` when an + * older server omits it; read it through [titleRatings]. + */ + @Serializable(with = DisplayRatingListSerializer::class) + val ratings: List<DisplayRating>? = null, @SerialName("imdb_id") val imdbId: String? = null, @SerialName("tmdb_id") val tmdbId: String? = null, @SerialName("tvdb_id") val tvdbId: String? = null, @@ -183,6 +211,8 @@ data class ItemDetail( @SerialName("release_date") val releaseDate: String? = null, @SerialName("first_air_date") val firstAirDate: String? = null, @SerialName("last_air_date") val lastAirDate: String? = null, + @SerialName("poster_is_episode_still") val posterIsEpisodeStill: Boolean? = null, + @SerialName("backdrop_is_episode_still") val backdropIsEpisodeStill: Boolean? = null, @SerialName("poster_url") val posterUrl: String? = null, @SerialName("poster_thumbhash") val posterThumbhash: String? = null, @SerialName("backdrop_url") val backdropUrl: String? = null, @@ -305,6 +335,10 @@ data class FileVersion( @SerialName("presentation_part_total") val presentationPartTotal: Int? = null, @SerialName("edition_raw") val editionRaw: String? = null, @SerialName("edition_key") val editionKey: String? = null, + val intro: TimeRange? = null, + val credits: TimeRange? = null, + val recap: TimeRange? = null, + val preview: TimeRange? = null, ) @Serializable @@ -429,7 +463,9 @@ data class Season( @SerialName("episode_count") val episodeCount: Int = 0, @SerialName("poster_url") val posterUrl: String? = null, @SerialName("poster_thumbhash") val posterThumbhash: String? = null, - @SerialName("user_data") val userData: SeasonUserData? = null + @SerialName("user_data") val userData: SeasonUserData? = null, + /** Localized fields ("overview") whose text was machine-translated by AI. */ + @SerialName("machine_translated_fields") val machineTranslatedFields: List<String> = emptyList(), ) @Serializable @@ -500,10 +536,18 @@ data class EpisodeListItem( @SerialName("imdb_id") val imdbId: String? = null, @SerialName("tmdb_id") val tmdbId: String? = null, @SerialName("tvdb_id") val tvdbId: String? = null, + @SerialName("still_is_episode_still") val stillIsEpisodeStill: Boolean? = null, @SerialName("still_url") val stillUrl: String? = null, @SerialName("still_thumbhash") val stillThumbhash: String? = null, @SerialName("user_data") val userData: LeafItemUserData? = null, - val files: List<EpisodeFile> = emptyList() + val files: List<EpisodeFile> = emptyList(), + /** + * This profile's metadata language while the episode's description is not + * available in it yet; translating the episode's season fills it. + */ + @SerialName("pending_translation_language") val pendingTranslationLanguage: String? = null, + /** Localized fields ("overview") whose text was machine-translated by AI. */ + @SerialName("machine_translated_fields") val machineTranslatedFields: List<String> = emptyList(), ) @Serializable @@ -570,6 +614,8 @@ data class WatchDetail( // Optional forward-compatible artwork. Current servers expose these on // ItemDetail rather than WatchDetail, so playback clients must fall back // to the full catalog detail when these fields are absent. + @SerialName("poster_is_episode_still") val posterIsEpisodeStill: Boolean? = null, + @SerialName("backdrop_is_episode_still") val backdropIsEpisodeStill: Boolean? = null, @SerialName("poster_url") val posterUrl: String? = null, @SerialName("poster_thumbhash") val posterThumbhash: String? = null, @SerialName("backdrop_url") val backdropUrl: String? = null, diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/ExternalRatings.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/ExternalRatings.kt new file mode 100644 index 0000000000..fb0b74a4f5 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/ExternalRatings.kt @@ -0,0 +1,128 @@ +package org.prairieserver.prairie.model.catalog + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonDecoder +import kotlin.math.abs +import kotlin.math.roundToLong + +/** + * One external rating as a title page shows it: the source key (`imdb`, + * `tmdb`, `rt_critic`, or one a plugin adds later), the plain-text mark + * (`IMDb`, `RT Audience`), a 0-100 score, and the value already formatted on + * the source's own scale (`8.5`, `93%`). Show [display] as it comes. The + * client never reads [score], so an entry without one still shows. + */ +@Serializable +data class DisplayRating( + val source: String, + val name: String, + val score: Double? = null, + val display: String, +) + +/** + * Decodes a `ratings` list entry by entry, so one malformed entry (a plugin + * source without `display`, a blank name) is dropped instead of failing the + * whole item detail. + */ +internal object DisplayRatingListSerializer : KSerializer<List<DisplayRating>> { + private val strict = ListSerializer(DisplayRating.serializer()) + + override val descriptor = strict.descriptor + + override fun deserialize(decoder: Decoder): List<DisplayRating> { + val jsonDecoder = decoder as? JsonDecoder ?: return strict.deserialize(decoder) + val entries = jsonDecoder.decodeJsonElement() as? JsonArray ?: return emptyList() + return entries.mapNotNull { entry -> + try { + jsonDecoder.json.decodeFromJsonElement(DisplayRating.serializer(), entry) + .takeIf { it.name.isNotBlank() && it.display.isNotBlank() } + } catch (_: SerializationException) { + null + } catch (_: IllegalArgumentException) { + null + } + } + } + + override fun serialize(encoder: Encoder, value: List<DisplayRating>) = + strict.serialize(encoder, value) +} + +/** + * The external ratings a title page or hero shows, shared by the phone and TV + * apps so both read the same list. + */ +object ExternalRatings { + const val SOURCE_IMDB = "imdb" + const val SOURCE_TMDB = "tmdb" + + /** + * The most ratings a phone-width title page shows. Web and iOS use the + * same limit. The server already sends at most three, so this only guards + * against an older server that sends more. Every layout (phone, tablet, + * fold, TV) shows ratings on one line and drops whole entries from the + * end when they do not fit. + */ + const val PHONE_LIMIT = 3 + + /** + * The ratings row for a title page. A current server sends [ratings] + * (empty when there is nothing to show), already chosen and formatted: + * it is returned untouched. An older server omits the member (`null`), so + * the row falls back to IMDb then TMDB from the raw out-of-ten scores. + * Never Rotten Tomatoes: item detail carries raw RT values for metadata + * editors even when an administrator has not chosen to show them. + */ + fun forTitle( + ratings: List<DisplayRating>?, + ratingImdb: Double?, + ratingTmdb: Double?, + ): List<DisplayRating> = ratings ?: listOfNotNull(imdb(ratingImdb), tmdb(ratingTmdb)) + + /** + * The ratings a phone-width title page shows: the first [PHONE_LIMIT] + * of [ratings], in the server's order. Wider layouts pass the server's + * list as is. + */ + fun forPhone(ratings: List<DisplayRating>): List<DisplayRating> = ratings.take(PHONE_LIMIT) + + /** The one rating a hero shows from card fields: IMDb, else TMDB. */ + fun primary(ratingImdb: Double?, ratingTmdb: Double?): DisplayRating? = + imdb(ratingImdb) ?: tmdb(ratingTmdb) + + fun imdb(value: Double?): DisplayRating? = outOfTen(SOURCE_IMDB, "IMDb", value) + + fun tmdb(value: Double?): DisplayRating? = outOfTen(SOURCE_TMDB, "TMDB", value) + + /** + * One decimal with a `.` separator whatever the device locale (`8.5`, + * never `8,5`), rounding half up like the web client. + */ + fun formatOneDecimal(value: Double): String { + val tenths = (value * 10.0).roundToLong() + val sign = if (tenths < 0) "-" else "" + val magnitude = abs(tenths) + return "$sign${magnitude / 10}.${magnitude % 10}" + } + + private fun outOfTen(source: String, name: String, value: Double?): DisplayRating? { + if (value == null || !value.isFinite() || value <= 0.0 || value > 10.0) return null + return DisplayRating( + source = source, + name = name, + score = value * 10.0, + display = formatOneDecimal(value), + ) + } +} + +/** This title's ratings row; see [ExternalRatings.forTitle]. */ +fun ItemDetail.titleRatings(): List<DisplayRating> = + ExternalRatings.forTitle(ratings, ratingImdb, ratingTmdb) diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/ItemTypes.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/ItemTypes.kt index d1c26cf0f9..0cc6a0ae59 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/ItemTypes.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/ItemTypes.kt @@ -22,5 +22,35 @@ fun isAudiobookItemType(type: String?): Boolean = fun isEpisodeItemType(type: String?): Boolean = normalizedItemType(type) == "episode" +private val tvItemTypes = setOf( + "series", + "season", + "episode", +) + +fun isTvItemType(type: String?): Boolean = + normalizedItemType(type) in tvItemTypes + fun isBookLikeItemType(type: String?): Boolean = normalizedItemType(type) in bookLikeItemTypes + +/** + * Reading types for display only (the missing-artwork mark), which also + * accepts the plurals some servers send. Routing stays on + * [isBookLikeItemType], which takes the singular catalog types. + */ +private val bookDisplayTypes = bookLikeItemTypes + setOf( + "ebooks", + "comics", +) + +fun isBookDisplayType(type: String?): Boolean = + normalizedItemType(type) in bookDisplayTypes + +private val podcastItemTypes = setOf( + "podcast", + "podcasts", +) + +fun isPodcastItemType(type: String?): Boolean = + normalizedItemType(type) in podcastItemTypes diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/MachineTranslation.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/MachineTranslation.kt new file mode 100644 index 0000000000..46287a573f --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/MachineTranslation.kt @@ -0,0 +1,43 @@ +package org.prairieserver.prairie.model.catalog + +/** + * Whether the overview a surface shows was machine-translated (the server's + * `machine_translated_fields` on a detail, season, episode or card). Use this + * wherever the "Translated by AI" mark sits with the overview alone: the + * server can report `["tagline"]` for a provider overview with an AI tagline. + */ +fun hasMachineTranslatedOverview(fields: List<String>?): Boolean = + fields.orEmpty().contains(MACHINE_TRANSLATED_OVERVIEW) + +const val MACHINE_TRANSLATED_OVERVIEW = "overview" + +/** + * The language a season's episode rows are still missing, if any. A season's + * translation job covers its episodes, so one pending row is enough to start it. + */ +fun List<EpisodeListItem>.pendingEpisodeTranslationLanguage(): String? = + firstNotNullOfOrNull { episode -> episode.pendingTranslationLanguage?.takeIf { it.isNotBlank() } } + +/** Refresh description metadata without replacing playback or personal state. */ +fun ItemDetail.withEpisodeDescription(row: EpisodeListItem): ItemDetail { + if (row.contentId != contentId) return this + // A detail read can finish after translation while the rail still holds + // a pending snapshot. Keep that completed detail until the rail catches up. + if (pendingTranslationLanguage.isNullOrBlank() && !row.pendingTranslationLanguage.isNullOrBlank()) return this + if (row.overview.isNullOrBlank()) return copy(pendingTranslationLanguage = row.pendingTranslationLanguage) + return copy( + overview = row.overview, + machineTranslatedFields = row.machineTranslatedFields, + pendingTranslationLanguage = row.pendingTranslationLanguage, + ) +} + +/** A translation poll owns description fields; the current row owns user state. */ +fun EpisodeListItem.withTranslatedDescription(row: EpisodeListItem): EpisodeListItem { + if (row.contentId != contentId) return this + return copy( + overview = row.overview, + machineTranslatedFields = row.machineTranslatedFields, + pendingTranslationLanguage = row.pendingTranslationLanguage, + ) +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/download/DownloadSidecar.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/download/DownloadSidecar.kt index fa26cdced4..ffedbf9b7d 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/download/DownloadSidecar.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/download/DownloadSidecar.kt @@ -1,6 +1,7 @@ package org.prairieserver.prairie.model.download import org.prairieserver.prairie.model.catalog.VersionChapter +import org.prairieserver.prairie.model.catalog.LeafItemUserData import kotlinx.serialization.Serializable /** @@ -29,6 +30,9 @@ data class DownloadSidecar( val subtitle: String? = null, val posterUrl: String? = null, val posterThumbhash: String? = null, + /** Null on older downloads; episode artwork is protected conservatively. */ + val posterIsEpisodeStill: Boolean? = null, + val episodeUserData: LeafItemUserData? = null, val year: Int? = null, val seriesTitle: String? = null, /** Stable parent series/show content id — the rename-safe grouping key for the @@ -72,6 +76,15 @@ data class DownloadSidecar( * it, captured from the offline manifest when the download completed. Null * for downloads completed before this existed (legacy offline playback). */ val offlineTracks: OfflineTrackInfo? = null, + /** Absolute path of the item's own poster (an episode's image for episodes), + * saved from the offline manifest when the download completed. Null when + * none was saved, including downloads completed before this existed. */ + val offlinePosterPath: String? = null, + /** Absolute path of the parent series poster saved with an episode download. + * Null for movies and for servers whose manifest does not list it. */ + val offlineSeriesPosterPath: String? = null, + /** ThumbHash of the parent series poster, from the episode's offline manifest. */ + val seriesPosterThumbhash: String? = null, /** Wall-clock millis when the sidecar was last written. Diagnostic only; * helps debug stale-file scenarios via `ls -la`. */ val updatedAtMs: Long, diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/download/OfflineArtwork.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/download/OfflineArtwork.kt new file mode 100644 index 0000000000..961107c6f2 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/download/OfflineArtwork.kt @@ -0,0 +1,68 @@ +package org.prairieserver.prairie.model.download + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** + * The artwork part of `GET /api/v2/downloads/{id}/manifest`: the proxy URLs to + * fetch once while online and the inline thumbhashes. + * + * For an episode entry `poster` is the episode's own image and + * `series_poster` is the parent series poster. The `series_poster` fields exist + * only on servers that name the series poster in episode manifests; older + * servers and movie manifests omit them. + */ +@Serializable +data class OfflineManifestArtwork( + @SerialName("poster_thumbhash") val posterThumbhash: String? = null, + @SerialName("series_poster_thumbhash") val seriesPosterThumbhash: String? = null, + @SerialName("artwork_urls") val artworkUrls: OfflineManifestArtworkUrls = OfflineManifestArtworkUrls(), +) + +@Serializable +data class OfflineManifestArtworkUrls( + val poster: String? = null, + @SerialName("series_poster") val seriesPoster: String? = null, +) + +/** Artwork saved beside a download, plus the thumbhashes the manifest listed. */ +data class OfflineArtworkFiles( + /** Absolute path of the saved `poster` image, or null when none was saved. */ + val posterPath: String? = null, + /** Absolute path of the saved `series_poster` image, or null when none was saved. */ + val seriesPosterPath: String? = null, + val posterThumbhash: String? = null, + val seriesPosterThumbhash: String? = null, +) + +private val offlineArtworkJson = Json { + ignoreUnknownKeys = true + coerceInputValues = true + isLenient = true +} + +/** Decodes the manifest's artwork subset; null for a body that is not a manifest object. */ +fun decodeOfflineManifestArtwork(body: String): OfflineManifestArtwork? = + runCatching { offlineArtworkJson.decodeFromString(OfflineManifestArtwork.serializer(), body) }.getOrNull() + +/** Artwork kinds saved with a download, as named by the manifest and the proxy route. */ +const val OFFLINE_ARTWORK_POSTER = "poster" +const val OFFLINE_ARTWORK_SERIES_POSTER = "series_poster" + +/** + * Only the same-server managed-download artwork proxy path for [kind] + * (`/api/v2/downloads/{id}/artwork/{kind}`) is fetched. The request carries the + * download owner's credentials, so an absolute or foreign reference in a + * manifest must never be followed. + */ +fun isOfflineArtworkFetchUrl(url: String, kind: String): Boolean { + val trimmed = url.trim() + if (!trimmed.startsWith(OFFLINE_ARTWORK_DOWNLOADS_PREFIX)) return false + if (trimmed.contains("://") || trimmed.contains("..") || trimmed.contains('\\')) return false + if (trimmed.contains('?') || trimmed.contains('#')) return false + val segments = trimmed.substring(OFFLINE_ARTWORK_DOWNLOADS_PREFIX.length).split('/') + return segments.size == 3 && segments[0].isNotEmpty() && segments[1] == "artwork" && segments[2] == kind +} + +private const val OFFLINE_ARTWORK_DOWNLOADS_PREFIX = "/api/v2/downloads/" diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/download/OfflineTracks.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/download/OfflineTracks.kt index 292a84aea6..920893402d 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/download/OfflineTracks.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/download/OfflineTracks.kt @@ -4,6 +4,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import org.prairieserver.prairie.model.catalog.AudioTrack +import org.prairieserver.prairie.playback.orNullIfBlank /** * The part of `GET /api/v2/downloads/{id}/manifest` the offline player needs: @@ -30,6 +31,14 @@ data class OfflineManifestSubtitle( val forced: Boolean = false, @SerialName("hearing_impaired") val hearingImpaired: Boolean = false, @SerialName("fetch_url") val fetchUrl: String = "", + /** + * Opaque revision of a stored (`downloaded:{id}`) or external + * (`external:{index}`) subtitle. It changes whenever the bytes [fetchUrl] + * serves can change: the server retimed the subtitle (a sync, or a timing + * set or reset), or an external file was edited on disk. Absent for + * embedded tracks and for an external file the server cannot read. + */ + val revision: String? = null, ) /** @@ -75,8 +84,47 @@ data class OfflineSubtitleFile( val title: String? = null, val forced: Boolean = false, val hearingImpaired: Boolean = false, + /** The manifest `fetch_url` the file came from; null on captures older than this field. */ + val fetchUrl: String? = null, + /** The manifest `revision` of the fetched bytes, when the manifest gave one. */ + val revision: String? = null, ) +/** + * Saved sidecars whose subtitle the [manifest] lists at a different revision, + * paired with that revision. Captures that predate [OfflineSubtitleFile.fetchUrl] + * cannot be matched to a manifest row and are never refreshed. An + * `external:{index}` ref names a sidecar by its position, which shifts when the + * files next to the media change, so such a row is refreshed only while it + * still describes the saved file. Two sidecars alike in every field the + * manifest lists can still trade places unnoticed: it has no stable identity + * for them. + */ +fun OfflineTrackInfo.subtitlesWithNewRevision( + manifest: OfflineManifestTracks, +): List<Pair<OfflineSubtitleFile, String>> { + val listed = manifest.subtitles + .filter { it.revision != null } + .associateBy { it.fetchUrl.trim() } + return subtitles.mapNotNull { saved -> + val row = saved.fetchUrl?.let(listed::get) ?: return@mapNotNull null + val revision = row.revision ?: return@mapNotNull null + if (row.isPositionalRef() && !row.describes(saved)) return@mapNotNull null + if (revision == saved.revision) null else saved to revision + } +} + +private fun OfflineManifestSubtitle.isPositionalRef(): Boolean = + fetchUrl.trim().substringAfterLast('/').startsWith("external:") + +/** Whether this row lists the same language, title, format, and flags [saved] was captured with. */ +private fun OfflineManifestSubtitle.describes(saved: OfflineSubtitleFile): Boolean = + language.orNullIfBlank().equals(saved.language, ignoreCase = true) && + title.orNullIfBlank() == saved.title && + offlineSubtitleFormat(format) == saved.format && + forced == saved.forced && + hearingImpaired == saved.hearingImpaired + private val offlineManifestJson = Json { ignoreUnknownKeys = true coerceInputValues = true diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/feature/ClientSurfacePolicy.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/feature/ClientSurfacePolicy.kt index 5fc61e86b9..55871949d1 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/feature/ClientSurfacePolicy.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/feature/ClientSurfacePolicy.kt @@ -1,8 +1,13 @@ package org.prairieserver.prairie.model.feature +import kotlinx.coroutines.flow.StateFlow + /** - * Client-side exposure switches for implemented surfaces that are not ready to - * appear in normal user navigation yet. Routes, repositories, and deep-link - * plumbing can remain compiled while menus/actions stay hidden. + * Whether Watch Party entry points may appear. The platform injects it from + * the device-local Settings → Experimental toggle, which defaults on in debug + * builds and off in release builds. The server's capabilities still decide + * whether the feature works once shown. */ -const val CLIENT_WATCH_TOGETHER_SURFACE_ENABLED: Boolean = false +interface WatchPartyExposure { + val enabled: StateFlow<Boolean> +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/feature/RequestsFeatureStore.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/feature/RequestsFeatureStore.kt index 5619241a2f..dd90762371 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/feature/RequestsFeatureStore.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/feature/RequestsFeatureStore.kt @@ -12,7 +12,15 @@ import org.prairieserver.prairie.repository.RequestsRepository * Matches the Apple clients: entry points start hidden, a successful * `/api/v2/requests/status` probe controls visibility, transient failures keep * the previous value, and reset hides the surface before a server/profile switch - * can reuse stale capability state. + * can reuse stale capability state. Reset also clears [RequestsRepository]'s + * session state, since both shells call it on sign-in, sign-out, and every + * server or profile switch. The feature counts only when the server + * says this profile is `allowed` and the state is `available`. + * + * [canModerate] says whether the signed-in user can approve, decline, and retry + * everyone's requests: an admin acting as the primary profile on a server with + * the request integration configured. It is probed only once requests are + * enabled, and is false on any answer other than `available: true`. */ class RequestsFeatureStore( private val repository: RequestsRepository, @@ -20,6 +28,13 @@ class RequestsFeatureStore( private val _isEnabled = MutableStateFlow(false) val isEnabled: StateFlow<Boolean> = _isEnabled.asStateFlow() + private val _canModerate = MutableStateFlow(false) + val canModerate: StateFlow<Boolean> = _canModerate.asStateFlow() + + private val _isResolved = MutableStateFlow(false) + /** A probe has answered (or failed) since the last [reset]; until then [isEnabled] is only a default. */ + val isResolved: StateFlow<Boolean> = _isResolved.asStateFlow() + // Written on the UI dispatcher (reset) and read after IO resumption // (refresh); Volatile gives the stale-response guard a happens-before. @kotlin.concurrent.Volatile @@ -27,20 +42,39 @@ class RequestsFeatureStore( suspend fun refresh() { val refreshGeneration = generation - when (val result = repository.status()) { - is ApiResult.Success -> { - if (refreshGeneration == generation) { - _isEnabled.value = result.data.requestsEnabled - } - } + val enabled = when (val result = repository.status()) { + is ApiResult.Success -> result.data.isAvailable + // A transient failure shouldn't yank an already-visible entry point. is ApiResult.Error, is ApiResult.NetworkError, - -> Unit + -> _isEnabled.value + } + if (refreshGeneration != generation) return + val moderates = if (!enabled) { + false + } else { + when (val result = repository.adminCapabilities()) { + is ApiResult.Success -> result.data.available + // The server answered: not an admin, not the primary profile, + // or no integration configured. + is ApiResult.Error -> false + // Transport trouble: keep the previous value. + is ApiResult.NetworkError -> _canModerate.value + } } + if (refreshGeneration != generation) return + // Moderation first: a screen that appears when Requests does already + // knows whether to load the approval queue. + _canModerate.value = moderates + _isEnabled.value = enabled + _isResolved.value = true } fun reset() { generation += 1 + repository.reset() _isEnabled.value = false + _canModerate.value = false + _isResolved.value = false } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/feature/ShuffleFeatureStore.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/feature/ShuffleFeatureStore.kt new file mode 100644 index 0000000000..85a317ea64 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/feature/ShuffleFeatureStore.kt @@ -0,0 +1,87 @@ +package org.prairieserver.prairie.model.feature + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.prairieserver.prairie.model.shuffle.Shuffle +import org.prairieserver.prairie.model.shuffle.ShuffleCapability +import org.prairieserver.prairie.model.shuffle.ShuffleScopeKind +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.apiv2.ShufflesV2Api + +/** + * Server-gated Shuffle capability, and the one place entry points start a + * shuffle from. + * + * Entry points start hidden; a successful `/api/v2/shuffles/capabilities` + * probe controls visibility per scope kind, transient failures keep the + * previous value, and reset hides the surface before a server or profile + * switch can reuse stale state. A server without shuffles answers `404`, + * which hides it too. + */ +class ShuffleFeatureStore( + private val api: ShufflesV2Api, +) { + private val _capability = MutableStateFlow(ShuffleCapability()) + val capability: StateFlow<ShuffleCapability> = _capability.asStateFlow() + + // Written on the UI dispatcher (reset) and read after IO resumption + // (refresh); Volatile gives the stale-response guard a happens-before. + @kotlin.concurrent.Volatile + private var generation: Int = 0 + + suspend fun refresh() { + val refreshGeneration = generation + val next = when (val result = api.capability()) { + is ApiResult.Success -> result.data + // The server answered: an older server has no shuffles. + is ApiResult.Error -> if (result.code == 404) ShuffleCapability() else return + is ApiResult.NetworkError -> return + } + if (refreshGeneration == generation) _capability.value = next + } + + fun reset() { + generation += 1 + _capability.value = ShuffleCapability() + started = null + } + + // The shuffle the last start returned, until its player takes it. The + // player then knows the first pick's `next` without another read. + @kotlin.concurrent.Volatile + private var started: Shuffle? = null + + /** Hands the player the shuffle [start] returned for [shuffleId], once. */ + fun takeStarted(shuffleId: String): Shuffle? = + started?.takeIf { it.id == shuffleId }?.also { started = null } + + /** The playback state of shuffle [shuffleId], seeded with what [start] returned. */ + fun playback(shuffleId: String): org.prairieserver.prairie.playback.ShufflePlayback = + org.prairieserver.prairie.playback.ShufflePlayback(api, shuffleId, takeStarted(shuffleId)) + + /** + * Starts a shuffle over a scope. On success play `current` from the + * beginning; on failure show [ShuffleStart.Failed.message]. + */ + suspend fun start(kind: ShuffleScopeKind, id: String): ShuffleStart = + when (val result = api.create(kind, id)) { + is ApiResult.Success -> { + started = result.data + ShuffleStart.Started(result.data) + } + is ApiResult.Error -> + ShuffleStart.Failed(if (result.code == 409) NOTHING_PLAYABLE else START_FAILED) + is ApiResult.NetworkError -> ShuffleStart.Failed(START_FAILED) + } + + companion object { + const val NOTHING_PLAYABLE = "Nothing here can be played." + const val START_FAILED = "Couldn't start shuffle." + } +} + +sealed interface ShuffleStart { + data class Started(val shuffle: Shuffle) : ShuffleStart + data class Failed(val message: String) : ShuffleStart +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/AutoSubtitleResolver.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/AutoSubtitleResolver.kt index 013079741f..2c79a40836 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/AutoSubtitleResolver.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/AutoSubtitleResolver.kt @@ -2,6 +2,8 @@ package org.prairieserver.prairie.model.playback import org.prairieserver.prairie.model.catalog.SubtitleTrack import org.prairieserver.prairie.playback.isBitmapSubtitleCodecFamily +import org.prairieserver.prairie.playback.isClientMountableBitmapCodecFamily +import org.prairieserver.prairie.playback.playbackSubtitleIdentity import org.prairieserver.prairie.playback.subtitleLabelIndicatesHearingImpaired /** @@ -16,10 +18,11 @@ import org.prairieserver.prairie.playback.subtitleLabelIndicatesHearingImpaired * * The DETAIL PAGE's semantics are the reference behaviour — its ordering and * cascade are what the viewer sees and what QA signed off (tvOS parity, QA - * 2026-07-09). Candidates are supplied in the caller's own iteration order and - * carry a [AutoSubtitleCandidate.selectionIndex] in the server's COMBINED - * selection space, so the winner can be handed straight to a playback start - * request. + * 2026-07-09). Candidates carry a [AutoSubtitleCandidate.selectionIndex] in the + * server's COMBINED selection space, so the winner can be handed straight to a + * playback start request, and their own [AutoSubtitleCandidate.source], so the + * caller's iteration order (catalog: embedded first; inventory: externals + * first) never decides between two otherwise equal tracks. */ data class AutoSubtitleCandidate( /** @@ -40,8 +43,19 @@ data class AutoSubtitleCandidate( * it, because the catalog only ever says SDH in the title. */ val hearingImpaired: Boolean = false, + /** Where the track lives. `null` (unknown) ranks with embedded, as on the web player. */ + val source: AutoSubtitleSource? = null, + /** + * Whether showing this track makes the server burn it into the picture for + * THIS device. `null` when the caller cannot tell, which falls back to + * treating every bitmap track as a burn-in. + */ + val needsBurnIn: Boolean? = null, ) +/** Track provenance, in auto-selection preference order. */ +enum class AutoSubtitleSource { EMBEDDED, EXTERNAL, DOWNLOADED } + /** Cascaded preference inputs. Same shape on every surface. */ data class AutoSubtitleContext( /** Cascaded `subtitle_language`. `null` = no preference; empty = "no subs". */ @@ -78,9 +92,9 @@ fun AutoSubtitleResolution.selectedCandidate(): AutoSubtitleCandidate? = * subs are enabled; otherwise the best track in the preferred language, falling * back to any forced track when forced subs are enabled. * - * Within a pool: full-dialogue text → non-forced text → any text → first. - * Bitmap tracks stay DEPRIORITISED, never excluded: a bitmap track that is the - * only candidate still wins. + * Within a pool, [autoSubtitlePreferenceOrder] ranks the tracks; a track that + * needs a burn-in is DEPRIORITISED, never excluded, so it still wins when it is + * the only candidate. * * "Show forced subtitles" is a SEPARATE setting and never outranks the * viewer's full-subtitle preference: when subtitles are wanted (mode `always`, @@ -125,10 +139,32 @@ fun resolveAutoSubtitle( } val target = bestAutoSubtitleCandidate(candidates, targetLanguage) - ?: if (context.showForced) candidates.firstOrNull { it.forced } else null + ?: if (context.showForced) { + candidates.filter { it.forced }.minWithOrNull(autoSubtitlePreferenceOrder) + } else { + null + } return target?.let(AutoSubtitleResolution::Select) ?: AutoSubtitleResolution.NoChange } +/** + * Preference order between tracks that already match the wanted language. + * Each tier only breaks ties in the one before it (server/web parity, + * prairie-server #1849): + * 1. a track this device renders itself beats one the server must burn in; + * 2. full dialogue beats forced, and plain beats SDH, so a file's own forced + * or SDH track never displaces the full track the viewer asked for; + * 3. embedded beats external beats downloaded. External sidecars are the ones + * that drift out of sync. + * Use with [minWithOrNull], which keeps the caller's order for full ties. + */ +val autoSubtitlePreferenceOrder: Comparator<AutoSubtitleCandidate> = compareBy( + { it.requiresBurnIn() }, + { it.forced }, + { it.isHearingImpaired() }, + { it.source?.ordinal ?: 0 }, +) + private fun bestAutoSubtitleCandidate( candidates: List<AutoSubtitleCandidate>, targetLanguage: String?, @@ -138,34 +174,29 @@ private fun bestAutoSubtitleCandidate( } else { candidates.filter { autoSubtitleLanguageKey(it.language) == targetLanguage } } - if (pool.isEmpty()) return null - - pool.firstOrNull { !it.forced && !it.isHearingImpaired() && !it.isBitmap() }?.let { return it } - pool.firstOrNull { !it.forced && !it.isBitmap() }?.let { return it } - pool.firstOrNull { !it.isBitmap() }?.let { return it } - return pool.first() + return pool.minWithOrNull(autoSubtitlePreferenceOrder) } private fun bestForcedAutoSubtitleCandidate( candidates: List<AutoSubtitleCandidate>, targetLanguage: String?, -): AutoSubtitleCandidate? { - val pool = candidates +): AutoSubtitleCandidate? = + candidates .filter { targetLanguage == null || autoSubtitleLanguageKey(it.language) == targetLanguage } .filter { it.forced } - if (pool.isEmpty()) return null - - pool.firstOrNull { !it.isHearingImpaired() && !it.isBitmap() }?.let { return it } - pool.firstOrNull { !it.isHearingImpaired() }?.let { return it } - return pool.first() -} + .minWithOrNull(autoSubtitlePreferenceOrder) /** The ONE SDH predicate: an explicit signal, or the track's own title. */ fun AutoSubtitleCandidate.isHearingImpaired(): Boolean = hearingImpaired || subtitleLabelIndicatesHearingImpaired(title) -/** The ONE bitmap predicate (PGS / VobSub / DVB / HDMV aliases). */ -private fun AutoSubtitleCandidate.isBitmap(): Boolean = isBitmapSubtitleCodecFamily(codec) +/** + * Whether this track ranks as a burn-in: the caller's answer for this device, + * or, when it has none, the bitmap predicate (PGS / VobSub / DVB / HDMV + * aliases). + */ +private fun AutoSubtitleCandidate.requiresBurnIn(): Boolean = + needsBurnIn ?: isBitmapSubtitleCodecFamily(codec) /** * The ONE ISO-639 folding table for auto-selection language comparison. @@ -198,6 +229,11 @@ fun autoSubtitleLanguageKey(language: String?): String? { * Candidates over the CATALOG subtitle list, in catalog order, addressed in * combined selection space — the inventory the detail page previews and the * one a playback start request can act on. + * + * Burn-in follows the server's delivery rule for the capabilities Android + * declares on every delivery (embedded and sidecar bitmap): text and an + * embedded PGS track reach the device as-is, while VobSub, DVB and any + * external bitmap file have no client route and are burned in. */ fun catalogAutoSubtitleCandidates( catalogTracks: List<SubtitleTrack>, @@ -210,6 +246,9 @@ fun catalogAutoSubtitleCandidates( codec = track.codec, title = track.title, forced = track.forced, + source = if (track.external) AutoSubtitleSource.EXTERNAL else AutoSubtitleSource.EMBEDDED, + needsBurnIn = isBitmapSubtitleCodecFamily(track.codec) && + (track.external || !isClientMountableBitmapCodecFamily(track.codec)), ) } } @@ -222,12 +261,30 @@ fun catalogAutoSubtitleCandidates( */ fun inventoryAutoSubtitleCandidates( rows: List<PlayerSubtitleInfo>, -): List<AutoSubtitleCandidate> = rows.map { row -> +): List<AutoSubtitleCandidate> = rows.map(PlayerSubtitleInfo::toAutoSubtitleCandidate) + +/** + * One session subtitle row as a resolver candidate. Burn-in comes from the + * row's own playback identity, which reads the server's `burn_in_only` + * delivery and otherwise applies the same rule as [catalogAutoSubtitleCandidates]. + */ +fun PlayerSubtitleInfo.toAutoSubtitleCandidate(): AutoSubtitleCandidate = AutoSubtitleCandidate( - selectionIndex = row.index, - language = row.language, - codec = row.codec, - title = row.catalogLabel ?: row.label, - forced = row.forced == true, + selectionIndex = index, + language = language, + codec = codec, + title = catalogLabel ?: label, + forced = forced == true, + source = autoSubtitleSource(), + needsBurnIn = playbackSubtitleIdentity(this) is SubtitleIdentity.ServerBurnIn, ) + +private fun PlayerSubtitleInfo.autoSubtitleSource(): AutoSubtitleSource? { + if (isLocalDownloadedSubtitle()) return AutoSubtitleSource.DOWNLOADED + return when ((catalogSource ?: source)?.trim()?.lowercase()) { + "embedded" -> AutoSubtitleSource.EMBEDDED + "external" -> AutoSubtitleSource.EXTERNAL + SUBTITLE_SOURCE_DOWNLOADED -> AutoSubtitleSource.DOWNLOADED + else -> null + } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/PlaybackModels.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/PlaybackModels.kt index 5118684227..7a66986ebe 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/PlaybackModels.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/PlaybackModels.kt @@ -72,6 +72,8 @@ data class PlayerSubtitleInfo( @SerialName("server_delivery") val serverDelivery: String? = null, /** Exact original-container track ID selected by a v3 embedded decision. */ @SerialName("native_container_track_id") val nativeContainerTrackId: String? = null, + /** The inventory's `sync_key`: present when the server can sync this track to the audio. */ + @SerialName("sync_key") val syncKey: String? = null, ) /** @@ -247,6 +249,11 @@ data class PlaybackExecutionPlan( @SerialName("decision_trace") val decisionTrace: List<String> = emptyList(), @SerialName("requested_media_file_id") val requestedMediaFileId: Int? = null, @SerialName("effective_media_file_id") val effectiveMediaFileId: Int? = null, + /** + * What the plan delivers (resolution, bitrate). The Quality menu reads it + * to mark the entry a stored resolution preference actually landed on. + */ + @SerialName("effective_recipe") val effectiveRecipe: PlaybackEffectiveRecipeV3? = null, ) /** @@ -469,9 +476,3 @@ data class DeliverySubtitleCapabilities( @SerialName("sidecar_bitmap") val sidecarBitmap: Boolean = false, @SerialName("font_attachments") val fontAttachments: Boolean = false, ) - -@Serializable -data class ProgressRequest( - val position: Double, - @SerialName("is_paused") val isPaused: Boolean -) diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/PlaybackProtocolV3.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/PlaybackProtocolV3.kt index da13a61261..ace8077521 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/PlaybackProtocolV3.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/PlaybackProtocolV3.kt @@ -378,6 +378,11 @@ data class PlaybackSubtitleInventoryItemV3( val delivery: String = "", val url: String? = null, @SerialName("font_bundle_url") val fontBundleUrl: String? = null, + /** + * Opaque name of this track for the subtitle sync operations, present on + * external and downloaded tracks whose timing the server can correct. + */ + @SerialName("sync_key") val syncKey: String? = null, ) /** Resolves the optional ordinal from the stable server track identity. */ @@ -429,6 +434,13 @@ data class PlaybackStartRequestV3( @SerialName("bandwidth_cap_kbps") val bandwidthCapKbps: Int? = null, @SerialName("client_capabilities") val capabilities: ClientCodecCapabilities, @SerialName("client_playback_context") val clientPlaybackContext: ClientPlaybackContext, + /** + * False keeps this attempt on [fileId] through every replan: the server + * refuses rather than substitutes another version. Watch Party starts send + * false so every viewer stays on the room's file. Null omits the field and + * keeps the server default for solo playback. + */ + @SerialName("allow_alternate_versions") val allowAlternateVersions: Boolean? = null, ) @Serializable @@ -498,6 +510,36 @@ sealed interface PlaybackV3Validation { data class ReplanRequired(val reason: String, val plan: PlaybackPlanV3, val sessionId: String) : PlaybackV3Validation } +/** + * Subtitle codecs, by source container, that Android selects natively from an + * original-HTTP stream by `container_track_id`. MP4 `mov_text` maps to the + * `tkhd` track ID and MKV SubRip/ASS to the Matroska TrackNumber, both of which + * Media3 sets as `Format.id`. The capability detector advertises these pairs + * and [validateForMedia3] accepts them, so the two share one container/codec + * list. Both also assume every entry is identified by `container_track_id`: + * only add a pair whose Media3 track ID is the container's own track ID. + */ +val NATIVE_EMBEDDED_SUBTITLE_CODECS: Map<String, List<String>> = mapOf( + "mp4" to listOf("mov_text"), + "mkv" to listOf("subrip", "ass"), +) + +/** + * Normalizes a subtitle codec name the way the server does when it matches a + * native capability, so a plan it issues for an alias is not rejected here. + */ +private fun nativeEmbeddedSubtitleCodec(codec: String?): String? = + when (val normalized = codec?.trim()?.lowercase()) { + "srt" -> "subrip" + "tx3g" -> "mov_text" + else -> normalized + } + +private fun isNativeEmbeddedSubtitlePair(container: String?, codec: String?): Boolean { + val codecs = NATIVE_EMBEDDED_SUBTITLE_CODECS[container?.trim()?.lowercase()] ?: return false + return nativeEmbeddedSubtitleCodec(codec) in codecs +} + fun PlaybackDecisionResponseV3.validateForMedia3(): PlaybackV3Validation { if (protocolVersion != PLAYBACK_PROTOCOL_V3 || PLAYBACK_PLAN_V3_FEATURE !in serverFeatures || @@ -564,8 +606,8 @@ fun PlaybackDecisionResponseV3.validateForMedia3(): PlaybackV3Validation { if (plan.delivery != PlaybackDelivery.ORIGINAL_HTTP || plan.subtitle.mode != PlaybackSubtitleModeV3.RENDER || plan.subtitle.artifact != null || native.streamIndex < 0 || nativeId == null || nativeId !in 1..Int.MAX_VALUE.toLong() || native.containerTrackId != nativeId.toString() || - selectedSubtitle?.source != "embedded" || selectedSubtitle.codec != "mov_text" || - plan.source.container?.lowercase() !in setOf("mp4", "mov", "m4v") || + selectedSubtitle?.source != "embedded" || + !isNativeEmbeddedSubtitlePair(plan.source.container, selectedSubtitle.codec) || plan.subtitle.trackId != plan.selectedTracks.subtitle?.id ) return PlaybackV3Validation.ReplanRequired("subtitle_embedded_failed", plan, resolvedSessionId) } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/PlaybackQualityMenu.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/PlaybackQualityMenu.kt new file mode 100644 index 0000000000..6ca3bf6625 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/PlaybackQualityMenu.kt @@ -0,0 +1,120 @@ +package org.prairieserver.prairie.model.playback + +/** The quality preference that hands the choice back to the server's planner. */ +const val PLAYBACK_QUALITY_AUTO = "auto" + +/** + * One row of the player's Quality menu. [id] is the `quality_preference` a + * pick sends back verbatim: a plan entry's own label, or + * [PLAYBACK_QUALITY_AUTO]. + */ +data class PlaybackQualityOption( + val id: String, + val name: String, + /** The entry's bitrate as the row's secondary text, or null when unknown. */ + val bitrateLabel: String? = null, + val height: Int = 0, + val bitrateKbps: Int = 0, + val preservesSource: Boolean = false, +) + +/** + * The plan's Quality menu: Auto, then every entry of + * [PlaybackPlanV3.availableQualities] in server order. The server owns the + * entries and the client never derives or filters them. Auto is added here + * because it is a preference, not an entry. A plan with a single entry still + * gets Auto + that entry, as the Apple clients show; only a plan with no + * entries (no plan at all) gets no menu. + */ +fun playbackQualityMenu(available: List<PlaybackAvailableQualityV3>): List<PlaybackQualityOption> { + val entries = available.map { quality -> + PlaybackQualityOption( + id = quality.label, + name = playbackQualityName(quality), + bitrateLabel = formatPlaybackQualityBitrate(quality.bitrateKbps), + height = quality.height, + bitrateKbps = quality.bitrateKbps, + preservesSource = quality.preservesSource, + ) + } + if (entries.isEmpty()) return entries + return listOf(PlaybackQualityOption(id = PLAYBACK_QUALITY_AUTO, name = "Auto")) + entries +} + +/** The entry's display name, else "Original" for the source entry, else its label. */ +fun playbackQualityName(quality: PlaybackAvailableQualityV3): String = + quality.displayName?.trim()?.takeIf { it.isNotEmpty() } + ?: if (quality.preservesSource) "Original" else quality.label + +/** + * A bitrate in Mbps to one decimal, dropping a zero decimal: "40 Mbps", + * "1.5 Mbps". Below 1 Mbps it stays in kbps. Null when the bitrate is unknown. + */ +fun formatPlaybackQualityBitrate(kbps: Int): String? { + if (kbps <= 0) return null + if (kbps < 1_000) return "$kbps kbps" + val tenths = (kbps + 50) / 100 + val fraction = tenths % 10 + return if (fraction == 0) "${tenths / 10} Mbps" else "${tenths / 10}.$fraction Mbps" +} + +/** + * The id of the [options] row that [preference] selects, or null when none + * does. A missing preference is Auto. When the plan has a sole server entry, + * any other preference selects it, since it is what plays. A stored + * resolution-only preference such as "1080p" resolves the way the planner + * applies it: to the source entry when the source fits under it and no + * bitrate cap reduced it ([delivered] shows that), otherwise to that + * resolution class's Medium entry. + */ +fun activePlaybackQualityId( + options: List<PlaybackQualityOption>, + preference: String?, + delivered: PlaybackEffectiveRecipeV3? = null, +): String? { + val normalized = preference?.trim()?.lowercase()?.takeIf { it.isNotEmpty() } ?: PLAYBACK_QUALITY_AUTO + options.firstOrNull { it.id.lowercase() == normalized }?.let { return it.id } + options.singleOrNull { it.id != PLAYBACK_QUALITY_AUTO }?.let { return it.id } + + val original = options.firstOrNull { it.preservesSource } + val aliasHeight = qualityPreferenceHeight(normalized) + if (aliasHeight == null) { + return if (normalized == "source" || normalized == "max") original?.id else null + } + + val deliveredKbps = delivered?.bitrateKbps?.takeIf { it > 0 } + val capped = deliveredKbps != null && deliveredKbps < (original?.bitrateKbps ?: 0) + if (original != null && !capped && original.height <= aliasHeight) return original.id + + val classHeight = ladderClassHeight(delivered?.width, delivered?.height) ?: aliasHeight + val rungId = when (classHeight) { + 480 -> "480p" + // A low bitrate cap can choose the 540p class, which has no menu entry. + 540 -> null + else -> "${classHeight}p-medium" + } + return options.firstOrNull { it.id == rungId }?.id +} + +private fun qualityPreferenceHeight(preference: String): Int? = when (preference) { + "2160p", "4k", "uhd" -> 2160 + "1080p", "fhd" -> 1080 + "720p", "hd" -> 720 + "480p", "sd" -> 480 + else -> null +} + +/** The server's bitrate ladder classes as (width, height) boxes, smallest first. */ +private val LADDER_CLASS_BOXES = listOf(854 to 480, 960 to 540, 1280 to 720, 1920 to 1080, 3840 to 2160) + +/** + * The ladder class an encoded frame belongs to: the smallest box that holds + * it, so a 1280x688 encode is 720p. A frame larger than every box is 2160p. + */ +private fun ladderClassHeight(width: Int?, height: Int?): Int? { + if (height == null || height <= 0) return null + val knownWidth = width?.takeIf { it > 0 } + return LADDER_CLASS_BOXES.firstOrNull { (boxWidth, boxHeight) -> + height <= boxHeight && (knownWidth == null || knownWidth <= boxWidth) + }?.second ?: 2160 +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/SubtitleTrackMerge.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/SubtitleTrackMerge.kt index 6c3a2c213b..e0656dc740 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/SubtitleTrackMerge.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/SubtitleTrackMerge.kt @@ -2,6 +2,7 @@ package org.prairieserver.prairie.model.playback import org.prairieserver.prairie.model.subtitles.DownloadedSubtitle +import org.prairieserver.prairie.model.subtitles.SubtitleSyncState /** `source` value the server (and web client) use for provider-downloaded tracks. */ const val SUBTITLE_SOURCE_DOWNLOADED = "downloaded" @@ -53,12 +54,19 @@ fun mergeDownloadedSubtitles( forced = null, url = "/stream/$sessionId/subtitles/$index${subtitleUrlExtension(dl.format)}", downloadId = dl.id, + // The server names a stored subtitle `stored-{id}` for sync, and + // syncs only the text formats it can retime. + syncKey = SubtitleSyncState.storedKey(dl.id).takeIf { isRetimableSubtitleFormat(dl.format) }, ) } return base + newTracks } +private val retimableSubtitleFormats = setOf("srt", "subrip", "vtt", "webvtt", "ass", "ssa") + +private fun isRetimableSubtitleFormat(format: String): Boolean = format.trim().lowercase() in retimableSubtitleFormats + private fun subtitleUrlExtension(format: String): String { return when (format.trim().lowercase()) { "ass", "ssa" -> ".ass" diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/profile/ProfileModels.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/profile/ProfileModels.kt index 3fc4dfaff4..747880af13 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/profile/ProfileModels.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/profile/ProfileModels.kt @@ -123,3 +123,10 @@ data class VerifyPinResponse( */ fun VerifyPinResponse.authorizedProfileToken(): String? = profileToken?.takeIf { valid && it.isNotBlank() } + +/** + * The household's primary profile, the only one the server lets manage the + * other profiles. A one-profile household is its own primary, as on Apple. + */ +fun List<Profile>.householdPrimary(): Profile? = + firstOrNull { it.isPrimary } ?: singleOrNull() diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/request/RequestDisplayState.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/request/RequestDisplayState.kt new file mode 100644 index 0000000000..f8062cf856 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/request/RequestDisplayState.kt @@ -0,0 +1,328 @@ +package org.prairieserver.prairie.model.request + +/** + * The five user-facing request states, shared by every request surface on + * phone and TV (and matching the Apple clients' `RequestDisplayState`), so a + * card, a list row, the detail page, and My Requests bucketing can never + * disagree about what a given server state looks like. + */ +sealed interface RequestDisplayState { + /** Submitted, awaiting approval. Cancelable by the requester. */ + data object Pending : RequestDisplayState + + /** + * Approved, queued, or downloading, including a finished download the + * library hasn't picked up yet. + */ + data object OnTheWay : RequestDisplayState + + /** The title is in the library (or, from a server without `state`, the request completed). */ + data object InLibrary : RequestDisplayState + + /** Declined or failed; the detail page says which, and why. */ + data class NeedsAttention(val attention: RequestAttention, val reason: String?) : RequestDisplayState + + /** + * Not requestable and no request exists (limit reached, requests off, + * blocked), or the request was cancelled. The request affordance simply + * doesn't render. + */ + data class Unavailable(val reason: String?) : RequestDisplayState + + val label: String + get() = when (this) { + Pending -> "Pending" + OnTheWay -> "On the way" + InLibrary -> "In library" + is NeedsAttention -> "Needs attention" + is Unavailable -> "Unavailable" + } + + val tint: RequestStatusTint + get() = when (this) { + Pending -> RequestStatusTint.Amber + OnTheWay -> RequestStatusTint.Sky + InLibrary -> RequestStatusTint.Emerald + is NeedsAttention -> RequestStatusTint.Rose + is Unavailable -> RequestStatusTint.Neutral + } + + /** The owner may cancel only while the request waits for approval. */ + val isCancelable: Boolean + get() = this == Pending + + /** The detail page's status line, which has room to say more than a caption. */ + val detailTitle: String + get() = when (this) { + Pending -> "Requested · Pending" + OnTheWay -> "On the way" + InLibrary -> "In your library" + is NeedsAttention -> requestReasonCopy(reason)?.let { "${attention.title} · $it" } ?: attention.title + is Unavailable -> requestReasonCopy(reason) ?: "Unavailable" + } + + /** + * The catalog item to open instead of the request, when there is nothing + * about the request left to show: only a state that reads "In library", + * with a known item. A title in the library that still has an active + * request (missing seasons, a failure) opens the request, as its status says. + */ + fun libraryItemToOpen(contentId: String?): String? = + contentId?.takeIf { this == InLibrary && it.isNotBlank() } + + companion object { + /** + * From a full request record: My Requests, the hub strip, admin rows. + * A decline or withdrawal explains itself in `outcome_reason`; a failure + * in `last_error` (sent to admins only). + */ + fun of(record: MediaRequest): RequestDisplayState { + val closedByDecision = record.state == RequestUserState.Declined || record.state == RequestUserState.Cancelled || + (record.state == null && (record.outcome == RequestOutcome.Declined || record.outcome == RequestOutcome.Cancelled)) + val reason = if (closedByDecision) record.outcomeReason else record.lastError.ifBlank { record.outcomeReason } + return of(state = record.state, status = record.status, outcome = record.outcome, reason = reason) + } + + /** The server's `state` decides when present and recognized; `status` and `outcome` otherwise. */ + fun of(state: String?, status: String, outcome: String, reason: String? = null): RequestDisplayState = + fromUserState(state, reason) ?: fromStatus(status, outcome, reason) + + /** + * From a search/discover/detail card's compact annotation. Null when the + * card has no state to show (requestable, never requested): that's the + * Request affordance, not a status. + * + * An active request's `state` comes before availability: a series in the + * library can have a request for its missing seasons, and the card must + * agree with My Requests about it. `request.reason` says why the title + * can't be requested, not why a request failed, so only a title with no + * request shows it. + */ + fun of(availability: String, request: RequestState): RequestDisplayState? { + fromUserState(request.state, reason = null)?.let { return it } + if (availability == RequestAvailability.Available) return InLibrary + request.status?.takeIf { it.isNotBlank() }?.let { + return fromStatus(it, RequestOutcome.Active, reason = null) + } + if (request.requestable) return null + return Unavailable(request.reason.takeIf { it.isNotBlank() }) + } + + /** Null for an absent or unknown state, so a newer server's state falls back to `status`. */ + private fun fromUserState(state: String?, reason: String?): RequestDisplayState? = when (state) { + RequestUserState.Pending -> Pending + // Some requested seasons are in, the rest are still coming: + // "In library" would file it as landed before it has. + RequestUserState.Approved, RequestUserState.Processing, RequestUserState.PartiallyAvailable -> OnTheWay + RequestUserState.Available -> InLibrary + RequestUserState.Declined -> NeedsAttention(RequestAttention.Declined, reason.nonBlank()) + RequestUserState.Failed -> NeedsAttention(RequestAttention.Failed, reason.nonBlank()) + RequestUserState.Cancelled -> Unavailable(reason.nonBlank()) + else -> null + } + + /** + * The mapping for servers without `state`, which can't tell a finished + * download from a title in the library: `completed` reads as in library. + * `outcome` wins over `status` for terminal states. + */ + private fun fromStatus(status: String, outcome: String, reason: String?): RequestDisplayState { + when (outcome) { + RequestOutcome.Declined -> return NeedsAttention(RequestAttention.Declined, reason.nonBlank()) + RequestOutcome.Failed -> return NeedsAttention(RequestAttention.Failed, reason.nonBlank()) + RequestOutcome.Cancelled -> return Unavailable(reason.nonBlank()) + } + return when (status) { + RequestStatus.Pending -> Pending + RequestStatus.Completed -> InLibrary + RequestStatus.Failed -> NeedsAttention(RequestAttention.Failed, reason.nonBlank()) + // An unknown status reads as in flight rather than broken: a newer + // server's added pipeline stage shouldn't read as an error. + else -> OnTheWay + } + } + + private fun String?.nonBlank(): String? = this?.takeIf { it.isNotBlank() } + } +} + +/** Why a request needs attention. A declined request and a failed one need different things from the user. */ +enum class RequestAttention(val title: String) { + Declined("Declined"), + Failed("Request failed"), +} + +/** The status color; the UI layer maps each to its theme color. */ +enum class RequestStatusTint { Amber, Sky, Emerald, Rose, Neutral } + +/** The four steps every request walks through, drawn as the stage track. */ +enum class RequestStep(val title: String) { + Requested("Requested"), + Approval("Approved"), + Download("Downloading"), + Library("In your library"), +} + +/** + * Where a request sits on the stage track, plus the finer labels the five + * display states can't express ("Queued" vs "Downloading" vs "Adding to + * library"). Derived from [RequestDisplayState], so the track, the dot color, + * and My Requests bucketing always agree. + */ +data class RequestProgress( + val display: RequestDisplayState, + /** Steps fully behind the request (0..4). Drawn solid. */ + val completedSteps: Int, + /** The step the request is on, drawn in [tint]. Null once every step is done, or off the track. */ + val currentStep: RequestStep?, + /** One or two words for card captions. */ + val shortLabel: String, + /** A plain-language phrase for list rows and the TV preview panel. */ + val longLabel: String, +) { + val tint: RequestStatusTint get() = display.tint + + companion object { + /** From a full request record. */ + fun of(record: MediaRequest): RequestProgress = + of(RequestDisplayState.of(record), state = record.state, status = record.status) + + /** From a card's compact annotation; null when there is no state to show. */ + fun of(availability: String, request: RequestState): RequestProgress? = + RequestDisplayState.of(availability, request)?.let { of(it, state = request.state, status = request.status) } + + /** The display state decides the bucket and color; `state` and `status` only place an in-flight request. */ + fun of(display: RequestDisplayState, state: String?, status: String?): RequestProgress = when (display) { + RequestDisplayState.Pending -> + RequestProgress(display, 1, RequestStep.Approval, "Pending", "Waiting for approval") + RequestDisplayState.OnTheWay -> { + val (step, short, long) = inFlightPhase(state, status) + RequestProgress(display, step.ordinal, step, short, long) + } + RequestDisplayState.InLibrary -> + RequestProgress(display, RequestStep.entries.size, null, "In library", "In your library") + is RequestDisplayState.NeedsAttention -> when (display.attention) { + RequestAttention.Declined -> RequestProgress(display, 1, RequestStep.Approval, "Declined", "Declined") + RequestAttention.Failed -> RequestProgress(display, 2, RequestStep.Download, "Failed", "Request failed") + } + is RequestDisplayState.Unavailable -> + RequestProgress(display, 0, null, display.label, display.detailTitle) + } + + /** + * Where an approved-but-unfinished request is. `partially_available` and + * a finished download the library hasn't picked up yet are both on the + * last step; everything else is queued or downloading. + */ + private fun inFlightPhase(state: String?, status: String?): Triple<RequestStep, String, String> { + if (state == RequestUserState.PartiallyAvailable) { + return Triple(RequestStep.Library, "Partly in library", "Partly in your library") + } + return when (status) { + RequestStatus.Downloading -> Triple(RequestStep.Download, "Downloading", "Downloading") + RequestStatus.Completed -> Triple(RequestStep.Library, "Adding to library", "Adding to your library") + RequestStatus.Approved, RequestStatus.Queued -> Triple(RequestStep.Download, "Queued", "Queued for download") + else -> Triple(RequestStep.Download, "On the way", "On the way") + } + } + } +} + +/** + * The My Requests sections (and filters), derived from [RequestDisplayState]. + * Requests that need the user sit above finished ones. Cancelled requests drop + * off the list. + */ +enum class MyRequestsBucket(val title: String) { + InMotion("In progress"), + NeedsAttention("Needs you"), + Landed("Available"), + ; + + companion object { + fun of(state: RequestDisplayState): MyRequestsBucket? = when (state) { + RequestDisplayState.Pending, RequestDisplayState.OnTheWay -> InMotion + RequestDisplayState.InLibrary -> Landed + is RequestDisplayState.NeedsAttention -> NeedsAttention + is RequestDisplayState.Unavailable -> null + } + + /** Ordered, non-empty buckets, newest first within each. */ + fun bucket(requests: List<MediaRequest>): List<Pair<MyRequestsBucket, List<MediaRequest>>> { + val grouped = requests.groupBy { of(RequestDisplayState.of(it)) } + return entries.mapNotNull { bucket -> + grouped[bucket]?.takeIf { it.isNotEmpty() }?.let { bucket to it.sortedByDescending { r -> r.createdAt.requestInstantKey() } } + } + } + } +} + +/** Per-quality target copy. */ +object RequestTargetSummary { + /** + * "1080p downloading · 4K queued" for a request fanned out to more than + * one quality. Null for single-target requests, whose one state is the + * request's own. + */ + fun text(targets: List<RequestTarget>): String? { + if (targets.size <= 1) return null + val parts = targets.mapNotNull { target -> + val quality = target.quality.takeIf { it.isNotBlank() } ?: return@mapNotNull null + val word = when (target.status) { + RequestStatus.Completed -> "done" + RequestStatus.Downloading -> "downloading" + RequestStatus.Queued, RequestStatus.Approved -> "queued" + RequestStatus.Pending -> "pending" + RequestStatus.Failed -> "failed" + else -> return@mapNotNull null + } + "$quality $word" + } + return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ") + } + + /** The requested qualities, for rows that aren't in flight: "1080p · 4K". */ + fun qualities(targets: List<RequestTarget>): String? = + targets.map { it.quality }.filter { it.isNotBlank() }.takeIf { it.isNotEmpty() }?.joinToString(" · ") +} + +/** + * Copy for a request reason: a server token (`quota_exceeded`, …) becomes a + * phrase; readable text from the server (a `last_error` sentence) shows as + * written; an unknown code shows nothing rather than a raw code. + */ +fun requestReasonCopy(token: String?): String? { + val value = token?.trim()?.takeIf { it.isNotEmpty() } ?: return null + return when (value) { + RequestReason.AlreadyRequested -> "Already requested" + RequestReason.AlreadyAvailable -> "Already in your library" + RequestReason.QuotaExceeded, "limit_reached" -> "Request limit reached" + RequestReason.RequestsDisabled -> "Requests are turned off" + RequestReason.Blocked, "requesting_blocked" -> "You can't request media right now" + "validation_failed" -> "That request couldn't be submitted" + "invalid_state" -> "This request can no longer be changed" + "not_found" -> "This title is no longer available" + RequestUnconfirmedToken -> "Not confirmed yet" + // Server sentences can arrive lowercase ("one or more fulfillment + // targets failed"); they read as their own line. + else -> value.takeUnless { RequestReasonCodePattern.matches(it) }?.replaceFirstChar { it.uppercase() } + } +} + +/** Local reason for a create whose outcome is not known yet; shown on the held primary action. */ +const val RequestUnconfirmedToken = "request_unconfirmed" + +private val RequestReasonCodePattern = Regex("[a-z0-9_]+") + +/** + * A sort key for a server timestamp: RFC 3339 instants compare as text once the + * fraction is padded, so sorting needs no date library. Unparseable values sort + * as written. + */ +internal fun String.requestInstantKey(): String { + val match = RequestInstantPattern.matchEntire(this) ?: return this + val (head, fraction, zone) = match.destructured + return if (zone == "Z") head + "." + fraction.padEnd(9, '0') + "Z" else this +} + +private val RequestInstantPattern = Regex("""(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:\d{2})""") diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/request/RequestModels.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/request/RequestModels.kt index 6a87b46e6b..b5948b8749 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/request/RequestModels.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/request/RequestModels.kt @@ -29,7 +29,7 @@ object RequestOutcome { /** * Values of [RequestState.reason]: why a title cannot be requested. They are - * codes for deciding what to offer, not text; show [reasonMessage] instead. + * codes for deciding what to offer, not text; show [requestReasonCopy] instead. */ object RequestReason { const val AlreadyRequested = "already_requested" @@ -44,9 +44,30 @@ object RequestAvailability { const val Available = "available" } +/** + * Values of the server's `state`: the one state to show a user for a request, + * derived from its status, outcome, servers and library presence. It says what + * `status` and `outcome` cannot: a finished download the library has not + * picked up yet is `processing`, not `available`. Older servers omit it, and a + * value this client does not know falls back to `status` and `outcome`. + */ +object RequestUserState { + const val Pending = "pending" + const val Approved = "approved" + const val Processing = "processing" + /** Some of a season request's seasons are in the library, not all. */ + const val PartiallyAvailable = "partially_available" + const val Available = "available" + const val Declined = "declined" + const val Cancelled = "cancelled" + const val Failed = "failed" +} + @Serializable data class RequestState( val status: String? = null, + /** The active request's user-facing [RequestUserState]; absent on older servers. */ + val state: String? = null, val requestable: Boolean = false, val reason: String = "", @SerialName("request_id") val requestId: String? = null, @@ -181,6 +202,8 @@ data class MediaRequest( @SerialName("backdrop_path") val backdropPath: String? = null, val status: String, val outcome: String, + /** What to show the user ([RequestUserState]); absent on older servers. */ + val state: String? = null, @SerialName("requested_by_user_id") val requestedByUserId: String? = null, @SerialName("requested_by_profile_id") val requestedByProfileId: String = "", @SerialName("integration_kind") val integrationKind: String = "", @@ -189,7 +212,10 @@ data class MediaRequest( @SerialName("external_id") val externalId: String = "", @SerialName("external_status") val externalStatus: String = "", @SerialName("library_content_id") val libraryContentId: String? = null, + /** Admins only: why the last submission to a download server failed. */ @SerialName("last_error") val lastError: String = "", + /** Why the request was declined or withdrawn, when a reason was given. */ + @SerialName("outcome_reason") val outcomeReason: String = "", @SerialName("created_at") val createdAt: String, @SerialName("updated_at") val updatedAt: String, @SerialName("approved_at") val approvedAt: String? = null, @@ -204,7 +230,35 @@ data class RequestsListResponse( @kotlinx.serialization.Required val page: org.prairieserver.prairie.network.apiv2.PageInfo = org.prairieserver.prairie.network.apiv2.PageInfo(hasMore = false), ) +/** + * `GET /api/v2/requests/status`. The contract requires `allowed` and `state`; + * they decode as optional so a response without them fails closed. + */ @Serializable data class RequestsFeatureStatus( @SerialName("requests_enabled") val requestsEnabled: Boolean, + val allowed: Boolean? = null, + val state: String? = null, +) { + /** The feature is on and this profile may use it; a blocked account sees `allowed: false`. */ + val isAvailable: Boolean + get() = requestsEnabled && allowed == true && state == "available" +} + +/** `GET /api/v2/admin/requests/capabilities`: whether the request integration is configured. */ +@Serializable +data class AdminRequestCapabilities( + val available: Boolean = false, +) + +/** Moderation actions an admin can take on someone's request (`POST /api/v2/admin/requests/{id}/…`). */ +enum class AdminRequestAction(val path: String) { + Approve("approve"), + Decline("decline"), + Retry("retry"), +} + +@Serializable +data class AdminRequestActionBody( + val reason: String? = null, ) diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/request/RequestMutations.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/request/RequestMutations.kt new file mode 100644 index 0000000000..3a753405db --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/request/RequestMutations.kt @@ -0,0 +1,131 @@ +package org.prairieserver.prairie.model.request + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeMark +import kotlin.time.TimeSource +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.errorMessage + +/** + * Sorts a failed create, cancel, or moderation into the two outcomes the UI + * treats differently. All of them are `non_retryable`: the server keeps no + * request identity, so a resend after an uncertain outcome can act twice. + * + * - **Definite:** the server answered with an error. Show it; the user may try again. + * - **Uncertain:** the request may have reached the server but no usable answer + * came back. Never resend; hold the action until a fresh read shows what happened. + */ +object RequestMutationFailure { + fun isUncertain(result: ApiResult<*>): Boolean = when (result) { + is ApiResult.Success -> false + // A received answer this client couldn't read: the server acted. A + // gateway error without the server's own problem body came from a + // proxy, which may have forwarded the call before giving up on it. + is ApiResult.Error -> (result.code == 0 && result.error == "invalid_response") || + (result.code in GatewayErrors && result.error.isEmpty()) + // Refused before a byte left the device, or never connected: nothing happened. + is ApiResult.NetworkError -> generateSequence(result.exception) { it.cause?.takeIf { cause -> cause !== it } } + .none { it::class.simpleName in NeverConnected } + } + + private val GatewayErrors = setOf(502, 503, 504) + + private val NeverConnected = setOf( + "UnknownHostException", + "ConnectException", + "ConnectTimeoutException", + "NoRouteToHostException", + "UnresolvedAddressException", + ) +} + +/** Copy for request mutations. */ +object RequestActionCopy { + const val UnconfirmedSubmit = "We couldn't confirm the request. Check My Requests before trying again." + const val UnconfirmedCancel = "We couldn't confirm the cancellation. Refresh your requests to check it." + const val UnconfirmedModeration = "We couldn't confirm that decision. Refresh to see where the request stands." + + /** + * Copy for a definite failure. v2 folds "already requested", "already + * available", and "invalid state" into one `conflict` problem, and the quota + * message carries the counts, so the problem's detail is the most specific + * copy there is. + */ + fun failure(result: ApiResult<*>, fallback: String): String = when { + result is ApiResult.Error && result.error == "validation_failed" -> "That request couldn't be submitted" + result is ApiResult.Error && result.error in CapabilityOff -> "Requests are turned off" + else -> result.errorMessage(fallback) + } + + private val CapabilityOff = setOf("capability_disabled", "capability_not_configured", "capability_unsupported") +} + +/** + * A cancel, approve, decline, or retry sent without a usable answer. A timed-out + * call can still be running on the server when the next read comes back, so an + * unchanged request proves nothing: the hold lasts until the request changes or + * leaves the list. It lapses after [Lifetime], read or not, so a call that never + * arrived doesn't lock the row forever. Releasing it can't double an action: the + * server applies each one only from the state it expects. + */ +class RequestActionHold( + val requestId: String, + val updatedAt: String, + private val since: TimeMark, + private val lifetime: Duration = Lifetime, +) { + constructor(request: MediaRequest, timeSource: TimeSource = TimeSource.Monotonic, lifetime: Duration = Lifetime) : + this(request.id, request.updatedAt, timeSource.markNow(), lifetime) + + /** Whether a complete read ([current] is the request's entry in it, or null when gone) shows what the held call did. */ + fun isSettled(current: MediaRequest?): Boolean { + if (current == null || current.id != requestId) return true + return current.updatedAt != updatedAt || since.elapsedNow() >= lifetime + } + + companion object { + val Lifetime: Duration = 60.seconds + } +} + +/** Where one row's admin action is, so the row can animate it. */ +sealed interface RequestRowActionPhase { + val action: AdminRequestAction + + /** The button spins. */ + data class Working(override val action: AdminRequestAction) : RequestRowActionPhase + + /** The button shows its result, then the row leaves the list. */ + data class Succeeded(override val action: AdminRequestAction) : RequestRowActionPhase +} + +/** + * The compact card state implied by a full request record, so every visible + * card flips its status after a create or cancel without a refetch. Active + * requests block re-requesting; cancelled ones re-open the affordance. + */ +fun MediaRequest.toRequestState(): RequestState = when (outcome) { + RequestOutcome.Cancelled -> RequestState(requestable = true) + // Re-requesting is allowed after a decline or failure, but the state stays + // visible so the card explains itself. + RequestOutcome.Declined, RequestOutcome.Failed -> RequestState( + status = status, + state = state, + requestable = true, + requestId = id, + ) + else -> RequestState( + status = status, + state = state, + requestable = false, + requestId = id, + ) +} + +/** This result with the request state implied by [record], when the record is for the same title. */ +fun RequestMediaResult.applying(record: MediaRequest): RequestMediaResult = + if (record.mediaType == mediaType && record.tmdbId == tmdbId) copy(request = record.toRequestState()) else this + +fun List<RequestMediaResult>.applyingRequestUpdate(record: MediaRequest): List<RequestMediaResult> = + map { it.applying(record) } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/request/RequestPresentation.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/request/RequestPresentation.kt index 4e452bda6b..a9a2fdd69c 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/request/RequestPresentation.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/request/RequestPresentation.kt @@ -1,9 +1,9 @@ package org.prairieserver.prairie.model.request /** - * Presentation policy shared by the mobile and TV request surfaces: - * TMDB image URL building, badge/chip status precedence, status - * labelling, cancellability, and the per-target summary line. + * Presentation policy shared by the mobile and TV request surfaces: TMDB + * image URL building and card routing. Status copy lives with + * [RequestDisplayState] and [RequestProgress]. */ fun requestPosterUrl(path: String?): String? = requestImageUrl(path, "w500") @@ -20,61 +20,14 @@ private fun requestImageUrl(path: String?, size: String): String? { } /** - * Raw status token for a discover/search card badge, in precedence - * order: in-library beats request status beats requestability beats - * the server-provided reason. Render with [requestDisplayLabel]. + * The one routing rule for request cards: a card whose status reads "In + * library" opens the real item; everything else opens the request detail, so + * the user can always see state and reason. That includes a title in the + * library with an active request, such as a series with missing seasons coming. */ -fun RequestMediaResult.badgeStatus(): String = when { - availability == RequestAvailability.Available -> RequestAvailability.Available - request.status?.isNotBlank() == true -> request.status.orEmpty() - request.requestable -> "request" - request.reason.isNotBlank() -> request.reason - else -> RequestAvailability.Missing -} - -/** Human label for a request status/outcome/availability/media-type token. */ -fun String.requestDisplayLabel(): String = when (lowercase()) { - RequestMediaType.Movie -> "Movie" - RequestMediaType.Series -> "Series" - RequestMediaType.Audiobook -> "Audiobook" - RequestMediaType.Ebook -> "Ebook" - RequestMediaType.All -> "All" - RequestAvailability.Available -> "In Library" - RequestAvailability.Missing -> "Missing" - "request" -> "Request" - else -> split('_', '-', ' ') - .filter { it.isNotBlank() } - .joinToString(" ") { token -> token.replaceFirstChar { it.uppercase() } } - .ifBlank { this } -} +fun RequestMediaResult.libraryItemToOpen(): String? = + RequestDisplayState.of(availability, request)?.libraryItemToOpen(libraryContentId) -/** - * A sentence saying why this title cannot be requested, or null when there is - * nothing worth showing. A title that already has a request gets null: its - * request status already says where it stands. A code this client does not - * know also gets null, so a raw code never reaches the screen, while reason - * text that is not a code is shown as the server wrote it. - */ -fun RequestState.reasonMessage(): String? = when (reason) { - RequestReason.AlreadyRequested -> null - RequestReason.AlreadyAvailable -> "This title is already in your library." - RequestReason.RequestsDisabled -> "Requests are disabled on this server." - RequestReason.Blocked -> "Your account is blocked from making requests." - RequestReason.QuotaExceeded -> "You've reached your request limit." - else -> reason.takeUnless { it.isBlank() || ReasonCodePattern.matches(it) } -} - -/** What a server reason code looks like, as opposed to readable text. */ -private val ReasonCodePattern = Regex("[a-z0-9_]+") - -fun MediaRequest.canCancel(): Boolean = - outcome == RequestOutcome.Active && status == RequestStatus.Pending - -fun MediaRequest.targetSummary(): String? { - if (targets.isEmpty()) return null - return targets.joinToString(limit = 2, truncated = "…") { target -> - listOf(target.instanceName, target.quality, target.status, target.externalStatus, target.lastError) - .filter { it.isNotBlank() } - .joinToString(" • ") - } -} +/** Same rule for the user's own request records. */ +fun MediaRequest.libraryItemToOpen(): String? = + RequestDisplayState.of(this).libraryItemToOpen(libraryContentId) diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/section/SectionModels.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/section/SectionModels.kt index 3027413b9a..b68d15c71f 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/section/SectionModels.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/section/SectionModels.kt @@ -33,12 +33,18 @@ data class SectionItem( @SerialName("position_seconds") val positionSeconds: Double? = null, @SerialName("duration_seconds") val durationSeconds: Double? = null, @SerialName("progress_updated_at") val progressUpdatedAt: String? = null, + @SerialName("poster_is_episode_still") val posterIsEpisodeStill: Boolean? = null, + @SerialName("backdrop_is_episode_still") val backdropIsEpisodeStill: Boolean? = null, @SerialName("poster_url") val posterUrl: String? = null, @SerialName("poster_thumbhash") val posterThumbhash: String? = null, @SerialName("backdrop_url") val backdropUrl: String? = null, @SerialName("backdrop_thumbhash") val backdropThumbhash: String? = null, @SerialName("logo_url") val logoUrl: String? = null, - @SerialName("user_state") val userState: MediaItemUserState? = null + @SerialName("user_state") val userState: MediaItemUserState? = null, + /** This profile's metadata language while the card's description is not available in it yet. */ + @SerialName("pending_translation_language") val pendingTranslationLanguage: String? = null, + /** Localized fields ("overview", "tagline") whose text was machine-translated by AI. */ + @SerialName("machine_translated_fields") val machineTranslatedFields: List<String> = emptyList(), ) @Serializable diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/server/ServerEntry.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/server/ServerEntry.kt index 1a7ddbfbcf..e845c93f8d 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/server/ServerEntry.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/server/ServerEntry.kt @@ -25,6 +25,13 @@ data class ServerEntry( val lastUsedAtEpochMs: Long = 0L, /** Native API contract learned on connect; see [ServerContract]. */ val contract: ServerContract = ServerContract.UNKNOWN, + /** + * Deployment identity (`GET /api/v2/system/identity`) last verified at + * [url]. One deployment answers the same id at every address, so this — + * not URL spelling — is how a TV's advertisement or a `prairie://device` + * link is matched to a saved server. Null until verified. + */ + val verifiedServerId: String? = null, ) { val displayName: String get() = userOverrideName?.takeIf { it.isNotBlank() } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/EpisodeSpoilers.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/EpisodeSpoilers.kt new file mode 100644 index 0000000000..ae6a5684fa --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/EpisodeSpoilers.kt @@ -0,0 +1,87 @@ +package org.prairieserver.prairie.model.settings + +import org.prairieserver.prairie.model.catalog.BrowseItem +import org.prairieserver.prairie.model.catalog.LeafItemUserData +import org.prairieserver.prairie.model.section.SectionItem + +/** + * Spoiler protection for episodes the profile has not started (settings + * contract revision 17). The profile settings + * [SettingKeys.CATALOG_HIDE_UNWATCHED_EPISODE_IMAGES] and + * [SettingKeys.CATALOG_HIDE_UNWATCHED_EPISODE_OVERVIEWS] choose what to hide; + * this object owns the rule for which episodes they apply to, shared by every + * phone and TV surface. The server never changes catalog responses; clients + * apply the settings. + */ +object EpisodeSpoilers { + /** Contract revision that introduced both keys. */ + const val MIN_CONTRACT_REVISION = 17 + + /** Settings protocol version these keys are defined against. */ + const val SETTINGS_API_VERSION = 1 + + val KEYS: List<String> = listOf( + SettingKeys.CATALOG_HIDE_UNWATCHED_EPISODE_IMAGES, + SettingKeys.CATALOG_HIDE_UNWATCHED_EPISODE_OVERVIEWS, + ) + + fun isSupported(capabilities: SettingsContractCapabilities): Boolean = + capabilities.apiVersion == SETTINGS_API_VERSION && + capabilities.manifestRevision >= MIN_CONTRACT_REVISION && + capabilities.supportsBatchedEffective + + /** + * An episode is unwatched while it is neither played nor in progress. + * Missing watch state counts as unwatched, so an episode is never + * revealed just because its payload left the state out. Matches the web + * client's `isEpisodeUnwatched`. + */ + fun isUnwatched( + played: Boolean?, + isInProgress: Boolean?, + positionSeconds: Double?, + ): Boolean { + if (played == true || isInProgress == true) return false + return (positionSeconds ?: 0.0) <= 0.0 + } + + fun isUnwatched(userData: LeafItemUserData?): Boolean = + userData == null || + isUnwatched(userData.played, userData.isInProgress, userData.positionSeconds) + + /** Browse cards use the live played flag returned by their action state. */ + fun hidesBrowseArtwork( + item: BrowseItem, prefs: EpisodeSpoilerPrefs, played: Boolean? = item.userState?.played, + selectBackdrop: Boolean = false, positionSeconds: Double? = item.positionSeconds, + ): Boolean { + val provenance = if (selectBackdrop && !item.backdropUrl.isNullOrBlank()) item.backdropIsEpisodeStill + else item.posterIsEpisodeStill + return item.type.equals("episode", ignoreCase = true) && + prefs.hidesImage(isUnwatched(played, isInProgress = null, positionSeconds = positionSeconds), provenance) + } + + /** Provenance follows the image selected for the wide card; null preserves older-server protection. */ + fun selectedImageIsStill(item: SectionItem): Boolean? = + if (!item.backdropUrl.isNullOrBlank()) item.backdropIsEpisodeStill else item.posterIsEpisodeStill + + /** Section rows carry no in-progress flag; played and position decide. */ + fun isUnwatched(item: SectionItem): Boolean = + isUnwatched(item.userState?.played, isInProgress = null, positionSeconds = item.positionSeconds) +} + +/** What the profile hides for an unwatched episode. Both off by default. */ +data class EpisodeSpoilerPrefs( + /** Blur the still of an episode the profile has not started. */ + val hideImages: Boolean = false, + /** Hide the description of an episode the profile has not started. */ + val hideOverviews: Boolean = false, +) { + fun hidesImage(unwatched: Boolean, isEpisodeStill: Boolean? = null): Boolean = + hideImages && unwatched && isEpisodeStill != false + + fun hidesOverview(unwatched: Boolean): Boolean = hideOverviews && unwatched + + companion object { + val NONE = EpisodeSpoilerPrefs() + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/PlaybackSettingsKeys.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/PlaybackSettingsKeys.kt index bd2160384d..05e8b59f7c 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/PlaybackSettingsKeys.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/PlaybackSettingsKeys.kt @@ -54,6 +54,7 @@ object PlaybackSettingsKeys { const val SubtitleFontSize = "subtitle.font_size" const val SubtitleFontFamily = "subtitle.font_family" const val SubtitleTextColor = "subtitle.text_color" + const val SubtitleTextOpacity = "subtitle.text_opacity" const val SubtitleBackgroundColor = "subtitle.background_color" const val SubtitleBackgroundStyle = "subtitle.background_style" const val SubtitleBackgroundOpacity = "subtitle.background_opacity" @@ -150,6 +151,21 @@ object PlaybackSettingsKeys { */ const val ForceHdrPassthrough = "player.force_hdr_passthrough" + /** + * Local-only per-profile TV setting, off by default. Clears the bars + * around a picture that doesn't fill the screen to transparent instead of + * painting them opaque black. + * + * Some devices composite the app window's SDR black above reference black + * while HDR or Dolby Vision is on, so painted bars glow grey (Onn 4K Pro, + * Sony TVs). Others do the reverse and lift the transparent area (Fire TV, + * Google TV Streamer). Neither the chip vendor nor any platform signal + * tells them apart (prairie-android#475), so the user picks what looks right + * on their device. It describes this device's compositor, not a playback + * preference, so this never enters [DeviceSettings]. + */ + const val TrueBlackBars = "player.true_black_bars" + val DeviceSettings = listOf( PreferredQuality, MaxBitrateKbps, @@ -173,6 +189,7 @@ object PlaybackSettingsKeys { SubtitleFontSize, SubtitleFontFamily, SubtitleTextColor, + SubtitleTextOpacity, SubtitleBackgroundColor, SubtitleBackgroundStyle, SubtitleBackgroundOpacity, @@ -181,6 +198,42 @@ object PlaybackSettingsKeys { SubtitlePosition, ) + /** + * The device settings that also have a profile layer (`profile` is among + * their allowed scopes), so clearing this device's value falls back to the + * profile's rather than straight to the contract default. These are the + * ones that offer "Use profile setting". Pinned to the manifest by + * `PlaybackSettingsKeysContractTest`. + */ + val ProfileLayeredDeviceSettings: Set<String> = setOf( + PreferredQuality, + MaxBitrateKbps, + AudioLanguage, + AutoSkipIntro, + IntroSkipMode, + AutoSkipCredits, + AutoPlayNext, + NextUpPromptSeconds, + SubtitleAppearance, + ) + + /** + * The keys one control writes together, so going back to the profile's + * value has to clear them together. Quality is one picker over two axes, + * and the intro-skip enum has the deprecated boolean the server mirrors + * beside it; clearing only one of a pair would leave half a device choice + * behind. + */ + fun deviceOverrideGroup(key: String): List<String> = when (key) { + PreferredQuality, MaxBitrateKbps -> listOf(PreferredQuality, MaxBitrateKbps) + IntroSkipMode, AutoSkipIntro -> listOf(IntroSkipMode, AutoSkipIntro) + else -> listOf(key) + } + + /** Whether this device holds its own value for [key]'s control. */ + fun hasDeviceOverride(overrides: Set<String>, key: String): Boolean = + deviceOverrideGroup(key).any { it in overrides } + /** * `old local slot -> current key`, for the two keys the settings cutover * renamed. diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/PlaybackSpeedRange.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/PlaybackSpeedRange.kt new file mode 100644 index 0000000000..ddf00d8f58 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/PlaybackSpeedRange.kt @@ -0,0 +1,27 @@ +package org.prairieserver.prairie.model.settings + +import kotlin.math.roundToLong + +/** + * The bounds of `player.playback_speed`: 0.25..3.0 in 0.05 steps. + * + * The generated [SettingKeys] carry no value bounds yet, so they are restated + * here and pinned to the vendored manifest by `PlayerSettingsContractTest`. + * The server enforces the step as well as the range, so a value off the grid is + * refused like one out of range, and the write is dropped. + */ +object PlaybackSpeedRange { + const val MIN = 0.25 + const val MAX = 3.0 + const val STEP = 0.05 + + /** [value] clamped to the range and rounded to the nearest step. */ + fun normalize(value: Double): Double { + if (value.isNaN()) return 1.0 + val clamped = value.coerceIn(MIN, MAX) + // Steps per unit (20), so the result is a whole number of steps + // divided once, which lands on the nearest double to the step value. + val stepsPerUnit = (1 / STEP).roundToLong() + return (clamped * stepsPerUnit).roundToLong().toDouble() / stepsPerUnit + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SettingKeys.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SettingKeys.kt index 2b3ae3525a..6c0c8caab6 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SettingKeys.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SettingKeys.kt @@ -24,18 +24,26 @@ data class SettingPresentation( ) object SettingKeys { - const val REVISION = 9 + const val REVISION = 17 + /** Blur unwatched episode images */ + const val CATALOG_HIDE_UNWATCHED_EPISODE_IMAGES = "catalog.hide_unwatched_episode_images" + /** Hide unwatched episode descriptions */ + const val CATALOG_HIDE_UNWATCHED_EPISODE_OVERVIEWS = "catalog.hide_unwatched_episode_overviews" /** Metadata language */ const val CATALOG_METADATA_LANGUAGE = "catalog.metadata_language" /** Metadata language exceptions */ const val CATALOG_METADATA_LANGUAGE_OVERRIDES = "catalog.metadata_language_overrides" + /** Show advisory age */ + const val CATALOG_SHOW_ADVISORY_AGE = "catalog.show_advisory_age" /** Download quality */ const val DOWNLOADS_DEFAULT_QUALITY = "downloads.default_quality" /** Keep watched downloads */ const val DOWNLOADS_KEEP_WATCHED = "downloads.keep_watched" /** Download over Wi-Fi only */ const val DOWNLOADS_WIFI_ONLY = "downloads.wifi_only" + /** Hide watched items from Home */ + const val HOME_HIDE_WATCHED_ITEMS = "home.hide_watched_items" /** Primary menu */ const val NAV_PRIMARY_MENU = "nav.primary_menu" /** Navigation shortcuts */ @@ -106,6 +114,8 @@ object SettingKeys { const val PLAYER_VIDEO_SKIP_BACK_SECONDS = "player.video_skip_back_seconds" /** Video fast-forward interval */ const val PLAYER_VIDEO_SKIP_FORWARD_SECONDS = "player.video_skip_forward_seconds" + /** Request titles I add to my watchlist */ + const val REQUESTS_WATCHLIST_AUTO_REQUEST = "requests.watchlist_auto_request" /** Search scope */ const val SEARCH_MEDIA_SCOPE = "search.media_scope" /** Match device caption settings */ @@ -146,13 +156,23 @@ object SettingKeys { const val UI_TEXT_WEIGHT = "ui.text_weight" /** Theme */ const val UI_THEME = "ui.theme" + /** Theme music */ + const val UI_THEME_MUSIC_ENABLED = "ui.theme_music_enabled" + /** Loop theme music */ + const val UI_THEME_MUSIC_LOOP = "ui.theme_music_loop" /** Time format */ const val UI_TIME_FORMAT = "ui.time_format" + /** Show title art */ + const val UI_TITLE_ART = "ui.title_art" /** Every key the server stores. Safe to flush. */ val REMOTE: List<String> = listOf( + CATALOG_HIDE_UNWATCHED_EPISODE_IMAGES, + CATALOG_HIDE_UNWATCHED_EPISODE_OVERVIEWS, CATALOG_METADATA_LANGUAGE, CATALOG_METADATA_LANGUAGE_OVERRIDES, + CATALOG_SHOW_ADVISORY_AGE, + HOME_HIDE_WATCHED_ITEMS, NAV_PRIMARY_MENU, NAV_SHORTCUTS, PLAYBACK_AUDIO_LANGUAGE, @@ -184,6 +204,7 @@ object SettingKeys { PLAYER_VIDEO_GRAVITY, PLAYER_VIDEO_SKIP_BACK_SECONDS, PLAYER_VIDEO_SKIP_FORWARD_SECONDS, + REQUESTS_WATCHLIST_AUTO_REQUEST, SEARCH_MEDIA_SCOPE, UI_CARD_OVERLAYS, UI_CARD_OVERLAYS_ENABLED, @@ -203,7 +224,10 @@ object SettingKeys { UI_TEXT_SCALE, UI_TEXT_WEIGHT, UI_THEME, + UI_THEME_MUSIC_ENABLED, + UI_THEME_MUSIC_LOOP, UI_TIME_FORMAT, + UI_TITLE_ART, ) /** Contract-known keys that never leave the device. */ @@ -223,9 +247,16 @@ object SettingKeys { * pair at write time, so editing either would rewrite the other. */ val DEPRECATED: Set<String> = setOf( PLAYBACK_AUTO_SKIP_INTRO, + UI_CUSTOM_CSS, + UI_CUSTOM_THEME_VARS, + UI_THEME, ) val BOOLEAN_KEYS: Set<String> = setOf( + CATALOG_HIDE_UNWATCHED_EPISODE_IMAGES, + CATALOG_HIDE_UNWATCHED_EPISODE_OVERVIEWS, + CATALOG_SHOW_ADVISORY_AGE, + HOME_HIDE_WATCHED_ITEMS, PLAYBACK_AUTO_PLAY_NEXT, PLAYBACK_AUTO_PLAY_NEXT_PREVIEW, PLAYBACK_AUTO_SKIP_CREDITS, @@ -237,10 +268,14 @@ object SettingKeys { PLAYER_HDR_ENABLED, PLAYER_MATCH_FRAME_RATE, PLAYER_SEEK_CACHE_ENABLED, + REQUESTS_WATCHLIST_AUTO_REQUEST, UI_CARD_OVERLAYS_ENABLED, UI_CARD_QUICK_ACTIONS_ENABLED, UI_HIGH_CONTRAST, UI_REMEMBER_LIBRARY_PAGE_STATE, + UI_THEME_MUSIC_ENABLED, + UI_THEME_MUSIC_LOOP, + UI_TITLE_ART, ) val INT_KEYS: Set<String> = setOf( diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearance.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearance.kt index 2695fd5336..2e7c7f6268 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearance.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearance.kt @@ -4,6 +4,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject /** * `wire` repeats what each `@SerialName` declares. It is spelled out as a @@ -56,6 +57,7 @@ data class SubtitleAppearance( val fontSize: SubtitleFontSizePreset = SubtitleFontSizePreset.Large, val fontFamily: String = SANS_SERIF, val fontColor: String = "#ffffff", + val textOpacity: Int = 100, val backgroundColor: String = "#000000", val backgroundStyle: SubtitleBackgroundStylePreset = SubtitleBackgroundStylePreset.Shadow, val backgroundOpacity: Int = 75, @@ -68,11 +70,13 @@ data class SubtitleAppearance( val safeBackgroundColor = if (isValidHex(backgroundColor)) backgroundColor else DEFAULT.backgroundColor val safeOutlineColor = if (isValidHex(textOutlineColor)) textOutlineColor else DEFAULT.textOutlineColor val clampedOpacity = backgroundOpacity.coerceIn(0, 100) + val clampedTextOpacity = textOpacity.coerceIn(1, 100) return if ( safeFontColor == fontColor && safeBackgroundColor == backgroundColor && safeOutlineColor == textOutlineColor && - clampedOpacity == backgroundOpacity + clampedOpacity == backgroundOpacity && + clampedTextOpacity == textOpacity ) { this } else { @@ -81,6 +85,7 @@ data class SubtitleAppearance( backgroundColor = safeBackgroundColor, textOutlineColor = safeOutlineColor, backgroundOpacity = clampedOpacity, + textOpacity = clampedTextOpacity, ) } } @@ -94,6 +99,31 @@ data class SubtitleAppearance( val DEFAULT: SubtitleAppearance = SubtitleAppearance() + /** Wire name of [textOpacity] inside the `playback.subtitle_appearance` object. */ + const val TEXT_OPACITY_FIELD: String = "textOpacity" + + /** + * First settings manifest revision whose `playback.subtitle_appearance` + * schema knows [textOpacity]. The schema sets additionalProperties to + * false, so an older server rejects the whole object when it carries + * the field. + */ + const val TEXT_OPACITY_MIN_MANIFEST_REVISION: Int = 14 + + fun supportsTextOpacity(manifestRevision: Int): Boolean = + manifestRevision >= TEXT_OPACITY_MIN_MANIFEST_REVISION + + /** + * [wire] as a server at [manifestRevision] accepts it: [TEXT_OPACITY_FIELD] + * is removed below [TEXT_OPACITY_MIN_MANIFEST_REVISION] and kept otherwise. + */ + fun wireObjectForRevision(wire: JsonObject, manifestRevision: Int): JsonObject = + if (supportsTextOpacity(manifestRevision) || TEXT_OPACITY_FIELD !in wire) { + wire + } else { + JsonObject(wire - TEXT_OPACITY_FIELD) + } + private val JSON = Json { encodeDefaults = true ignoreUnknownKeys = true diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearanceProjection.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearanceProjection.kt index a8f6b25172..98a98c31b7 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearanceProjection.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearanceProjection.kt @@ -21,6 +21,7 @@ object SubtitleAppearanceProjection { PlaybackSettingsKeys.SubtitleFontSize, PlaybackSettingsKeys.SubtitleFontFamily, PlaybackSettingsKeys.SubtitleTextColor, + PlaybackSettingsKeys.SubtitleTextOpacity, PlaybackSettingsKeys.SubtitleBackgroundColor, PlaybackSettingsKeys.SubtitleBackgroundStyle, PlaybackSettingsKeys.SubtitleBackgroundOpacity, @@ -49,6 +50,9 @@ object SubtitleAppearanceProjection { fields[PlaybackSettingsKeys.SubtitleTextColor]?.let { raw -> hexColor(raw)?.let { out = out.copy(fontColor = it) } } + fields[PlaybackSettingsKeys.SubtitleTextOpacity]?.let { raw -> + raw.trim().toIntOrNull()?.let { out = out.copy(textOpacity = it.coerceIn(1, 100)) } + } fields[PlaybackSettingsKeys.SubtitleBackgroundColor]?.let { raw -> hexColor(raw)?.let { out = out.copy(backgroundColor = it) } } @@ -80,6 +84,7 @@ object SubtitleAppearanceProjection { PlaybackSettingsKeys.SubtitleFontSize to safe.fontSize.wire, PlaybackSettingsKeys.SubtitleFontFamily to safe.fontFamily, PlaybackSettingsKeys.SubtitleTextColor to safe.fontColor, + PlaybackSettingsKeys.SubtitleTextOpacity to safe.textOpacity.toString(), PlaybackSettingsKeys.SubtitleBackgroundColor to safe.backgroundColor, PlaybackSettingsKeys.SubtitleBackgroundStyle to safe.backgroundStyle.wire, PlaybackSettingsKeys.SubtitleBackgroundOpacity to safe.backgroundOpacity.toString(), diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/shuffle/ShuffleModels.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/shuffle/ShuffleModels.kt new file mode 100644 index 0000000000..38b32f72ee --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/shuffle/ShuffleModels.kt @@ -0,0 +1,69 @@ +package org.prairieserver.prairie.model.shuffle + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.prairieserver.prairie.model.section.SectionItem + +/** What a shuffle draws its random picks from. Wire values of `ShuffleScopeRequest.kind`. */ +enum class ShuffleScopeKind(val wire: String) { + LIBRARY("library"), + SERIES("series"), + SEASON("season"), + LIBRARY_COLLECTION("library_collection"), + USER_COLLECTION("user_collection"), +} + +/** + * `GET /api/v2/shuffles/capabilities`. Shuffle is offered only when [state] + * is `available` and the profile is [allowed]; [scopeKinds] lists the scopes + * `createShuffle` accepts. + */ +@Serializable +data class ShuffleCapability( + val state: String = "", + val allowed: Boolean = false, + @SerialName("scope_kinds") val scopeKinds: List<String> = emptyList(), + val revision: String = "", +) { + val isAvailable: Boolean get() = allowed && state == STATE_AVAILABLE + + fun supports(kind: ShuffleScopeKind): Boolean = isAvailable && kind.wire in scopeKinds + + companion object { + const val STATE_AVAILABLE = "available" + } +} + +/** The scope a running shuffle plays from, named as it was when the shuffle started. */ +@Serializable +data class ShuffleScope( + val kind: String, + val id: String, + val title: String, + /** A season scope's series title; absent for other scopes. */ + @SerialName("parent_title") val parentTitle: String? = null, +) + +/** + * A running shuffle. [current] plays now and [next] after it; both are catalog + * item cards (movies or episodes). [next] equals [current] only when one item + * in the scope can play. + */ +@Serializable +data class Shuffle( + val id: String, + val scope: ShuffleScope, + val current: SectionItem, + val next: SectionItem, + @SerialName("created_at") val createdAt: String = "", + @SerialName("updated_at") val updatedAt: String = "", +) + +private val SHUFFLE_LIBRARY_TYPES = setOf("movie", "movies", "series", "show", "shows", "tv", "mixed") + +/** Whether a library of [type] holds movies or episodes: movie, TV, and mixed libraries shuffle. */ +fun isShuffleLibraryType(type: String?): Boolean = type?.trim()?.lowercase() in SHUFFLE_LIBRARY_TYPES + +/** A season shuffles only when it has two or more playable episodes. */ +fun canShuffleSeason(episodes: List<org.prairieserver.prairie.model.catalog.EpisodeListItem>): Boolean = + episodes.count { it.files.isNotEmpty() } > 1 diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/subtitles/SubtitleModels.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/subtitles/SubtitleModels.kt index 71487a8a6c..aa0eed46da 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/subtitles/SubtitleModels.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/subtitles/SubtitleModels.kt @@ -80,6 +80,9 @@ data class DownloadedSubtitle( val score: Double = 0.0, @SerialName("hearing_impaired") val hearingImpaired: Boolean = false, @SerialName("created_at") val createdAt: String = "", + val timing: SubtitleTiming = SubtitleTiming(), + /** The latest sync job; null when the subtitle was never synced. */ + val sync: SubtitleSyncJob? = null, ) /** Envelope for POST /api/v2/subtitles/download. */ diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/subtitles/SubtitleSync.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/subtitles/SubtitleSync.kt new file mode 100644 index 0000000000..d93b999fb7 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/subtitles/SubtitleSync.kt @@ -0,0 +1,218 @@ +package org.prairieserver.prairie.model.subtitles + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlin.math.abs +import kotlin.math.roundToInt +import kotlin.math.roundToLong + +/** + * A subtitle's timing correction: original time `t` plays at + * `t * scale + offsetMs`. The server applies it to every delivery path. + */ +@Serializable +data class SubtitleTiming( + @SerialName("offset_ms") val offsetMs: Long = 0, + val scale: Double = 1.0, +) { + val isIdentity: Boolean get() = offsetMs == 0L && scale == 1.0 +} + +/** A subtitle's latest sync job (`SubtitleSyncJobState`). */ +@Serializable +data class SubtitleSyncJob( + val id: String = "", + val status: String, + /** `auto` for the sync of a new download or upload, `manual` for one someone asked for. */ + val trigger: String? = null, + /** What an active job is doing: `queued`, `analyzing`, or `matching`. */ + val phase: String? = null, + /** 0..1 while the job is active. */ + val progress: Double? = null, + /** Why a failed job failed: `subtitle_changed`, `no_audio`, `unavailable`, or `error`. */ + val failure: String? = null, + val confidence: Double? = null, + /** The correction the job found, once it finished. */ + val result: SubtitleTiming? = null, + @SerialName("created_at") val createdAt: String = "", + @SerialName("finished_at") val finishedAt: String? = null, +) { + val inProgress: Boolean get() = status == PENDING || status == RUNNING + + companion object { + const val PENDING = "pending" + const val RUNNING = "running" + const val SYNCED = "synced" + const val ALREADY_SYNCED = "already_synced" + const val NO_MATCH = "no_match" + const val FAILED = "failed" + + const val PHASE_ANALYZING = "analyzing" + const val PHASE_MATCHING = "matching" + + const val FAILURE_SUBTITLE_CHANGED = "subtitle_changed" + const val FAILURE_NO_AUDIO = "no_audio" + const val FAILURE_UNAVAILABLE = "unavailable" + } +} + +/** + * One syncable subtitle of a media file (`SubtitleSyncState`): a stored + * subtitle or a subtitle file next to the media (a sidecar), named by its + * opaque sync [key], with its timing and latest job. + */ +@Serializable +data class SubtitleSyncState( + val key: String, + @SerialName("media_file_id") val mediaFileId: String = "", + /** `downloaded` for a stored subtitle, `external` for a sidecar. */ + val source: String = "", + @SerialName("stored_subtitle_id") val storedSubtitleId: String? = null, + val language: String = "", + val format: String = "", + /** The release name, or the sidecar's file name. */ + val label: String = "", + val timing: SubtitleTiming = SubtitleTiming(), + val sync: SubtitleSyncJob? = null, +) { + val isSidecar: Boolean get() = source == SOURCE_EXTERNAL + + companion object { + const val SOURCE_EXTERNAL = "external" + const val SOURCE_DOWNLOADED = "downloaded" + + /** The sync key of a stored subtitle; the server documents it as equivalent to the stored operations. */ + fun storedKey(subtitleId: Int): String = "stored-$subtitleId" + } +} + +/** The sync state a stored subtitle returned by a download or upload carries. */ +fun DownloadedSubtitle.syncState(): SubtitleSyncState = SubtitleSyncState( + key = SubtitleSyncState.storedKey(id), + mediaFileId = mediaFileId.toString(), + source = SubtitleSyncState.SOURCE_DOWNLOADED, + storedSubtitleId = id.toString(), + language = language, + format = format, + label = releaseName.ifBlank { provider }, + timing = timing, + sync = sync, +) + +/** GET /api/v2/subtitles/sync/status: whether this server can align subtitles to audio. */ +@Serializable +data class SubtitleSyncCapability( + val state: String = "", + @SerialName("auto_sync") val autoSync: Boolean = false, + /** Whether subtitle files next to the media (sidecars) can be synced too. */ + val external: Boolean = false, + val allowed: Boolean = false, +) { + val available: Boolean get() = state == "available" && allowed +} + +/** "+2.3 s" / "−0.4 s"; whole seconds keep one decimal so a list scans evenly. */ +fun formatSyncOffset(offsetMs: Long): String { + val tenths = (abs(offsetMs) + 50) / 100 + val sign = if (offsetMs < 0) "−" else "+" + return "$sign${tenths / 10}.${tenths % 10} s" +} + +private val frameRates = listOf( + 23.976 to "23.976", 24.0 to "24", 25.0 to "25", 29.97 to "29.97", + 30.0 to "30", 50.0 to "50", 59.94 to "59.94", 60.0 to "60", +) + +// The server reports a frame-rate conversion as its exact ratio; anything else +// is drift between two cuts, which only looks like a nearby ratio. +private const val FRAME_RATE_TOLERANCE = 1e-6 + +/** + * Names a scale correction as the frame-rate conversion it most likely is + * ("25→23.976 fps"), or as a plain speed factor ("×1.0008 speed"). Null for + * no scaling. A subtitle cut for source rate `from` stretched onto video rate + * `to` has scale = from / to. + */ +fun describeSyncScale(scale: Double): String? { + if (!scale.isFinite() || scale == 1.0) return null + for ((from, fromLabel) in frameRates) { + for ((to, toLabel) in frameRates) { + if (from != to && abs(from / to - scale) <= FRAME_RATE_TOLERANCE) return "$fromLabel→$toLabel fps" + } + } + // Up to four decimals without trailing zeros, like the web player's Number(scale.toFixed(4)). + val tenThousandths = (scale * 10_000).roundToLong() + val whole = tenThousandths / 10_000 + val fraction = (tenThousandths % 10_000).toString().padStart(4, '0').trimEnd('0') + return if (fraction.isEmpty()) "×$whole speed" else "×$whole.$fraction speed" +} + +/** "+2.3 s · 25→23.976 fps": a correction in a few characters. */ +fun describeSyncTiming(timing: SubtitleTiming): String { + val scale = describeSyncScale(timing.scale) + val offset = if (timing.offsetMs != 0L || scale == null) formatSyncOffset(timing.offsetMs) else null + return listOfNotNull(offset, scale).joinToString(" · ") +} + +/** An active job's progress as a whole percentage, or null once it finished. */ +fun syncProgressPercent(job: SubtitleSyncJob?): Int? { + if (job == null || !job.inProgress) return null + return ((job.progress ?: 0.0).coerceIn(0.0, 1.0) * 100).roundToInt() +} + +/** What an active job is doing, in a few words. */ +fun syncPhaseLabel(job: SubtitleSyncJob?): String? { + if (job == null || !job.inProgress) return null + return when (job.phase) { + SubtitleSyncJob.PHASE_MATCHING -> "Matching lines to speech…" + SubtitleSyncJob.PHASE_ANALYZING -> "Listening to the audio…" + else -> "Waiting to start…" + } +} + +/** Why a failed job failed, in plain words. */ +fun syncFailureMessage(failure: String?): String = when (failure) { + SubtitleSyncJob.FAILURE_SUBTITLE_CHANGED -> "The subtitle changed while it was syncing. Try again." + SubtitleSyncJob.FAILURE_NO_AUDIO -> "This video has no audio Prairie can read." + SubtitleSyncJob.FAILURE_UNAVAILABLE -> "The server is busy. Try again in a few minutes." + else -> "Sync failed. Try again." +} + +/** + * One short line describing a subtitle's timing, or null when there is + * nothing to say (never synced and never adjusted). Matches the web player. + */ +fun subtitleSyncStatusLabel(timing: SubtitleTiming, sync: SubtitleSyncJob?): String? { + when (sync?.status) { + SubtitleSyncJob.PENDING, SubtitleSyncJob.RUNNING -> { + val percent = syncProgressPercent(sync) + return if (percent != null && percent > 0) "Syncing… $percent%" else "Syncing…" + } + SubtitleSyncJob.NO_MATCH -> return "Doesn't match this video" + SubtitleSyncJob.FAILED -> return "Sync failed" + SubtitleSyncJob.ALREADY_SYNCED -> if (timing.isIdentity) return "Already in sync" + SubtitleSyncJob.SYNCED -> { + if (timing.isIdentity) return "Original timing" + if (sync.result == timing) return "Synced ${describeSyncTiming(timing)}" + } + } + return if (timing.isIdentity) null else "Timing adjusted ${describeSyncTiming(timing)}" +} + +/** + * The last finished job's result for the timing section, or null when there + * is none to show; [warning] marks an outcome that left the timing unchanged + * because the sync could not help. + */ +data class SubtitleSyncResultLine(val text: String, val warning: Boolean = false) + +fun subtitleSyncResultLine(timing: SubtitleTiming, sync: SubtitleSyncJob?): SubtitleSyncResultLine? = + when (sync?.status) { + SubtitleSyncJob.SYNCED -> + if (timing.isIdentity) null else SubtitleSyncResultLine("Synced to the audio: ${describeSyncTiming(timing)}") + SubtitleSyncJob.ALREADY_SYNCED -> SubtitleSyncResultLine("Already matches the audio.") + SubtitleSyncJob.NO_MATCH -> + SubtitleSyncResultLine("Doesn't match this video's audio; probably for another release.", warning = true) + SubtitleSyncJob.FAILED -> SubtitleSyncResultLine(syncFailureMessage(sync.failure), warning = true) + else -> null + } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/watchtogether/WatchPartyV2Models.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/watchtogether/WatchPartyV2Models.kt new file mode 100644 index 0000000000..7c875e78ad --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/watchtogether/WatchPartyV2Models.kt @@ -0,0 +1,93 @@ +package org.prairieserver.prairie.model.watchtogether + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.prairieserver.prairie.model.catalog.BrowseItem + +/** + * `GET /api/v2/watch-together/capabilities`. Each flag reflects an operation + * the server actually wires. [socketProtocol] is `prairie.room.v2` when the v2 + * room socket is served and empty otherwise; `lobby_ready` and + * `connection_replaced` ride on that socket. + */ +@Serializable +data class WatchTogetherCapabilitiesV2( + val state: String = "", + val allowed: Boolean? = null, + @SerialName("staged_selection") val stagedSelection: Boolean = false, + @SerialName("lobby_ready") val lobbyReady: Boolean = false, + @SerialName("connection_replaced") val connectionReplaced: Boolean = false, + @SerialName("selection_mode_switch") val selectionModeSwitch: Boolean = false, + @SerialName("member_state") val memberState: Boolean = false, + val picker: Boolean = false, + @SerialName("vote_host_override") val voteHostOverride: Boolean = false, + @SerialName("stop_playback") val stopPlayback: Boolean = false, + @SerialName("max_member_state_ids") val maxMemberStateIds: Int = 0, + @SerialName("socket_protocol") val socketProtocol: String = "", +) + +/** One member's watch state for one content id (`POST .../member-state`). */ +@Serializable +data class MemberWatchState( + @SerialName("user_id") val userId: String = "", + @SerialName("profile_id") val profileId: String = "", + val state: String = "", + @SerialName("position_seconds") val positionSeconds: Double? = null, + @SerialName("duration_seconds") val durationSeconds: Double? = null, + @SerialName("on_watchlist") val onWatchlist: Boolean = false, +) + +@Serializable +data class ItemMemberState( + @SerialName("content_id") val contentId: String, + val members: List<MemberWatchState> = emptyList(), +) + +/** + * Member watch state for the requested ids that the caller may see, in + * request order. Inaccessible and missing ids are omitted; an omitted id is + * unavailable, not unplayed. + */ +@Serializable +data class MemberStateResponse( + val members: List<RoomMember> = emptyList(), + val items: List<ItemMemberState> = emptyList(), +) + +@Serializable +data class PickerMember( + @SerialName("user_id") val userId: String = "", + @SerialName("profile_id") val profileId: String = "", + @SerialName("display_name") val displayName: String = "", + @SerialName("position_seconds") val positionSeconds: Double? = null, + @SerialName("duration_seconds") val durationSeconds: Double? = null, +) + +/** For a series row: the episode most members would play next. */ +@Serializable +data class PickerNextUp( + @SerialName("content_id") val contentId: String, + @SerialName("season_number") val seasonNumber: Int = 0, + @SerialName("episode_number") val episodeNumber: Int = 0, + val title: String = "", + @SerialName("member_count") val memberCount: Int = 0, +) + +@Serializable +data class PickerEntry( + val item: BrowseItem, + val members: List<PickerMember> = emptyList(), + @SerialName("next_up") val nextUp: PickerNextUp? = null, +) + +/** + * `GET .../picker`: shared rows resolved through the caller's catalog access. + * Continue Together lists only items two or more connected members are part + * way through, so it is empty in a party of one. + */ +@Serializable +data class PickerResponse( + val members: List<RoomMember> = emptyList(), + @SerialName("continue_together") val continueTogether: List<PickerEntry> = emptyList(), + @SerialName("watchlist_union") val watchlistUnion: List<PickerEntry> = emptyList(), +) diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/watchtogether/WatchTogetherModels.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/watchtogether/WatchTogetherModels.kt index 6f60e09ea1..fbcdb5a273 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/watchtogether/WatchTogetherModels.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/watchtogether/WatchTogetherModels.kt @@ -124,11 +124,33 @@ object TransportActionSerializer : KSerializer<TransportAction> by EnumWireSeria // ---- Core payloads -------------------------------------------------------- +/** + * One connected member of a v2 snapshot (server `MemberSummary`). HTTP v2 + * sends `user_id` as a string and the room socket sends it as a number; the + * lenient decoder reads both into [userId]. The readiness booleans are + * omitted when false, which means false only when the server advertises + * `watch_party_coordinator_v1`. + */ +@Serializable +data class RoomMember( + @SerialName("user_id") val userId: String = "", + @SerialName("profile_id") val profileId: String = "", + @SerialName("display_name") val displayName: String = "", + @SerialName("is_host") val isHost: Boolean = false, + @SerialName("is_self") val isSelf: Boolean = false, + val connected: Boolean = false, + @SerialName("lobby_ready") val lobbyReady: Boolean = false, + @SerialName("is_ready") val isReady: Boolean = false, + @SerialName("is_buffering") val isBuffering: Boolean = false, + @SerialName("is_syncing") val isSyncing: Boolean = false, +) + /** * Universal room payload (server `Snapshot`). Several fields are per-recipient * (`self_*`, `invite_path` is host-only). `selection_revision` and * `generation` are Longs; `anchor_position_seconds` is a Double; timestamps are - * RFC3339 strings. + * RFC3339 strings. [anchorPositionSeconds] is already projected to the time + * the server sent the snapshot; never add the time since [anchorUpdatedAt]. */ @Serializable data class RoomSnapshot( @@ -154,7 +176,21 @@ data class RoomSnapshot( @SerialName("self_ignore_wait") val selfIgnoreWait: Boolean = false, @SerialName("attached_session_id") val attachedSessionId: String? = null, @SerialName("invite_path") val invitePath: String? = null, -) + val members: List<RoomMember> = emptyList(), +) { + /** This viewer's own member entry, when the server listed one. */ + val selfMember: RoomMember? get() = members.firstOrNull { it.isSelf } + + /** + * The room kept going without this viewer: its waiting deadline passed, or + * the room no longer waits for this viewer's stalls. The viewer must send + * `ready` once its media is playable again. + */ + val selfCatchingUp: Boolean + get() = phase == RoomPhase.Playing && + (playbackState == RoomPlaybackState.Playing || playbackState == RoomPlaybackState.Paused) && + (selfIgnoreWait || selfMember?.isBuffering == true) +} /** A content suggestion (vote-mode room). In WS broadcasts [votedByMe] is forced false. */ @Serializable @@ -193,10 +229,15 @@ data class TransportCommand( // ---- REST request models -------------------------------------------------- -/** POST /rooms. */ +/** + * POST /rooms. [roomId] is the caller-selected identity: the logical action + * keeps it so an explicit retry after an uncertain outcome replays the same + * room instead of creating a second one. + */ @Serializable data class CreateRoomRequest( - @SerialName("selection_mode") val selectionMode: String? = null, + @SerialName("room_id") val roomId: String, + @SerialName("selection_mode") val selectionMode: String = RoomSelectionMode.HostPick.wire, ) /** POST /join — one of [code]/[joinToken] required; token wins. */ @@ -206,7 +247,7 @@ data class JoinRoomRequest( @SerialName("join_token") val joinToken: String? = null, ) -/** PUT /rooms/{id}/selection (host-only). */ +/** PUT /rooms/{id}/selection and PUT /rooms/{id}/staged-selection (host-only). */ @Serializable data class SetSelectionRequest( @SerialName("content_id") val contentId: String, @@ -214,15 +255,51 @@ data class SetSelectionRequest( @SerialName("library_id") val libraryId: Int? = null, ) +/** PATCH /rooms/{id}/selection-mode (host-only, lobby only). */ +@Serializable +data class SelectionModeRequest( + @SerialName("selection_mode") val selectionMode: String, +) + +/** The playback refusals that may move the whole room to another source file. */ +enum class SourceFallbackReason(val wire: String) { + NoAlternateVersion("no_alternate_version"), + HdrTranscodeUnsupported("hdr_transcode_unsupported"), + SubtitleConversionUnsupported("subtitle_conversion_unsupported"), + TranscodingDisabled("transcoding_disabled"); + + companion object { + fun fromWire(w: String?): SourceFallbackReason? = entries.firstOrNull { it.wire == w } + } +} + +/** POST /rooms/{id}/source-fallback, fenced by the failed file and selection revision. */ +@Serializable +data class SourceFallbackRequest( + @SerialName("selection_revision") val selectionRevision: Long, + @SerialName("failed_file_id") val failedFileId: String, + val reason: String, +) + +/** POST /rooms/{id}/member-state. */ +@Serializable +data class MemberStateRequest( + @SerialName("content_ids") val contentIds: List<String>, +) + /** PATCH /rooms/{id}/policy (host-only). */ @Serializable data class UpdatePolicyRequest( @SerialName("guest_control_policy") val guestControlPolicy: String, ) -/** POST /rooms/{id}/suggestions. */ +/** + * POST /rooms/{id}/suggestions. [suggestionId] is the caller-selected identity, + * kept by the logical action so an explicit retry replays the same suggestion. + */ @Serializable data class AddSuggestionRequest( + @SerialName("suggestion_id") val suggestionId: String, @SerialName("content_id") val contentId: String, @SerialName("content_type") val contentType: String, val title: String, @@ -246,10 +323,23 @@ data class RoomResponse( @SerialName("room_access_token") val roomAccessToken: String = "", ) -/** `{suggestions:[…]}` — all suggestion list/mutation responses. */ +/** One page of GET /rooms/{id}/suggestions, in creation order. */ @Serializable data class SuggestionsResponse( @SerialName("items") val suggestions: List<Suggestion> = emptyList(), + val page: SuggestionPageInfo? = null, +) + +@Serializable +data class SuggestionPageInfo( + @SerialName("has_more") val hasMore: Boolean = false, + @SerialName("next_cursor") val nextCursor: String? = null, +) + +/** POST /rooms/{id}/suggestions receipt (HTTP 201). */ +@Serializable +data class SuggestionReceipt( + @SerialName("suggestion_id") val suggestionId: String, ) // ---- Client→server WS frames ---------------------------------------------- @@ -268,20 +358,36 @@ data class WsTransportRequest( @SerialName("is_paused") val isPaused: Boolean, ) +/** + * Periodic position report. While the room waits, [commandId] with + * [isReady] = true acknowledges that command exactly like a `ready` frame, so a + * lost acknowledgement heals on the next report. + */ @Serializable data class WsStateReport( val type: String = "state_report", @SerialName("session_id") val sessionId: String, @SerialName("position_seconds") val positionSeconds: Double, @SerialName("is_paused") val isPaused: Boolean, + @SerialName("command_id") val commandId: String? = null, + @SerialName("is_ready") val isReady: Boolean? = null, ) +/** Readiness acknowledgement for the command named by [commandId]. */ @Serializable data class WsReady( val type: String = "ready", @SerialName("session_id") val sessionId: String, @SerialName("position_seconds") val positionSeconds: Double, @SerialName("is_paused") val isPaused: Boolean, + @SerialName("command_id") val commandId: String? = null, +) + +/** Advisory lobby "I'm ready"; distinct from playback readiness. */ +@Serializable +data class WsLobbyReady( + val type: String = "lobby_ready", + val ready: Boolean, ) @Serializable diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/AccessChangeSignals.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/AccessChangeSignals.kt new file mode 100644 index 0000000000..e4305e40ab --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/AccessChangeSignals.kt @@ -0,0 +1,220 @@ +package org.prairieserver.prairie.network + +import io.ktor.client.statement.HttpResponse +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.util.AttributeKey +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.update +import kotlin.time.TimeSource + +/** v2 problem code for a profile header the server no longer accepts. */ +const val PROFILE_VERIFICATION_REQUIRED = "profile_verification_required" + +/** + * Whether a failed read says the viewer may no longer see the resource: 404, + * 410, or a 403 other than [PROFILE_VERIFICATION_REQUIRED] (a stale profile + * proof, which profile recovery handles). Transport failures, 401, and 5xx are + * transient and say nothing about access. + */ +fun ApiResult<*>.isAccessRefusal(): Boolean = + this is ApiResult.Error && + (code == 404 || code == 410 || (code == 403 && error != PROFILE_VERIFICATION_REQUIRED)) + +/** + * How the client learns that the server changed what the signed-in viewer may + * access, without the session ending. + * + * An administrator can move an account to another access group, or change its + * permissions or playback-quality override, while the user is signed in. The + * server then raises its access-policy revision: PIN profile tokens minted + * before the change stop working (403 `profile_verification_required`), and the + * events socket sends `{"type":"access_changed"}` and closes with code 4001. + * + * This type only carries those observations. [reportStaleProfile] feeds the + * profile recovery in the repository layer; [changes] tells screens to refetch + * access-dependent data. Both are inert when the server never produces them. + */ +class AccessChangeSignals( + /** Milliseconds on a monotonic clock; tests substitute virtual time. */ + private val nowMillis: () -> Long = monotonicMillis(), +) { + private val _staleProfileReports = MutableSharedFlow<StaleProfileReport>( + extraBufferCapacity = 16, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + /** Every request the server refused because its profile proof is stale. */ + val staleProfileReports: SharedFlow<StaleProfileReport> = _staleProfileReports.asSharedFlow() + + /** Recent reports, newest last; [revision] moves only after a report is logged here. */ + private val reports = MutableStateFlow(emptyList<AccessChangeReport>()) + private val _revision = MutableStateFlow(0L) + + /** Raised once per events-socket connection that reports an access change. */ + val revision: StateFlow<Long> = _revision.asStateFlow() + + /** + * Access changes reported after collection starts, coalesced. The frame and + * close of one connection count once. The Home and notifications sockets + * each detect the same change on their own server-side check, which runs + * every 15 seconds from each connection's start, so their reports can be + * that far apart: the first report refreshes at once, and reports within + * [ACCESS_CHANGE_COALESCE_MS] of it are taken as the same change. The + * exception is a report from a connection opened after that refresh. Each + * connection reports once and then reconnects, and a new connection + * compares against the policy its own ticket was minted under, so its + * report is a change the refresh did not load, and it refreshes again. + */ + val changes: Flow<Unit> = flow { emitAll(changes(AccessChangeCursor())) } + + /** + * [changes] for a holder that outlives its screen, such as a ViewModel + * whose screen leaves composition while another destination is in front. + * The [cursor] remembers which revision the holder has applied, so when + * collection resumes, a change reported while nothing was collecting + * refreshes at once instead of being skipped. Coalescing follows the + * cursor as well, so the second socket's report of a change already + * applied does not refresh again on return. + */ + fun changes(cursor: AccessChangeCursor): Flow<Unit> = flow { + revision.collect { current -> + val applied = cursor.appliedRevision + if (applied != null && current <= applied) return@collect + cursor.appliedRevision = current + // The first collection starts from whatever the holder loaded. + if (applied == null) return@collect + val now = nowMillis() + val last = cursor.lastRefreshAt + val pending = reports.value.filter { it.revision in (applied + 1)..current } + // Reports older than the log reach are unknown; refresh for them. + val complete = pending.size.toLong() == current - applied + val covered = last != null && complete && now - last < ACCESS_CHANGE_COALESCE_MS && + pending.all { report -> report.connectedAt == null || report.connectedAt < last } + if (!covered) { + cursor.lastRefreshAt = now + emit(Unit) + } + } + } + + fun reportStaleProfile(report: StaleProfileReport) { + _staleProfileReports.tryEmit(report) + } + + /** The time to pass to [reportAccessChanged] for a connection whose ticket was just minted. */ + fun connectionTime(): Long = nowMillis() + + /** + * Records one access-change report. [connectedAt] is the [connectionTime] + * taken after the reporting socket's ticket was minted; null for a report + * that does not come from an events socket, which coalesces by time only. + */ + fun reportAccessChanged(connectedAt: Long? = null) { + var logged = 0L + reports.update { log -> + logged = (log.lastOrNull()?.revision ?: 0L) + 1 + (log + AccessChangeReport(logged, connectedAt)).takeLast(REPORT_LOG_SIZE) + } + _revision.update { maxOf(it, logged) } + } + + companion object { + /** Longer than the server's 15-second socket access check. */ + const val ACCESS_CHANGE_COALESCE_MS = 20_000L + + private const val REPORT_LOG_SIZE = 32 + + /** Close code the events socket uses after an `access_changed` frame. */ + const val ACCESS_CHANGED_CLOSE_CODE: Short = 4001 + + /** Frame `type` of the access-change notice on the events socket. */ + const val ACCESS_CHANGED_FRAME_TYPE = "access_changed" + } +} + +/** + * The access changes one holder has already applied; see + * [AccessChangeSignals.changes]. Keep one per ViewModel and collect it from one + * place at a time (the screen's main-thread effect). + */ +class AccessChangeCursor { + internal var appliedRevision: Long? = null + internal var lastRefreshAt: Long? = null +} + +private class AccessChangeReport(val revision: Long, val connectedAt: Long?) + +private fun monotonicMillis(): () -> Long { + val start = TimeSource.Monotonic.markNow() + return { start.elapsedNow().inWholeMilliseconds } +} + +/** + * One request refused with 403 `profile_verification_required`, described by + * the identity it actually presented. The recovery acts only when that identity + * is still the active one, which is what keeps a stale background request (or a + * second report for an identity already cleared) from doing anything. + */ +data class StaleProfileReport( + val requestUrl: String, + val profileId: String, + val profileToken: String?, + /** The scope a pinned (background/outbox) request was bound to, if any. */ + val pinnedScope: AuthScopeSnapshot?, +) { + override fun toString(): String = + "StaleProfileReport(requestUrl=<redacted>, profileId=<redacted>, " + + "profileToken=<redacted>, pinned=${pinnedScope != null})" +} + +/** Client attribute through which the v2 error decoder finds the signals. */ +internal val AccessChangeSignalsKey: AttributeKey<AccessChangeSignals> = + AttributeKey("PrairieAccessChangeSignals") + +/** + * Report this response if it is a stale-profile refusal for a request that ran + * on a profile identity. Called by the v2 error decoder, so it sees every v2 + * call without the auth plugin having to read response bodies. + */ +internal fun HttpResponse.reportStaleProfileIfRefused(problemCode: String) { + if (status != HttpStatusCode.Forbidden || problemCode != PROFILE_VERIFICATION_REQUIRED) return + val signals = call.client.attributes.getOrNull(AccessChangeSignalsKey) ?: return + val request = call.request + // Diagnostics uploads choose their profile headers themselves. + if (request.attributes.contains(DiagnosticsUploadAuthorizationKey) || + request.attributes.contains(DiagnosticsRequestScopeKey) + ) return + if (isHouseholdManagementRequest(request.method, request.url.encodedPath)) return + val profileId = request.headers["X-Profile-Id"]?.takeIf { it.isNotBlank() } ?: return + signals.reportStaleProfile( + StaleProfileReport( + requestUrl = request.url.toString(), + profileId = profileId, + profileToken = request.headers["X-Profile-Token"]?.takeIf { it.isNotBlank() }, + pinnedScope = request.attributes.getOrNull(AuthScopeAttributeKey), + ), + ) +} + +/** + * Household management (creating, editing, or deleting profiles, avatars, and + * the household session list) answers `profile_verification_required` when the + * active profile is not the verified primary profile. That is a missing + * permission, not a stale token, so it must not clear the profile. A stale token + * on these screens still recovers through the next ordinary read. + */ +internal fun isHouseholdManagementRequest(method: HttpMethod, path: String): Boolean { + val profilesPath = path.startsWith("/api/v2/profiles") || path.startsWith("/api/v1/profiles") + if (!profilesPath || path.endsWith("/verify-pin")) return false + return method != HttpMethod.Get || path.contains("/profiles/household") +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/AuthInterceptorImpl.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/AuthInterceptorImpl.kt index f471b0f249..40c9c0e311 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/AuthInterceptorImpl.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/AuthInterceptorImpl.kt @@ -14,6 +14,8 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.prairieserver.prairie.model.auth.RefreshRequest import org.prairieserver.prairie.model.auth.RefreshResponse +import org.prairieserver.prairie.network.apiv2.Problem +import io.ktor.client.statement.bodyAsText /** * How close to expiry an access token may get before a request refreshes it @@ -52,6 +54,12 @@ internal enum class RefreshOutcome { * concurrent traffic amplifies the outage it is already suffering. */ FailedTransient, + + /** + * A [FailedTransient] the server explained: 503 `provider_unavailable`, + * the sign-in provider couldn't re-check the session. The session stays. + */ + ProviderUnavailable, } /** @@ -65,6 +73,12 @@ class PrairieAuthConfig { var deviceMetadataProvider: DeviceMetadataProvider? = null var diagnosticsObserver: NetworkDiagnosticsObserver? = null var cleartextOriginConsent: CleartextOriginConsent? = null + + /** + * Receives stale-profile refusals the v2 error decoder sees on this + * client. Null leaves them as ordinary errors. + */ + var accessChangeSignals: AccessChangeSignals? = null } /** @@ -88,6 +102,7 @@ val PrairieAuthPlugin = createClientPlugin("PrairieAuthPlugin", ::PrairieAuthCon val deviceMetadataProvider = pluginConfig.deviceMetadataProvider val diagnosticsObserver = pluginConfig.diagnosticsObserver val cleartextOriginConsent = pluginConfig.cleartextOriginConsent + pluginConfig.accessChangeSignals?.let { client.attributes.put(AccessChangeSignalsKey, it) } val refreshMutex = Mutex() @@ -242,6 +257,11 @@ val PrairieAuthPlugin = createClientPlugin("PrairieAuthPlugin", ::PrairieAuthCon if (!refreshResponse.status.shouldInvalidateSessionAfterRefreshFailure()) { // Gateway/proxy/server failure: the credentials may well // still be good, so the caller may spend them as before. + if (refreshResponse.status == HttpStatusCode.ServiceUnavailable && + isProviderUnavailableProblem(runCatching { refreshResponse.bodyAsText() }.getOrNull()) + ) { + return@withLock RefreshOutcome.ProviderUnavailable + } return@withLock RefreshOutcome.FailedTransient } run { @@ -285,6 +305,83 @@ val PrairieAuthPlugin = createClientPlugin("PrairieAuthPlugin", ::PrairieAuthCon } } + /** + * One refresh of a pinned (background or other-server) [pinnedScope], + * serialised on [refreshMutex]. Never invalidates the active UI session: a + * rejected refresh token is only remembered so it isn't spent again. Tells + * a refresh the server refused with 503 + * `provider_unavailable` (the provider re-check couldn't reach the + * provider under `fail_closed`) apart from any other failure: the session + * is still valid and the caller can say so. + * + * @return [PinnedRefresh.Refreshed] when [pinnedScope] now holds a token + * other than [sentAuth]. + */ + suspend fun refreshPinnedScope(pinnedScope: AuthScopeSnapshot, sentAuth: String?): PinnedRefresh { + val pinnedGeneration = pinnedScope.credentialGenerationId + return refreshMutex.withLock { + // Another path may have refreshed this scope while we waited. + val current = tokenManager.getAccessTokenForScope(pinnedScope)?.let { "Bearer $it" } + if (current != null && current != sentAuth) { + return@withLock PinnedRefresh.Refreshed + } + val refreshToken = tokenManager.getRefreshTokenForScope(pinnedScope) + if (refreshToken.isNullOrBlank() || pinnedScope.serverUrl.isBlank() || + refreshToken in rejectedPinnedRefreshTokens.value + ) { + return@withLock PinnedRefresh.Failed + } + try { + diagnosticsObserver.safeAuthRefresh("started") + val refreshResponse = client.post("${pinnedScope.serverUrl}/api/v2/auth/refresh") { + contentType(ContentType.Application.Json) + setBody(RefreshRequest(refreshToken)) + } + if (refreshResponse.status == HttpStatusCode.OK) { + diagnosticsObserver.safeAuthRefresh("succeeded") + val tokens = refreshResponse.body<RefreshResponse>() + tokenManager.saveTokensForScope( + scope = pinnedScope, + accessToken = tokens.accessToken, + refreshToken = tokens.refreshToken, + expiresIn = tokens.expiresIn, + ) + // A temporary credential generation may have ended while + // refresh was in flight. Its token manager deliberately + // drops that stale response instead of falling through to + // the saved account, so retry only when the exact scope now + // exposes the rotated token. + val after = tokenManager.getAccessTokenForScope(pinnedScope)?.let { "Bearer $it" } + if (after != null && after != sentAuth) PinnedRefresh.Refreshed else PinnedRefresh.Failed + } else { + diagnosticsObserver.safeAuthRefresh("failed") + if (refreshResponse.status == HttpStatusCode.ServiceUnavailable && + isProviderUnavailableProblem(runCatching { refreshResponse.bodyAsText() }.getOrNull()) + ) { + return@withLock PinnedRefresh.ProviderUnavailable + } + if (refreshResponse.status.shouldInvalidateSessionAfterRefreshFailure()) { + if (pinnedGeneration != null) { + deadCredentialGenerations.update { it + pinnedGeneration } + } else { + rejectedPinnedRefreshTokens.update { it + refreshToken } + } + } + // Don't invalidate the active session for a background scope. + // Re-check in case a concurrent path refreshed it in flight. + val after = tokenManager.getAccessTokenForScope(pinnedScope)?.let { "Bearer $it" } + if (after != null && after != sentAuth) PinnedRefresh.Refreshed else PinnedRefresh.Failed + } + } catch (e: Throwable) { + diagnosticsObserver.safeAuthRefresh("failed") + PinnedRefresh.Failed + } + } + } + + suspend fun refreshPinnedScopeOnce(pinnedScope: AuthScopeSnapshot, sentAuth: String?): Boolean = + refreshPinnedScope(pinnedScope, sentAuth) == PinnedRefresh.Refreshed + onRequest { request, _ -> val skipAuth = request.attributes.getOrNull(SkipPrairieAuthAttributeKey) == true val requireAuth = request.attributes.getOrNull(RequirePrairieAuthAttributeKey) == true @@ -460,14 +557,53 @@ val PrairieAuthPlugin = createClientPlugin("PrairieAuthPlugin", ::PrairieAuthCon request.removePrairieCredentialHeaders() return@on proceed(request) } - if (request.attributes.getOrNull(SingleAttemptAttributeKey) == true) return@on proceed(request) + val singleAttempt = request.attributes.getOrNull(SingleAttemptAttributeKey) == true + if (singleAttempt && pinnedScope == null) return@on proceed(request) if (pinnedScope != null) { - val sentAuth = request.headers[HttpHeaders.Authorization] + var sentAuth = request.headers[HttpHeaders.Authorization] + val pinnedGeneration = pinnedScope.credentialGenerationId + // Refresh first when the caller asked for a fresh bearer (an approver + // about to approve a TV, a single-use link confirmation) and this + // scope's token is expiring. Same refresh as the 401 path below, so + // a rotation can't race it. This runs for single-attempt requests + // too: it happens before the one request is sent, not as a retry. + if ( + request.attributes.getOrNull(FreshPrairieAuthAttributeKey) == true && + sentAuth != null && + (pinnedGeneration == null || pinnedGeneration !in deadCredentialGenerations.value) && + tokenManager.accessTokenExpiresWithin(pinnedScope, PROACTIVE_REFRESH_MARGIN_MS) + ) { + diagnosticsObserver.safeAuthRefresh("required") + when (refreshPinnedScope(pinnedScope, sentAuth)) { + PinnedRefresh.Refreshed -> + tokenManager.getAccessTokenForScope(pinnedScope)?.let { newToken -> + request.headers.remove(HttpHeaders.Authorization) + request.header(HttpHeaders.Authorization, "Bearer $newToken") + sentAuth = "Bearer $newToken" + } + // The provider couldn't re-check the session. It stays valid, + // but an expired bearer would only come back 401 and read as + // "sign in again": fail the call with the reason instead. + PinnedRefresh.ProviderUnavailable -> + if (tokenManager.accessTokenExpiresWithin(pinnedScope, 0L)) { + request.removePrairieCredentialHeaders() + throw PrairieAuthUnavailableException(PrairieAuthUnavailableException.PROVIDER_UNAVAILABLE) + } + // The scope may have been signed out or replaced meanwhile: + // send the captured bearer only while the scope still holds it. + PinnedRefresh.Failed -> + if (tokenManager.getAccessTokenForScope(pinnedScope)?.let { "Bearer $it" } != sentAuth) { + request.removePrairieCredentialHeaders() + throw PrairieAuthUnavailableException(PrairieAuthUnavailableException.CREDENTIALS_REPUDIATED) + } + } + } + // Exactly one request: a single-attempt call never replays after a 401. + if (singleAttempt) return@on proceed(request) val originalCall = proceed(request) if (originalCall.response.status != HttpStatusCode.Unauthorized) { return@on originalCall } - val pinnedGeneration = pinnedScope.credentialGenerationId if (pinnedGeneration != null && pinnedGeneration in deadCredentialGenerations.value) { // Already-rejected temporary credentials: surface the 401 instead of // re-refreshing them for every pinned op (progress ticks, teardown). @@ -479,59 +615,7 @@ val PrairieAuthPlugin = createClientPlugin("PrairieAuthPlugin", ::PrairieAuthCon return@on originalCall } diagnosticsObserver.safeAuthRefresh("required") - val refreshed = refreshMutex.withLock { - // Another path may have refreshed this scope while we waited. - val current = tokenManager.getAccessTokenForScope(pinnedScope)?.let { "Bearer $it" } - if (current != null && current != sentAuth) { - return@withLock true - } - val refreshToken = tokenManager.getRefreshTokenForScope(pinnedScope) - if (refreshToken.isNullOrBlank() || pinnedScope.serverUrl.isBlank() || - refreshToken in rejectedPinnedRefreshTokens.value - ) { - return@withLock false - } - try { - diagnosticsObserver.safeAuthRefresh("started") - val refreshResponse = client.post("${pinnedScope.serverUrl}/api/v2/auth/refresh") { - contentType(ContentType.Application.Json) - setBody(RefreshRequest(refreshToken)) - } - if (refreshResponse.status == HttpStatusCode.OK) { - diagnosticsObserver.safeAuthRefresh("succeeded") - val tokens = refreshResponse.body<RefreshResponse>() - tokenManager.saveTokensForScope( - scope = pinnedScope, - accessToken = tokens.accessToken, - refreshToken = tokens.refreshToken, - expiresIn = tokens.expiresIn, - ) - // A temporary credential generation may have ended while - // refresh was in flight. Its token manager deliberately - // drops that stale response instead of falling through to - // the saved account, so retry only when the exact scope now - // exposes the rotated token. - val after = tokenManager.getAccessTokenForScope(pinnedScope)?.let { "Bearer $it" } - after != null && after != sentAuth - } else { - diagnosticsObserver.safeAuthRefresh("failed") - if (refreshResponse.status.shouldInvalidateSessionAfterRefreshFailure()) { - if (pinnedGeneration != null) { - deadCredentialGenerations.update { it + pinnedGeneration } - } else { - rejectedPinnedRefreshTokens.update { it + refreshToken } - } - } - // Don't invalidate the active session for a background scope. - // Re-check in case a concurrent path refreshed it in flight. - val after = tokenManager.getAccessTokenForScope(pinnedScope)?.let { "Bearer $it" } - after != null && after != sentAuth - } - } catch (e: Throwable) { - diagnosticsObserver.safeAuthRefresh("failed") - false - } - } + val refreshed = refreshPinnedScopeOnce(pinnedScope, sentAuth) return@on if (refreshed) { tokenManager.getAccessTokenForScope(pinnedScope)?.let { newToken -> request.headers.remove(HttpHeaders.Authorization) @@ -622,10 +706,22 @@ val PrairieAuthPlugin = createClientPlugin("PrairieAuthPlugin", ::PrairieAuthCon ) } + // The provider couldn't re-check the session. It stays valid, + // but a request that needs a fresh bearer would only come + // back 401 on an expired one and read as "sign in again": + // fail it with the reason instead, as the pinned path does. + RefreshOutcome.ProviderUnavailable -> + if (request.attributes.getOrNull(FreshPrairieAuthAttributeKey) == true && + tokenManager.accessTokenExpiresWithin(0L) + ) { + request.removePrairieCredentialHeaders() + throw PrairieAuthUnavailableException(PrairieAuthUnavailableException.PROVIDER_UNAVAILABLE) + } + RefreshOutcome.NotAttempted, RefreshOutcome.FailedTransient -> Unit } proactiveRefreshFailedTransiently = - earlyOutcome == RefreshOutcome.FailedTransient + earlyOutcome == RefreshOutcome.FailedTransient || earlyOutcome == RefreshOutcome.ProviderUnavailable // This request's bearer was captured BEFORE the refresh mutex was // waited on. In that window another coroutine can have signed out, @@ -781,6 +877,25 @@ private fun HttpRequestBuilder.applyProfileHeaders( private suspend fun TokenManager.temporaryGenerationId(): String? = snapshotCurrentScope()?.credentialGenerationId +/** How one pinned-scope refresh ended. */ +enum class PinnedRefresh { + /** The scope now holds a new token. */ + Refreshed, + + /** 503 `provider_unavailable`: the session is kept, try again later. */ + ProviderUnavailable, + + /** Anything else; the caller spends what it has. */ + Failed, +} + +/** Whether [body] is the v2 problem `provider_unavailable`. */ +internal fun isProviderUnavailableProblem(body: String?): Boolean { + if (body.isNullOrBlank()) return false + val problem = runCatching { PrairieJson.decodeFromString(Problem.serializer(), body) }.getOrNull() ?: return false + return problem.code == PrairieAuthUnavailableException.PROVIDER_UNAVAILABLE_PROBLEM +} + private fun HttpStatusCode.shouldInvalidateSessionAfterRefreshFailure(): Boolean = this == HttpStatusCode.BadRequest || this == HttpStatusCode.Unauthorized || diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/AuthScopeSnapshot.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/AuthScopeSnapshot.kt index d2d071c6b8..bf212c57fb 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/AuthScopeSnapshot.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/AuthScopeSnapshot.kt @@ -64,6 +64,24 @@ data class AuthScopeSnapshot( current.identityGeneration == identityGeneration && current.credentialEpoch == credentialEpoch + companion object { + /** + * The account scope, with no profile, for a call to a saved server that + * need not be the active one (approving a TV, reading its connections). + * Such a scope can't carry the token manager's live persistent-credential + * epoch, so the request is pinned to [identityGeneration] instead: a late + * refresh can't overwrite a same-server account replacement. + */ + fun profileless(serverId: String, serverUrl: String, identityGeneration: Long) = AuthScopeSnapshot( + serverId = serverId, + serverUrl = serverUrl, + profileId = null, + profileToken = null, + identityGeneration = identityGeneration, + isIdentityGenerationStamped = true, + ) + } + override fun toString(): String = "AuthScopeSnapshot(" + "serverId=<redacted>, profileId=<redacted>, serverUrl=<redacted>, " + @@ -107,6 +125,21 @@ fun HttpRequestBuilder.skipPrairieAuth() { attributes.put(SkipPrairieAuthAttributeKey, true) } +/** + * Marks a request that must go out on a fresh bearer. Pinned ([authScope]): + * when the scope's access token has expired or is about to, [PrairieAuthPlugin] + * refreshes it first rather than waiting for a 401. Both pinned and on the + * active session: when that refresh is refused with 503 + * `provider_unavailable` and the token has already expired, the request + * isn't sent and fails with [PrairieAuthUnavailableException.PROVIDER_UNAVAILABLE]. + * Used where the TV sign-in spec has the approver refresh before approving + * or declining a TV (and reading the account the card names), and for the + * account's linking operations: the link ticket, a link's confirmation and + * linking with directory credentials. + */ +internal val FreshPrairieAuthAttributeKey: AttributeKey<Boolean> = AttributeKey("PrairieFreshAuth") +internal fun HttpRequestBuilder.freshPrairieAuth() { attributes.put(FreshPrairieAuthAttributeKey, true) } + /** A nonretryable mutation must not be replayed by the auth-refresh interceptor. */ internal val SingleAttemptAttributeKey = AttributeKey<Boolean>("PrairieSingleAttempt") internal fun HttpRequestBuilder.singleAttempt() { attributes.put(SingleAttemptAttributeKey, true) } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/PrairieAuthUnavailableException.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/PrairieAuthUnavailableException.kt index 04653f95a9..8cee5b9d8a 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/PrairieAuthUnavailableException.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/PrairieAuthUnavailableException.kt @@ -11,6 +11,12 @@ package org.prairieserver.prairie.network * is already torn down. The access token may still have time left, which is * exactly the danger: the server could honour a write for a session the app * has ended. + * - [PROVIDER_UNAVAILABLE] — a request that needs a fresh bearer + * ([freshPrairieAuth]: approving or declining a TV, a link ticket, a link's + * confirmation, linking with directory credentials) found its access token + * expired, and the refresh was refused with 503 `provider_unavailable`: the + * sign-in provider couldn't re-check the session. The session stays; the + * caller says so and the person retries. * * Subclasses [IllegalStateException] because that is what the required-auth * path has always thrown, so existing handlers keep working. Callers that @@ -23,5 +29,13 @@ class PrairieAuthUnavailableException(val reason: String) : IllegalStateExceptio companion object { const val REQUIRED_AUTH_UNAVAILABLE = "required_silo_auth_unavailable" const val CREDENTIALS_REPUDIATED = "silo_auth_credentials_repudiated" + const val PROVIDER_UNAVAILABLE = "silo_auth_provider_unavailable" + + /** The v2 problem code the refresh answers. */ + const val PROVIDER_UNAVAILABLE_PROBLEM = "provider_unavailable" + + /** Whether [error] is a [PROVIDER_UNAVAILABLE] refusal. */ + fun isProviderUnavailable(error: Throwable?): Boolean = + (error as? PrairieAuthUnavailableException)?.reason == PROVIDER_UNAVAILABLE } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/PrairieHttpClient.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/PrairieHttpClient.kt index 3be64774ea..c279486964 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/PrairieHttpClient.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/PrairieHttpClient.kt @@ -5,5 +5,9 @@ import io.ktor.client.* /** * Factory for creating the Ktor HttpClient. * Implementation provided by Agent 2 in PrairieHttpClientImpl.kt. + * + * [retryOnConnectionFailure] false stops the engine from silently resending a + * request whose connection failed after it was sent. Operations that must + * never be replayed (Watch Party start, select, promote) use such a client. */ -expect fun createPlatformHttpClient(): HttpClient +expect fun createPlatformHttpClient(retryOnConnectionFailure: Boolean = true): HttpClient diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/PrairieHttpClientImpl.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/PrairieHttpClientImpl.kt index facce4e742..b56323a403 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/PrairieHttpClientImpl.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/PrairieHttpClientImpl.kt @@ -40,8 +40,10 @@ fun createPrairieClient( deviceMetadataProvider: DeviceMetadataProvider? = null, diagnosticsObserver: NetworkDiagnosticsObserver? = null, cleartextOriginConsent: CleartextOriginConsent? = null, + retryOnConnectionFailure: Boolean = true, + accessChangeSignals: AccessChangeSignals? = null, ): HttpClient { - val platformClient = createPlatformHttpClient() + val platformClient = createPlatformHttpClient(retryOnConnectionFailure) return platformClient.config { install(ContentNegotiation) { @@ -78,6 +80,7 @@ fun createPrairieClient( this.deviceMetadataProvider = deviceMetadataProvider this.diagnosticsObserver = diagnosticsObserver this.cleartextOriginConsent = cleartextOriginConsent + this.accessChangeSignals = accessChangeSignals } install(HttpTimeout) { diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/ServerRegistry.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/ServerRegistry.kt index 75515c0c86..3e14353560 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/ServerRegistry.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/ServerRegistry.kt @@ -56,6 +56,12 @@ interface ServerRegistry { */ suspend fun setContract(serverId: String, contract: ServerContract) {} + /** + * Records the deployment identity verified at [serverId]'s URL. Default is + * a no-op so single-purpose fakes need not track it. + */ + suspend fun setVerifiedServerId(serverId: String, verifiedServerId: String?) {} + /** Remove an entry entirely. If it was active, fall back to the next-MRU server. */ suspend fun remove(serverId: String) @@ -73,3 +79,15 @@ interface ServerRegistry { /** Bump the active server's lastUsedAt — call after a successful API hit. */ suspend fun touchActive() } + +/** + * Saved servers this device holds an access token for, the active one first, + * then most recently used: the servers a phone can approve a TV on or push to + * a nearby one. + */ +suspend fun ServerRegistry.signedInEntries(tokenManager: TokenManager): List<ServerEntry> { + val activeId = activeServerId.value + return entries.value + .filter { it.url.isNotBlank() && !tokenManager.getAccessTokenForScope(it.id).isNullOrBlank() } + .sortedWith(compareByDescending<ServerEntry> { it.id == activeId }.thenByDescending { it.lastUsedAtEpochMs }) +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/TokenManager.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/TokenManager.kt index d50d09ea51..8207594f24 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/TokenManager.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/TokenManager.kt @@ -232,6 +232,14 @@ interface TokenManager { */ suspend fun accessTokenExpiresWithin(marginMs: Long): Boolean = false + /** + * [accessTokenExpiresWithin] for a saved account's [scope], which need not + * be the active server: a phone approving a TV on another saved server + * renews that server's token first. Same clamping rules. Default false, so + * an implementation that can't answer keeps the reactive 401 refresh. + */ + suspend fun accessTokenExpiresWithin(scope: AuthScopeSnapshot, marginMs: Long): Boolean = false + /** * Capture the currently-active scope for pinning a background request. Returns * null when no server is active or the implementation isn't multi-server-aware. @@ -240,6 +248,14 @@ interface TokenManager { */ suspend fun snapshotCurrentScope(): AuthScopeSnapshot? = null + /** + * The durable id of the login whose credentials [serverId]'s saved slot + * holds: new on every sign-in there, gone on sign-out, and unchanged + * across process restarts, refreshes and profile switches. Null when that + * server has no saved credentials, or the implementation keeps no such id. + */ + suspend fun loginSessionId(serverId: String): String? = null + /** * Read a specific server's latest access token (handles rotation). Default * delegates to the active-scope [getAccessToken] for single-scope impls. diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/WatchTogetherRealtimeClient.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/WatchTogetherRealtimeClient.kt index 60230313d6..bc05b5a4f3 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/WatchTogetherRealtimeClient.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/WatchTogetherRealtimeClient.kt @@ -1,14 +1,19 @@ package org.prairieserver.prairie.network +import org.prairieserver.prairie.model.watchtogether.RoomPlaybackState import org.prairieserver.prairie.model.watchtogether.RoomSnapshot import org.prairieserver.prairie.model.watchtogether.Suggestion +import org.prairieserver.prairie.model.watchtogether.TransportAction import org.prairieserver.prairie.model.watchtogether.TransportCommand import org.prairieserver.prairie.model.watchtogether.WsAttachSession import org.prairieserver.prairie.model.watchtogether.WsBuffering +import org.prairieserver.prairie.model.watchtogether.WsLobbyReady import org.prairieserver.prairie.model.watchtogether.WsPing import org.prairieserver.prairie.model.watchtogether.WsReady import org.prairieserver.prairie.model.watchtogether.WsStateReport import org.prairieserver.prairie.model.watchtogether.WsTransportRequest +import org.prairieserver.prairie.network.api.ROOM_TOKEN_HEADER +import org.prairieserver.prairie.util.wallClockMillis import io.ktor.client.HttpClient import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession import io.ktor.client.plugins.websocket.webSocketSession @@ -17,7 +22,6 @@ import io.ktor.client.request.header import io.ktor.client.request.post import io.ktor.http.HttpHeaders import io.ktor.http.URLProtocol -import io.ktor.http.isSuccess import io.ktor.http.HttpStatusCode import io.ktor.http.encodeURLPathPart import io.ktor.http.encodedPath @@ -28,9 +32,12 @@ import io.ktor.websocket.readText import kotlinx.coroutines.CancellationException import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.serialization.builtins.ListSerializer @@ -41,17 +48,23 @@ import kotlinx.serialization.json.JsonPrimitive import org.prairieserver.prairie.model.notifications.WsTicketResponse import org.prairieserver.prairie.network.apiv2.ApiV2Gate import org.prairieserver.prairie.network.apiv2.safeApiV2Call +import org.prairieserver.prairie.util.parseRfc3339ToEpochMillis /** * Per-room websocket. One [connect] = one ticket mint at * `POST /api/v2/watch-together/rooms/{id}/ws-ticket` (scoped auth plus the - * room JWT in `X-Room-Token`) followed by one upgrade of + * room proof in `X-Room-Token`) followed by one upgrade of * `/api/v2/watch-together/rooms/{id}/ws` that carries only the single-use * ticket in `Sec-WebSocket-Protocol`. No credential travels in the URL. * - * [RoomRealtimeEvent.Closed] is reserved for an explicit decoded - * `room_closed` frame. Physical EOF and socket failures surface as - * [RoomRealtimeEvent.TransportTerminated], while owner cancellation is silent. + * The ticket mint may be refreshed and resent once by the auth interceptor + * after a 401; the server classifies it as naturally idempotent. + * + * [RoomRealtimeEvent.Closed] and [RoomRealtimeEvent.ConnectionReplaced] are + * reserved for decoded terminal frames. Physical EOF and socket failures + * surface as [RoomRealtimeEvent.TransportTerminated], while owner cancellation + * is silent. Frames are delivered with backpressure: a slow collector pauses + * the receive loop instead of losing events. */ interface WatchTogetherRealtimeClient { /** Open one physical room socket and publish [RoomRealtimeEvent.Opened] once writable. */ @@ -64,9 +77,16 @@ interface WatchTogetherRealtimeClient { /** Client→server sends report whether a frame reached the current writable socket. */ suspend fun attachSession(sessionId: String): Boolean suspend fun transportRequest(action: String, positionSeconds: Double?, isPaused: Boolean): Boolean - suspend fun stateReport(sessionId: String, positionSeconds: Double, isPaused: Boolean): Boolean - suspend fun ready(sessionId: String, positionSeconds: Double, isPaused: Boolean): Boolean + suspend fun stateReport( + sessionId: String, + positionSeconds: Double, + isPaused: Boolean, + commandId: String? = null, + isReady: Boolean = false, + ): Boolean + suspend fun ready(sessionId: String, positionSeconds: Double, isPaused: Boolean, commandId: String? = null): Boolean suspend fun buffering(sessionId: String, positionSeconds: Double, isPaused: Boolean): Boolean + suspend fun lobbyReady(ready: Boolean): Boolean = false suspend fun ping(clientSentAt: String): Boolean /** Opaque identity of the currently writable physical socket, if any. */ @@ -105,8 +125,21 @@ internal fun interface WatchTogetherSocketConnector { internal const val ROOM_SOCKET_PROTOCOL = "prairie.room.v2" +/** A frame that cannot be handed to the socket in this long is a failed send. */ +private const val SEND_TIMEOUT_MS = 5_000L + private fun roomPath(roomId: String) = "/api/v2/watch-together/rooms/${roomId.encodeURLPathPart()}" +/** + * A room ticket is usable when it names the room protocol, fits the server's + * bounds, and can travel in a header. `expires_in` of 0 is valid but expiring; + * see [RoomTicketExpiringException]. + */ +internal fun validRoomTicket(ticket: WsTicketResponse): Boolean = + ticket.protocol == ROOM_SOCKET_PROTOCOL && ticket.expiresIn in 0..30 && + ticket.maxConnectionSeconds in 1..300 && ticket.ticket.isNotEmpty() && + ticket.ticket.all { it.isLetterOrDigit() && it.code < 128 || it in "!#$%&'*+-.^_`|~" } + private class KtorWatchTogetherSocketConnector( private val client: HttpClient, ) : WatchTogetherSocketConnector { @@ -115,14 +148,15 @@ private class KtorWatchTogetherSocketConnector( client.post { url { encodedPath = "${roomPath(request.roomId)}/ws-ticket" } authScope(request.authScope); requirePrairieAuth() - header("X-Room-Token", request.roomToken) - }.also { check(!it.status.isSuccess() || it.status == HttpStatusCode.OK) } + header(ROOM_TOKEN_HEADER, request.roomToken) + }.also { check(it.status.value !in 200..299 || it.status == HttpStatusCode.OK) } }) { is ApiResult.Success -> minted.data - is ApiResult.Error -> throw IllegalStateException("room_ticket_unavailable:${minted.code}") + is ApiResult.Error -> throw RoomTicketRefusedException(minted.code, minted.error) is ApiResult.NetworkError -> throw minted.exception } - check(ticket.protocol == ROOM_SOCKET_PROTOCOL && ticket.ticket.isNotEmpty()) { "room_ticket_invalid" } + check(validRoomTicket(ticket)) { "room_ticket_invalid" } + if (ticket.expiresIn == 0) throw RoomTicketExpiringException() val session = client.webSocketSession { roomSocketUpgrade(request.authScope, request.roomId, ticket) } if (session.call.response.headers[HttpHeaders.SecWebSocketProtocol] != ROOM_SOCKET_PROTOCOL) { runCatching { session.close() } @@ -173,19 +207,21 @@ class DefaultWatchTogetherRealtimeClient private constructor( private val tokenManager: TokenManager, private val json: Json, private val socketConnector: WatchTogetherSocketConnector, + private val wallClockMs: () -> Long, ) : WatchTogetherRealtimeClient { constructor( client: HttpClient, tokenManager: TokenManager, json: Json = PrairieJson, - ) : this(tokenManager, json, KtorWatchTogetherSocketConnector(client)) + ) : this(tokenManager, json, KtorWatchTogetherSocketConnector(client), ::wallClockMillis) internal constructor( tokenManager: TokenManager, socketConnector: WatchTogetherSocketConnector, json: Json = PrairieJson, - ) : this(tokenManager, json, socketConnector) + wallClockMs: () -> Long = ::wallClockMillis, + ) : this(tokenManager, json, socketConnector, wallClockMs) private val sessionMutex = Mutex() private var session: WatchTogetherSocketConnection? = null @@ -201,7 +237,7 @@ class DefaultWatchTogetherRealtimeClient private constructor( val profileId = scope?.profileId val scopedAccessToken = scope?.let { tokenManager.getAccessTokenForScope(it) } if (scope == null || scopedAccessToken.isNullOrBlank() || profileId.isNullOrBlank()) { - trySend( + send( RoomRealtimeEvent.TransportTerminated( IllegalStateException("missing_auth_scope"), ), @@ -223,16 +259,18 @@ class DefaultWatchTogetherRealtimeClient private constructor( session = connection activeSessionId = ++sessionId } - trySend(RoomRealtimeEvent.Opened) + send(RoomRealtimeEvent.Opened) while (true) { val raw = connection.receiveText() ?: break - decodeRoomFrame(json, raw)?.let { trySend(it) } + decodeRoomFrame(json, raw, wallClockMs())?.let { send(it) } } - trySend(RoomRealtimeEvent.TransportTerminated()) + send(RoomRealtimeEvent.TransportTerminated()) } catch (cancellation: CancellationException) { throw cancellation } catch (failure: Throwable) { - trySend(RoomRealtimeEvent.TransportTerminated(failure)) + // The cause decides whether the owner reconnects, so it must not be + // dropped behind a full buffer. + send(RoomRealtimeEvent.TransportTerminated(failure)) } finally { connection?.let { completed -> // Cancellation is the privacy boundary for an identity/room @@ -256,15 +294,25 @@ class DefaultWatchTogetherRealtimeClient private constructor( private suspend fun sendText(text: String): Boolean { val writable = sessionMutex.withLock { session } ?: return false - return try { - writable.sendText(text) - true - } catch (cancellation: CancellationException) { - throw cancellation + return deliver(writable, text) + } + + /** + * Send one frame, bounded by [SEND_TIMEOUT_MS] so the playback binding's + * event loop never waits on a socket that has stopped draining. A socket + * that dies under a pending send cancels its outgoing channel, which + * surfaces here as a CancellationException even though the caller is + * still running. Only the caller's own cancellation may propagate; + * anything else is a failed send. Letting the channel's exception through + * ended the binding's event loop silently. + */ + private suspend fun deliver(writable: WatchTogetherSocketConnection, text: String): Boolean = + try { + withTimeoutOrNull(SEND_TIMEOUT_MS) { writable.sendText(text) } != null } catch (_: Throwable) { + currentCoroutineContext().ensureActive() false } - } override suspend fun attachSession(sessionId: String) = sendText(json.encodeToString(WsAttachSession.serializer(), WsAttachSession(sessionId = sessionId))) @@ -293,29 +341,38 @@ class DefaultWatchTogetherRealtimeClient private constructor( WsTransportRequest.serializer(), WsTransportRequest(action = action, positionSeconds = positionSeconds, isPaused = isPaused), ) - return try { - writable.sendText(payload) - true - } catch (cancellation: CancellationException) { - throw cancellation - } catch (_: Throwable) { - false - } + return deliver(writable, payload) } - override suspend fun stateReport(sessionId: String, positionSeconds: Double, isPaused: Boolean) = - sendText( - json.encodeToString( - WsStateReport.serializer(), - WsStateReport(sessionId = sessionId, positionSeconds = positionSeconds, isPaused = isPaused), + override suspend fun stateReport( + sessionId: String, + positionSeconds: Double, + isPaused: Boolean, + commandId: String?, + isReady: Boolean, + ) = sendText( + json.encodeToString( + WsStateReport.serializer(), + WsStateReport( + sessionId = sessionId, + positionSeconds = positionSeconds, + isPaused = isPaused, + commandId = commandId?.takeIf { isReady && it.isNotBlank() }, + isReady = isReady.takeIf { it && !commandId.isNullOrBlank() }, ), - ) + ), + ) - override suspend fun ready(sessionId: String, positionSeconds: Double, isPaused: Boolean) = + override suspend fun ready(sessionId: String, positionSeconds: Double, isPaused: Boolean, commandId: String?) = sendText( json.encodeToString( WsReady.serializer(), - WsReady(sessionId = sessionId, positionSeconds = positionSeconds, isPaused = isPaused), + WsReady( + sessionId = sessionId, + positionSeconds = positionSeconds, + isPaused = isPaused, + commandId = commandId?.takeIf { it.isNotBlank() }, + ), ), ) @@ -327,25 +384,29 @@ class DefaultWatchTogetherRealtimeClient private constructor( ), ) + override suspend fun lobbyReady(ready: Boolean) = + sendText(json.encodeToString(WsLobbyReady.serializer(), WsLobbyReady(ready = ready))) + override suspend fun ping(clientSentAt: String) = sendText(json.encodeToString(WsPing.serializer(), WsPing(clientSentAt = clientSentAt))) } /** - * Pure decode of one room WS server frame into a [RoomRealtimeEvent], or null - * when the frame is not one we surface (unknown `type`, malformed payload, or - * malformed JSON). Never throws — this is the load-bearing, fully-tested - * logic; socket I/O above is kept thin and untested. + * Pure decode of one room WS server frame into a [RoomRealtimeEvent]. Never + * throws — this is the load-bearing, fully-tested logic; socket I/O above is + * kept thin. * * - `snapshot {room}` → [RoomRealtimeEvent.SnapshotEvent] * - `transport_command {command}`→ [RoomRealtimeEvent.TransportCommandEvent] * - `suggestions_update {suggestions}` → [RoomRealtimeEvent.SuggestionsEvent] * - `room_closed {reason}` → [RoomRealtimeEvent.Closed] - * - `pong {…}` → [RoomRealtimeEvent.Pong] + * - `connection_replaced {reason}` → [RoomRealtimeEvent.ConnectionReplaced] + * - `pong {…}` → [RoomRealtimeEvent.Pong], stamped with [receivedAtMs] * - `error {code,message}` → [RoomRealtimeEvent.Error] - * - anything else → null + * - a known type whose payload does not decode → [RoomRealtimeEvent.Malformed] + * - an unknown type or non-object JSON → null (ignored) */ -fun decodeRoomFrame(json: Json, raw: String): RoomRealtimeEvent? { +fun decodeRoomFrame(json: Json, raw: String, receivedAtMs: Long? = null): RoomRealtimeEvent? { val obj: JsonObject = try { val element = json.parseToJsonElement(raw) element as? JsonObject ?: return null @@ -354,51 +415,56 @@ fun decodeRoomFrame(json: Json, raw: String): RoomRealtimeEvent? { } val type = (obj["type"] as? JsonPrimitive)?.content ?: return null + fun str(key: String) = (obj[key] as? JsonPrimitive)?.content ?: "" return when (type) { WatchTogetherRealtime.TypeSnapshot -> { - val room = obj["room"] as? JsonObject ?: return null + val room = obj["room"] as? JsonObject ?: return RoomRealtimeEvent.Malformed(type) val snapshot = try { json.decodeFromJsonElement(RoomSnapshot.serializer(), room) } catch (_: Exception) { - return null + return RoomRealtimeEvent.Malformed(type) } + if (snapshot.roomId.isBlank()) return RoomRealtimeEvent.Malformed(type) RoomRealtimeEvent.SnapshotEvent(snapshot) } WatchTogetherRealtime.TypeTransportCommand -> { - val command = obj["command"] as? JsonObject ?: return null + val command = obj["command"] as? JsonObject ?: return RoomRealtimeEvent.Malformed(type) val parsed = try { json.decodeFromJsonElement(TransportCommand.serializer(), command) } catch (_: Exception) { - return null + return RoomRealtimeEvent.Malformed(type) + } + // A command must say what to do, when, and in what state the room + // ends up. Without any of those it can't be applied safely, so the + // room is reconciled instead. The server always sends all three. + if (parsed.action == TransportAction.Unknown || parsed.playbackState == RoomPlaybackState.Unknown || + parseRfc3339ToEpochMillis(parsed.executeAt) == null + ) { + return RoomRealtimeEvent.Malformed(type) } RoomRealtimeEvent.TransportCommandEvent(parsed) } WatchTogetherRealtime.TypeSuggestionsUpdate -> { - val array = obj["suggestions"] as? JsonArray ?: return null + val array = obj["suggestions"] as? JsonArray ?: return RoomRealtimeEvent.Malformed(type) val list = try { json.decodeFromJsonElement(ListSerializer(Suggestion.serializer()), array) } catch (_: Exception) { - return null + return RoomRealtimeEvent.Malformed(type) } RoomRealtimeEvent.SuggestionsEvent(list) } - WatchTogetherRealtime.TypeRoomClosed -> { - val reason = (obj["reason"] as? JsonPrimitive)?.content - RoomRealtimeEvent.Closed(reason) - } - WatchTogetherRealtime.TypePong -> { - fun str(key: String) = (obj[key] as? JsonPrimitive)?.content ?: "" - RoomRealtimeEvent.Pong( - clientSentAt = str("client_sent_at"), - serverReceivedAt = str("server_received_at"), - serverSentAt = str("server_sent_at"), - ) - } - WatchTogetherRealtime.TypeError -> { - fun str(key: String) = (obj[key] as? JsonPrimitive)?.content ?: "" - RoomRealtimeEvent.Error(code = str("code"), message = str("message")) - } + WatchTogetherRealtime.TypeRoomClosed -> + RoomRealtimeEvent.Closed((obj["reason"] as? JsonPrimitive)?.content) + WatchTogetherRealtime.TypeConnectionReplaced -> + RoomRealtimeEvent.ConnectionReplaced((obj["reason"] as? JsonPrimitive)?.content) + WatchTogetherRealtime.TypePong -> RoomRealtimeEvent.Pong( + clientSentAt = str("client_sent_at"), + serverReceivedAt = str("server_received_at"), + serverSentAt = str("server_sent_at"), + clientReceivedMs = receivedAtMs, + ) + WatchTogetherRealtime.TypeError -> RoomRealtimeEvent.Error(code = str("code"), message = str("message")) else -> null } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/WatchTogetherRealtimeEvent.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/WatchTogetherRealtimeEvent.kt index f104fa2c74..4d3d22aeb7 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/WatchTogetherRealtimeEvent.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/WatchTogetherRealtimeEvent.kt @@ -10,13 +10,15 @@ object WatchTogetherRealtime { const val TypeTransportCommand = "transport_command" const val TypeSuggestionsUpdate = "suggestions_update" const val TypeRoomClosed = "room_closed" + const val TypeConnectionReplaced = "connection_replaced" const val TypePong = "pong" const val TypeError = "error" } /** * A decoded room realtime event. The repository folds these into its - * StateFlows / feeds them to the RoomSyncEngine. + * StateFlows and hands transport commands and clock samples to the player + * binding. */ sealed class RoomRealtimeEvent { /** A physical connection completed its handshake and is writable. */ @@ -25,18 +27,66 @@ sealed class RoomRealtimeEvent { data class SnapshotEvent(val room: RoomSnapshot) : RoomRealtimeEvent() data class TransportCommandEvent(val command: TransportCommand) : RoomRealtimeEvent() data class SuggestionsEvent(val suggestions: List<Suggestion>) : RoomRealtimeEvent() + + /** + * A clock sample. [clientReceivedMs] is stamped on the wall clock when the + * frame left the socket, before any downstream collector could delay it. + */ data class Pong( val clientSentAt: String, val serverReceivedAt: String, val serverSentAt: String, + val clientReceivedMs: Long? = null, ) : RoomRealtimeEvent() - /** Explicit server `room_closed{reason}`. Terminal at the protocol layer. */ + /** + * Explicit server `room_closed{reason}`. Terminal. At connect time the + * reason is `not_found` or `ended`; every later close says `host_left`, + * including the host's explicit End. + */ data class Closed(val reason: String? = null) : RoomRealtimeEvent() - /** Physical EOF, handshake failure, or socket I/O failure. Transient. */ + /** + * Another connection for this account and profile took over the room + * membership. Terminal for this device only: the room keeps going, and + * only an explicit user rejoin may reclaim it. + */ + data class ConnectionReplaced(val reason: String? = null) : RoomRealtimeEvent() + + /** Physical EOF, handshake failure, or socket I/O failure. Transient unless [cause] is terminal. */ data class TransportTerminated(val cause: Throwable? = null) : RoomRealtimeEvent() - /** Server `error{code,message}`. */ + /** Server `error{code,message}`: an action-level rejection, not the end of the room. */ data class Error(val code: String, val message: String) : RoomRealtimeEvent() + + /** + * A frame of a known [type] that did not decode. The repository reconciles + * by reading the room instead of silently dropping state. + */ + data class Malformed(val type: String) : RoomRealtimeEvent() } + +/** + * The room ticket mint refused the request. [terminal] refusals (403, 404, + * 409, 422) end the engagement; anything else is a transport failure that may + * be retried. A 401 left after the interceptor's one refresh is retried too: a + * refresh that failed on the network says nothing about the party, and a real + * sign-out leaves the party through the identity transition. + */ +class RoomTicketRefusedException( + val status: Int, + val code: String, +) : IllegalStateException("room_ticket_refused:$status:$code") { + val terminal: Boolean get() = status in TERMINAL_STATUSES + + private companion object { + val TERMINAL_STATUSES = setOf(403, 404, 409, 422) + } +} + +/** + * The minted ticket was valid but `expires_in` was 0: the access token or the + * room proof has under a second left. Renew whichever is expiring, then mint + * again; minting again unchanged cannot help. + */ +class RoomTicketExpiringException : IllegalStateException("room_ticket_expiring") diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/AuthApi.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/AuthApi.kt index de41773f3d..e340ec9bf2 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/AuthApi.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/AuthApi.kt @@ -8,6 +8,9 @@ import io.ktor.http.* import org.prairieserver.prairie.model.auth.* import org.prairieserver.prairie.network.ApiErrorBody import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.authScope +import org.prairieserver.prairie.network.freshPrairieAuth import org.prairieserver.prairie.network.map import org.prairieserver.prairie.network.singleAttempt import org.prairieserver.prairie.network.skipPrairieAuth @@ -116,6 +119,20 @@ class AuthApi( suspend fun getMe(): ApiResult<User> = safeApiV2Call<Account>(apiV2Gate) { client.get("/api/v2/account/me") }.map { account -> account.toUser() } + /** + * The account signed in on [scope]'s server, which need not be the active + * one. Read by a phone about to approve a TV, so the scope's access token is + * renewed first when it has expired or is about to (TV sign-in spec). + */ + suspend fun getMe(scope: AuthScopeSnapshot): ApiResult<User> = + // Gated on the scope's own server, not the active one. + safeApiV2Call<Account>(apiV2Gate.forServer(scope.serverId)) { + client.get("/api/v2/account/me") { + authScope(scope) + freshPrairieAuth() + } + }.map { account -> account.toUser() } + suspend fun logout(): ApiResult<Unit> = safeApiV2Call(apiV2Gate) { client.post("/api/v2/auth/logout").requireAuthStatus(204) } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/AuthWireV2.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/AuthWireV2.kt index 1ca68336ba..383587888c 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/AuthWireV2.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/AuthWireV2.kt @@ -50,9 +50,12 @@ internal data class DevicePollV2( val temporary: Boolean, val tokens: TokenPairV2? = null, @SerialName("session_expires_at") val sessionExpiresAt: String? = null, + val opened: Boolean = false, + @SerialName("expires_at") val expiresAt: String? = null, ) { fun domain() = DeviceLoginPollResponse(status, pollAfter, tokens?.accessToken, tokens?.refreshToken, - tokens?.expiresIn, tokens?.user?.domain(), profileId, profileToken, temporary, sessionExpiresAt) + tokens?.expiresIn, tokens?.user?.domain(), profileId, profileToken, temporary, sessionExpiresAt, opened, + expiresAt = expiresAt) } @Serializable @@ -61,8 +64,16 @@ internal data class DeviceCapabilityV2( val state: String, @SerialName("remote_playback_handoff") val handoff: Boolean, @SerialName("protocol_versions") val protocols: List<Int>, + val cancel: Boolean = false, + @SerialName("opened_signal") val openedSignal: Boolean = false, ) { - fun domain() = DeviceLoginCapabilityResponse(state == "available" && handoff, protocols) + fun domain() = DeviceLoginCapabilityResponse( + remotePlaybackHandoff = state == "available" && handoff, + protocolVersions = protocols, + deviceLoginAvailable = state == "available", + cancel = state == "available" && cancel, + openedSignal = state == "available" && openedSignal, + ) } internal fun HttpResponse.requireAuthStatus(expected: Int): HttpResponse = also { diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/BrandingApi.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/BrandingApi.kt index 8d8f3c5d81..815fd80979 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/BrandingApi.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/BrandingApi.kt @@ -14,6 +14,17 @@ import org.prairieserver.prairie.network.apiv2.safeApiV2Call data class BrandingStatus( @SerialName("server_name") val serverName: String? = null, + /** White-label line under the sign-in title. */ + @SerialName("login_subtitle") + val loginSubtitle: String? = null, + /** `#RRGGBB` the first-run backdrop tints toward. */ + @SerialName("accent_color") + val accentColor: String? = null, + /** Site-relative paths; resolve them against the server before use. */ + @SerialName("mark_url") + val markUrl: String? = null, + @SerialName("wordmark_url") + val wordmarkUrl: String? = null, ) open class BrandingApi(private val client: HttpClient) { @@ -22,6 +33,13 @@ open class BrandingApi(private val client: HttpClient) { return safeApiV2Call<BrandingStatus>(ApiV2Gate.Unrestricted) { request("/api/v2/theme/branding") } } + /** The same document from the server at [serverUrl], whether or not it is the active one. */ + open suspend fun getBranding(serverUrl: String): ApiResult<BrandingStatus> { + return safeApiV2Call<BrandingStatus>(ApiV2Gate.Unrestricted) { + request("${serverUrl.trimEnd('/')}/api/v2/theme/branding") + } + } + private suspend fun request(path: String) = client.get(path) { // Public identity probe, like checkHealth() which it replaced as // the primary source of a server's display name. Without this it diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/CatalogApi.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/CatalogApi.kt index 5ae43ffd7e..f408b8c261 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/CatalogApi.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/CatalogApi.kt @@ -21,6 +21,7 @@ class CatalogApi(client: HttpClient, private val v2: CatalogV2Api = CatalogV2Api continuation: CatalogContinuationV2? = null, limit: Int? = null, namePrefix: String? = null, yearMin: Int? = null, yearMax: Int? = null, queryGroups: List<CatalogQueryGroup> = emptyList(), match: String? = null, + skipTotal: Boolean = false, ): ApiResult<CatalogResponse> { val groups = queryGroups.toV2Groups().toMutableList() val implicit = buildList { @@ -38,7 +39,8 @@ class CatalogApi(client: HttpClient, private val v2: CatalogV2Api = CatalogV2Api } return v2.browse(CatalogQueryV2(source = source ?: "query", q = query, type = mediaType, libraryId = libraryId?.toString(), sort = sort, order = order, limit = limit ?: 50, - namePrefix = namePrefix, groups = groups, match = match), continuation).map { it.toCatalogResponse() } + namePrefix = namePrefix, groups = groups, match = match, skipTotal = skipTotal), continuation) + .map { it.toCatalogResponse() } } suspend fun getAudiobookGroups(libraryId: Int, groupBy: String, sort: String = "name", @@ -77,7 +79,7 @@ class CatalogApi(client: HttpClient, private val v2: CatalogV2Api = CatalogV2Api suspend fun captureWatchAuthority() = watchDetail.capture() suspend fun isWatchAuthorityCurrent(owner: AuthScopeSnapshot) = watchDetail.current(owner) - suspend fun getWatchDetail(id: String, owner: AuthScopeSnapshot, libraryId: Int? = null) = watchDetail.detail(id, owner, libraryId) + suspend fun getWatchDetail(id: String, owner: AuthScopeSnapshot, libraryId: Int? = null, fileId: Int? = null) = watchDetail.detail(id, owner, libraryId, fileId) /** Captures the current viewer at call time when the caller has no retained owner. */ suspend fun getWatchDetail(id: String, libraryId: Int? = null): ApiResult<WatchDetail> { diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/DeviceLoginApi.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/DeviceLoginApi.kt index e8d8eb4c46..8d2d51b8f8 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/DeviceLoginApi.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/DeviceLoginApi.kt @@ -1,5 +1,6 @@ package org.prairieserver.prairie.network.api +import org.prairieserver.prairie.model.auth.DeviceLoginCancelResponse import org.prairieserver.prairie.model.auth.DeviceLoginPollRequest import org.prairieserver.prairie.model.auth.DeviceLoginPollResponse import org.prairieserver.prairie.model.auth.DeviceLoginDecisionRequest @@ -15,6 +16,7 @@ import org.prairieserver.prairie.network.singleAttempt import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.AuthScopeSnapshot import org.prairieserver.prairie.network.authScope +import org.prairieserver.prairie.network.freshPrairieAuth import org.prairieserver.prairie.network.skipPrairieAuth import io.ktor.client.HttpClient import io.ktor.client.request.get @@ -71,6 +73,25 @@ interface DeviceLoginApi { message = "Remote playback handoff is not supported.", ) + /** + * The device-pairing capability document at [serverUrl]: whether device + * sign-in is configured, and whether the server supports cancel and the + * opened signal. Same document [remotePlaybackCapabilityAt] reads. + */ + suspend fun deviceLoginCapabilityAt( + serverUrl: String, + ): ApiResult<DeviceLoginCapabilityResponse> = remotePlaybackCapabilityAt(serverUrl) + + /** + * Withdraw this device's own request (`POST /auth/device/cancel`) so an + * abandoned code can't be approved later. Only call it when the + * capability reports `cancel`. + */ + suspend fun cancelDeviceLoginAt( + serverUrl: String, + deviceCode: String, + ): ApiResult<DeviceLoginCancelResponse> = unsupportedScopedDeviceLoginOperation() + suspend fun startRemotePlaybackAt( serverUrl: String, deviceName: String?, @@ -182,6 +203,22 @@ class DefaultDeviceLoginApi(private val client: HttpClient, private val gate: Ap }.requireAuthStatus(200) }.map { it.domain() } + override suspend fun deviceLoginCapabilityAt( + serverUrl: String, + ): ApiResult<DeviceLoginCapabilityResponse> = remotePlaybackCapabilityAt(serverUrl) + + override suspend fun cancelDeviceLoginAt( + serverUrl: String, + deviceCode: String, + ): ApiResult<DeviceLoginCancelResponse> = safeApiV2Call(ApiV2Gate.Unrestricted) { + client.post("${serverUrl.trimEnd('/')}/api/v2/auth/device/cancel") { + skipPrairieAuth() + singleAttempt() + contentType(ContentType.Application.Json) + setBody(DeviceLoginPollRequest(deviceCode)) + }.requireAuthStatus(200) + } + override suspend fun startRemotePlaybackAt( serverUrl: String, deviceName: String?, @@ -218,6 +255,8 @@ class DefaultDeviceLoginApi(private val client: HttpClient, private val gate: Ap code: String?, ): ApiResult<DeviceLoginDecisionResponse> = safeApiV2Call(gate) { client.post("/api/v2/auth/device/approve") { + // The approver refreshes before deciding (TV sign-in spec). + freshPrairieAuth() contentType(ContentType.Application.Json) setBody(DeviceLoginDecisionRequest(token = token, code = code)) }.requireAuthStatus(200) @@ -228,6 +267,8 @@ class DefaultDeviceLoginApi(private val client: HttpClient, private val gate: Ap code: String?, ): ApiResult<DeviceLoginDecisionResponse> = safeApiV2Call(gate) { client.post("/api/v2/auth/device/deny") { + // The approver refreshes before deciding (TV sign-in spec). + freshPrairieAuth() contentType(ContentType.Application.Json) setBody(DeviceLoginDecisionRequest(token = token, code = code)) }.requireAuthStatus(200) @@ -236,7 +277,7 @@ class DefaultDeviceLoginApi(private val client: HttpClient, private val gate: Ap override suspend fun lookupDeviceLoginForScope( scope: AuthScopeSnapshot, code: String, - ): ApiResult<DeviceLoginLookupResponse> = safeApiV2Call(gate) { + ): ApiResult<DeviceLoginLookupResponse> = safeApiV2Call(gate.forServer(scope.serverId)) { client.get("/api/v2/auth/device") { skipPrairieAuth() authScope(scope) @@ -247,9 +288,11 @@ class DefaultDeviceLoginApi(private val client: HttpClient, private val gate: Ap override suspend fun approveDeviceLoginForScope( scope: AuthScopeSnapshot, code: String, - ): ApiResult<DeviceLoginDecisionResponse> = safeApiV2Call(gate) { + ): ApiResult<DeviceLoginDecisionResponse> = safeApiV2Call(gate.forServer(scope.serverId)) { client.post("/api/v2/auth/device/approve") { authScope(scope) + // The approver refreshes before deciding (TV sign-in spec). + freshPrairieAuth() contentType(ContentType.Application.Json) setBody(DeviceLoginDecisionRequest(code = code)) }.requireAuthStatus(200) @@ -276,9 +319,11 @@ class DefaultDeviceLoginApi(private val client: HttpClient, private val gate: Ap override suspend fun denyDeviceLoginForScope( scope: AuthScopeSnapshot, code: String, - ): ApiResult<DeviceLoginDecisionResponse> = safeApiV2Call(gate) { + ): ApiResult<DeviceLoginDecisionResponse> = safeApiV2Call(gate.forServer(scope.serverId)) { client.post("/api/v2/auth/device/deny") { authScope(scope) + // The approver refreshes before deciding (TV sign-in spec). + freshPrairieAuth() contentType(ContentType.Application.Json) setBody(DeviceLoginDecisionRequest(code = code)) }.requireAuthStatus(200) diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/ExternalSignInApi.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/ExternalSignInApi.kt new file mode 100644 index 0000000000..67090d2f97 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/ExternalSignInApi.kt @@ -0,0 +1,484 @@ +package org.prairieserver.prairie.network.api + +import io.ktor.client.HttpClient +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import io.ktor.http.isSuccess +import kotlinx.coroutines.CancellationException +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.http.encodeURLPathPart +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject +import org.prairieserver.prairie.model.auth.AccountIdentities +import org.prairieserver.prairie.model.auth.AccountIdentity +import org.prairieserver.prairie.model.auth.AccountIdentityLinkTicket +import org.prairieserver.prairie.model.auth.ExternalSignInCapabilities +import org.prairieserver.prairie.model.auth.LoginResponse +import org.prairieserver.prairie.model.auth.NetworkIdentity +import org.prairieserver.prairie.model.auth.OAuthHandshakeCapabilities +import org.prairieserver.prairie.model.auth.SignInProvider +import org.prairieserver.prairie.model.auth.SignInProviders +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.PrairieJson +import org.prairieserver.prairie.network.apiv2.Problem +import org.prairieserver.prairie.network.apiv2.ApiV2Gate +import org.prairieserver.prairie.network.apiv2.safeApiV2Call +import org.prairieserver.prairie.network.authScope +import org.prairieserver.prairie.network.freshPrairieAuth +import org.prairieserver.prairie.network.map +import org.prairieserver.prairie.network.singleAttempt +import org.prairieserver.prairie.network.skipPrairieAuth + +/** + * External sign-in (OIDC, LDAP, network identity) operations the apps use: + * provider discovery, the native OAuth handoff's completion, the network + * identity sign-in, and the account's Sign-in section. + * Contract: prairie-server `docs/auth-api.md` ("External sign-in", "OAuth sign-in + * flows"). Nothing here retries; the completions are single use. + */ +interface ExternalSignInApi { + /** `listAuthProviders` at [serverUrl], unauthenticated. */ + suspend fun listProviders(serverUrl: String): ApiResult<SignInProviders> + + /** `getOAuthHandshakeCapabilities` at [serverUrl], unauthenticated. */ + suspend fun oauthCapabilities(serverUrl: String): ApiResult<OAuthHandshakeCapabilities> + + /** + * `getExternalSignInCapabilities` at [scope]'s server: whether it serves + * the account's identities (`listAccountIdentities`, `deleteAccountIdentity`). + */ + suspend fun externalSignInCapabilities(scope: AuthScopeSnapshot): ApiResult<ExternalSignInCapabilities> + + /** `completeOAuthLogin` for a native code: the token pair and the account. */ + suspend fun completeOAuthLogin(serverUrl: String, code: String, codeVerifier: String): ApiResult<LoginResponse> + + /** + * `signInWithNetworkIdentity`: POST `{}` to [signInPath] on the saved base + * [serverUrl], unauthenticated. [signInPath] is a provider's + * [SignInProvider.networkSignInPath], which discovery already reduced to + * the base-relative route, so nothing is posted to another origin. + * Answers `login`'s token pair and account. Never retried: each attempt + * spends the server's login rate-limit budget. + */ + suspend fun signInWithNetworkIdentity(serverUrl: String, signInPath: String): ApiResult<LoginResponse> + + /** `listAccountIdentities` for [scope]'s account. */ + suspend fun listIdentities(scope: AuthScopeSnapshot): ApiResult<AccountIdentities> + + /** `deleteAccountIdentity`: disconnect one of [scope]'s identities. */ + suspend fun deleteIdentity(scope: AuthScopeSnapshot, identityId: String): ApiResult<Unit> + + /** `createAccountIdentityLinkTicket` after the account re-enters its local password. */ + suspend fun createLinkTicket( + scope: AuthScopeSnapshot, + installationId: String, + password: String, + ): ApiResult<AccountIdentityLinkTicket> + + /** `completeAccountIdentityLink`: confirm a native linking flow's code. */ + suspend fun completeLink(scope: AuthScopeSnapshot, code: String, codeVerifier: String): ApiResult<Unit> + + /** + * `linkAccountIdentityWithCredentials`: link [scope]'s account to a + * directory (LDAP) identity, confirming the account's local [password] + * and signing in to the directory as [username] with [directoryPassword]. + */ + suspend fun linkWithCredentials( + scope: AuthScopeSnapshot, + installationId: String, + password: String, + username: String, + directoryPassword: String, + ): ApiResult<AccountIdentity> + + /** + * `linkAccountIdentityWithNetwork`: link [scope]'s account to the person + * who owns this device at network provider [installationId], confirming + * the account's local [password]. The server allows it only through that + * provider's network. + */ + suspend fun linkWithNetwork( + scope: AuthScopeSnapshot, + installationId: String, + password: String, + ): ApiResult<AccountIdentity> +} + +class DefaultExternalSignInApi( + private val client: HttpClient, + private val apiV2Gate: ApiV2Gate, +) : ExternalSignInApi { + + // Provider discovery is public and addressed by URL, often to a saved + // server other than the active one, so the active server's update verdict + // doesn't gate it (like the other URL-addressed sign-in reads). A server + // without it answers 404, which leaves the password form alone. + override suspend fun listProviders(serverUrl: String): ApiResult<SignInProviders> = + safeApiV2Call<AuthProviderCollectionV2>(ApiV2Gate.Unrestricted) { + client.get("${serverUrl.trimEnd('/')}/api/v2/auth/providers") { skipPrairieAuth() } + }.map { it.domain(serverUrl) } + + override suspend fun oauthCapabilities(serverUrl: String): ApiResult<OAuthHandshakeCapabilities> = + safeApiV2Call<OAuthHandshakeCapabilitiesV2>(ApiV2Gate.Unrestricted) { + client.get("${serverUrl.trimEnd('/')}/api/v2/auth/oauth/capabilities") { skipPrairieAuth() } + }.map { it.domain() } + + override suspend fun externalSignInCapabilities(scope: AuthScopeSnapshot): ApiResult<ExternalSignInCapabilities> = + safeApiV2Call<ExternalSignInCapabilitiesV2>(apiV2Gate.forServer(scope.serverId)) { + client.get("/api/v2/auth/external-sign-in/capabilities") { authScope(scope) } + }.map { it.domain() } + + override suspend fun completeOAuthLogin( + serverUrl: String, + code: String, + codeVerifier: String, + ): ApiResult<LoginResponse> = safeApiV2Call<TokenPairV2>(apiV2Gate) { + client.post("${serverUrl.trimEnd('/')}/api/v2/auth/oauth/complete") { + skipPrairieAuth(); singleAttempt() + contentType(ContentType.Application.Json) + setBody(CompleteOAuthLoginV2(code = code, codeVerifier = codeVerifier)) + }.requireAuthStatus(200) + }.map { it.domain() } + + override suspend fun signInWithNetworkIdentity(serverUrl: String, signInPath: String): ApiResult<LoginResponse> = + safeApiV2Call<TokenPairV2>(apiV2Gate) { + client.post("${serverUrl.trimEnd('/')}$signInPath") { + skipPrairieAuth(); singleAttempt() + // The server refuses a sign-in without a JSON body (415), which + // keeps a cross-site form post from signing a device in. + contentType(ContentType.Application.Json) + setBody(JsonObject(emptyMap())) + }.requireAuthStatus(200) + }.map { it.domain() } + + override suspend fun listIdentities(scope: AuthScopeSnapshot): ApiResult<AccountIdentities> = + safeApiV2Call<AccountIdentityCollectionV2>(apiV2Gate.forServer(scope.serverId)) { + client.get("/api/v2/account/identities") { authScope(scope) } + }.map { collection -> AccountIdentities(collection.items.map { it.domain() }, collection.canUnlink) } + + override suspend fun deleteIdentity(scope: AuthScopeSnapshot, identityId: String): ApiResult<Unit> = + safeApiV2Call<Unit>(apiV2Gate.forServer(scope.serverId)) { + client.delete("/api/v2/account/identities/${identityId.encodeURLPathPart()}") { + authScope(scope); singleAttempt() + } + } + + override suspend fun createLinkTicket( + scope: AuthScopeSnapshot, + installationId: String, + password: String, + ): ApiResult<AccountIdentityLinkTicket> = locatedCall(scope, expected = 200, decode = { body -> + PrairieJson.decodeFromString(LinkTicketV2.serializer(), body).let { + AccountIdentityLinkTicket(ticket = it.ticket, expiresAt = it.expiresAt) + } + }) { + client.post("/api/v2/account/identities/link-ticket") { + authScope(scope); freshPrairieAuth(); singleAttempt() + contentType(ContentType.Application.Json) + setBody(LinkTicketRequestV2(installationId = installationId, password = password)) + } + } + + override suspend fun completeLink(scope: AuthScopeSnapshot, code: String, codeVerifier: String): ApiResult<Unit> = + safeApiV2Call<Unit>(apiV2Gate.forServer(scope.serverId)) { + client.post("/api/v2/account/identities/link-complete") { + authScope(scope); freshPrairieAuth(); singleAttempt() + contentType(ContentType.Application.Json) + setBody(CompleteOAuthLoginV2(code = code, codeVerifier = codeVerifier)) + } + } + + override suspend fun linkWithCredentials( + scope: AuthScopeSnapshot, + installationId: String, + password: String, + username: String, + directoryPassword: String, + ): ApiResult<AccountIdentity> = locatedCall(scope, expected = 201, decode = { body -> + PrairieJson.decodeFromString(AccountIdentityV2.serializer(), body).domain() + }) { + client.post("/api/v2/account/identities/link-credentials") { + authScope(scope); freshPrairieAuth(); singleAttempt() + contentType(ContentType.Application.Json) + setBody( + LinkCredentialsRequestV2( + installationId = installationId, + password = password, + username = username, + directoryPassword = directoryPassword, + ), + ) + } + } + + override suspend fun linkWithNetwork( + scope: AuthScopeSnapshot, + installationId: String, + password: String, + ): ApiResult<AccountIdentity> = locatedCall(scope, expected = 201, decode = { body -> + PrairieJson.decodeFromString(AccountIdentityV2.serializer(), body).domain() + }) { + client.post("/api/v2/account/identities/link-network") { + authScope(scope); freshPrairieAuth(); singleAttempt() + contentType(ContentType.Application.Json) + setBody(LinkNetworkRequestV2(installationId = installationId, password = password)) + } + } + + /** + * One v2 exchange whose 422s differ only by the member the problem names + * (see [locatedError]); everything else is as [safeApiV2Call]. + */ + private suspend fun <T> locatedCall( + scope: AuthScopeSnapshot, + expected: Int, + decode: (String) -> T, + block: suspend () -> HttpResponse, + ): ApiResult<T> { + apiV2Gate.forServer(scope.serverId).blocked()?.let { return it } + return try { + val response = block() + val body = response.bodyAsText() + when { + response.status.value == expected -> ApiResult.Success(decode(body)) + response.status.isSuccess() -> ApiResult.Error(0, "invalid_response", "Unexpected response status.") + else -> locatedError(response.status.value, body) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + ApiResult.NetworkError(e) + } + } + + companion object { + /** A 422 at `body.password`: the account's local password is wrong. */ + const val WRONG_PASSWORD = "wrong_password" + + /** A 422 at `body.directory_password`: the directory refused the username and password. */ + const val DIRECTORY_REFUSED = "directory_refused" + + /** + * The problem as an [ApiResult.Error] whose `error` is the problem + * code, except for a 422 that names `body.directory_password` + * ([DIRECTORY_REFUSED]) or `body.password` ([WRONG_PASSWORD]): those + * two refusals share `validation_failed` and differ only by member. + */ + internal fun locatedError(status: Int, body: String): ApiResult.Error { + val problem = runCatching { PrairieJson.decodeFromString(Problem.serializer(), body) }.getOrNull() + val locations = problem?.errors.orEmpty().map { it.location } + val code = when { + status == 422 && "body.directory_password" in locations -> DIRECTORY_REFUSED + status == 422 && "body.password" in locations -> WRONG_PASSWORD + else -> problem?.code.orEmpty() + } + return ApiResult.Error(status, code, problem?.detail.orEmpty()) + } + } +} + +@Serializable +internal data class AuthProviderV2( + val id: String, + @SerialName("display_name") val displayName: String, + val mode: String, + val default: Boolean = false, + @SerialName("icon_url") val iconUrl: String? = null, + @SerialName("installation_id") val installationId: String? = null, + @SerialName("native_start_path") val nativeStartPath: String? = null, + @SerialName("network_sign_in_path") val networkSignInPath: String? = null, + @SerialName("network_identity") val networkIdentity: AuthProviderNetworkIdentityV2? = null, +) { + fun domain(serverUrl: String) = SignInProvider( + id = id, + displayName = displayName, + mode = when (mode) { + "credentials" -> SignInProvider.Mode.Credentials + "oauth" -> SignInProvider.Mode.OAuth + "network" -> SignInProvider.Mode.Network + else -> SignInProvider.Mode.Unknown + }, + isDefault = default, + iconUrl = iconUrl?.trim()?.takeIf { it.isNotEmpty() }?.let { absoluteUrl(it, serverUrl) }, + installationId = installationId?.takeIf { it.isNotBlank() }, + // Only the paths are kept: the app uses them on its own saved server base. + nativeStartPath = nativeStartPath(nativeStartPath), + networkSignInPath = networkSignInPath(networkSignInPath), + networkIdentity = networkIdentity?.let { NetworkIdentity(displayName = it.displayName, username = it.username) }, + ) +} + +@Serializable +internal data class AuthProviderNetworkIdentityV2( + @SerialName("display_name") val displayName: String = "", + val username: String = "", +) + +/** + * The native start relative to the server base: `/api/v2/auth/oauth/{id}/native/start` + * and any query, taken from `native_start_path`. An oauth provider without it + * offers no native sign-in. Whatever comes before `/api/v2/` (a reverse + * proxy's path prefix) is dropped: apps resolve the path against their own + * saved base URL and never open another origin. + * Null for anything that isn't that route. + */ +internal fun nativeStartPath(path: String?): String? = baseRelativeRoute(path, NATIVE_START_PREFIX, NATIVE_START_ROUTE) + +/** + * `signInWithNetworkIdentity` relative to the server base + * (`/api/v2/auth/network/{id}/sign-in`), taken from `network_sign_in_path` + * the way [nativeStartPath] takes the native start: a reverse proxy's path + * prefix is dropped, and anything that isn't that route is null. + */ +internal fun networkSignInPath(path: String?): String? = baseRelativeRoute(path, NETWORK_SIGN_IN_PREFIX, NETWORK_SIGN_IN_ROUTE) + +private fun baseRelativeRoute(path: String?, prefix: String, route: Regex): String? { + val raw = path?.trim() + ?.takeIf { it.startsWith("/") && !it.startsWith("//") } + ?.substringBefore('#') ?: return null + val start = raw.substringBefore('?').indexOf(prefix) + if (start < 0) return null + return raw.substring(start).takeIf { route.matches(it) } +} + +private const val NATIVE_START_PREFIX = "/api/v2/auth/oauth/" +private val NATIVE_START_ROUTE = Regex("^/api/v2/auth/oauth/[A-Za-z0-9._~%-]+/native/start(\\?[^#\\s]*)?$") +private const val NETWORK_SIGN_IN_PREFIX = "/api/v2/auth/network/" +private val NETWORK_SIGN_IN_ROUTE = Regex("^/api/v2/auth/network/[A-Za-z0-9._~%-]+/sign-in$") + +/** A site-relative icon path (`/api/v2/plugin-content/...`) resolves against the server that listed it. */ +internal fun absoluteUrl(url: String, serverUrl: String): String? = when { + url.startsWith("https://") || url.startsWith("http://") -> url + url.startsWith("/") && !url.startsWith("//") -> serverUrl.trimEnd('/') + url + else -> null +} + +@Serializable +internal data class AuthProviderCollectionV2( + val items: List<AuthProviderV2>, + @SerialName("password_login") val passwordLogin: Boolean = true, +) { + fun domain(serverUrl: String) = SignInProviders( + providers = items.map { it.domain(serverUrl) }, + passwordLogin = passwordLogin, + ) +} + +@Serializable +internal data class OAuthHandshakeCapabilitiesV2( + val state: String? = null, + val available: Boolean = false, + val native: Boolean = false, + val linking: Boolean = false, + @SerialName("select_account") val selectAccount: Boolean = false, +) { + fun domain(): OAuthHandshakeCapabilities { + val usable = available && (state == null || state == "available") + return OAuthHandshakeCapabilities( + available = usable, + native = usable && native, + linking = usable && linking, + selectAccount = usable && selectAccount, + ) + } +} + +@Serializable +internal data class ExternalSignInCapabilitiesV2( + val state: String? = null, + val available: Boolean = false, + val identities: Boolean = false, + @SerialName("credentials_linking") val credentialsLinking: Boolean = false, + @SerialName("network_sign_in") val networkSignIn: Boolean = false, + @SerialName("network_link_keeps_password") val networkLinkKeepsPassword: Boolean = false, +) { + fun domain(): ExternalSignInCapabilities { + val usable = available && (state == null || state == "available") + return ExternalSignInCapabilities( + identities = usable && identities, + credentialsLinking = usable && credentialsLinking, + networkSignIn = usable && networkSignIn, + networkLinkKeepsPassword = usable && networkSignIn && networkLinkKeepsPassword, + ) + } +} + +@Serializable +internal data class CompleteOAuthLoginV2( + val code: String, + @SerialName("code_verifier") val codeVerifier: String, +) { + override fun toString(): String = "CompleteOAuthLoginV2(<redacted>)" +} + +@Serializable +internal data class AccountIdentityV2( + val id: String, + @SerialName("installation_id") val installationId: String, + @SerialName("provider_id") val providerId: String = "", + @SerialName("provider_name") val providerName: String = "", + val username: String = "", + val email: String = "", + @SerialName("display_name") val displayName: String = "", + @SerialName("linked_at") val linkedAt: String, + @SerialName("last_sign_in_at") val lastSignInAt: String? = null, +) { + fun domain() = AccountIdentity( + id = id, + installationId = installationId, + providerId = providerId, + providerName = providerName, + username = username, + email = email, + displayName = displayName, + linkedAt = linkedAt, + lastSignInAt = lastSignInAt, + ) +} + +@Serializable +internal data class AccountIdentityCollectionV2( + val items: List<AccountIdentityV2>, + @SerialName("can_unlink") val canUnlink: Boolean? = null, +) + +@Serializable +internal data class LinkTicketRequestV2( + @SerialName("installation_id") val installationId: String, + val password: String, +) { + override fun toString(): String = "LinkTicketRequestV2(installationId=$installationId, password=<redacted>)" +} + +@Serializable +internal data class LinkTicketV2( + val ticket: String, + @SerialName("expires_at") val expiresAt: String, +) { + override fun toString(): String = "LinkTicketV2(<redacted>)" +} + +@Serializable +internal data class LinkCredentialsRequestV2( + @SerialName("installation_id") val installationId: String, + val password: String, + val username: String, + @SerialName("directory_password") val directoryPassword: String, +) { + override fun toString(): String = "LinkCredentialsRequestV2(installationId=$installationId, <credentials redacted>)" +} + +@Serializable +internal data class LinkNetworkRequestV2( + @SerialName("installation_id") val installationId: String, + val password: String, +) { + override fun toString(): String = "LinkNetworkRequestV2(installationId=$installationId, password=<redacted>)" +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/ProfileApi.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/ProfileApi.kt index 8485138f5a..58086bee6f 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/ProfileApi.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/ProfileApi.kt @@ -29,17 +29,38 @@ class ProfileApi( // Profile headers remain optional: the picker and first-profile bootstrap // run before selection. When present, retain the captured manager PIN proof. + // [actingAs] replaces only this request's profile headers; the device's + // selected profile is untouched. private suspend inline fun <reified T> exchange( path: String, method: HttpMethod, status: HttpStatusCode, nonRetryable: Boolean = false, + actingAs: HouseholdManager? = null, + withoutProfile: Boolean = false, noinline configure: HttpRequestBuilder.() -> Unit = {}, ): ApiResult<T> { val scope = tokens?.snapshotCurrentScope() if (tokens != null && scope == null) return identityChanged() - return ownedV2Call<T, T>(apiV2Gate, tokens, scope, OwnerPolicy.IDENTITY, status, { owner -> + // A manager proof belongs to the account it was verified under; never + // present it on another account's request. + if (actingAs?.scope != null && !actingAs.scope.isSameAccountAs(scope)) return identityChanged() + // The pinned path rewrites profile headers from the scope, so the acting + // profile goes into the pinned scope rather than onto the request. + val pinned = when { + scope == null -> null + actingAs != null -> scope.copy(profileId = actingAs.profileId, profileToken = actingAs.profileToken) + withoutProfile -> scope.copy(profileId = null, profileToken = null) + else -> scope + } + return ownedV2Call<T, T>(apiV2Gate, tokens, pinned, OwnerPolicy.IDENTITY, status, { owner -> client.request(path) { this.method = method owner?.let { authScope(it) } + if (owner == null && actingAs != null) { + headers.remove(PROFILE_ID_HEADER) + headers.remove(PROFILE_TOKEN_HEADER) + header(PROFILE_ID_HEADER, actingAs.profileId) + actingAs.profileToken?.let { header(PROFILE_TOKEN_HEADER, it) } + } requirePrairieAuth() if (nonRetryable) singleAttempt() configure() @@ -51,8 +72,11 @@ class ProfileApi( exchange<ProfileCollectionV2>("/api/v2/profiles", HttpMethod.Get, HttpStatusCode.OK) .map { ProfilesResponse(it.items.map { profile -> profile.toProfile() }) } - suspend fun createProfile(request: CreateProfileRequest): ApiResult<Profile> = - exchange<ProfileV2>("/api/v2/profiles", HttpMethod.Post, HttpStatusCode.Created, nonRetryable = true) { + suspend fun createProfile( + request: CreateProfileRequest, + actingAs: HouseholdManager? = null, + ): ApiResult<Profile> = + exchange<ProfileV2>("/api/v2/profiles", HttpMethod.Post, HttpStatusCode.Created, nonRetryable = true, actingAs = actingAs) { contentType(ContentType.Application.Json) // Create does not accept null; library identifiers are v2 strings. val fields = PrairieJson.encodeToJsonElement(CreateProfileRequest.serializer(), request).jsonObject @@ -66,29 +90,81 @@ class ProfileApi( // PATCH v2 only; a failed mutation is never replayed. suspend fun updateProfile( id: String, - request: UpdateProfileRequest - ): ApiResult<Profile> = updateProfile(id, request.toProfileUpdate()) + request: UpdateProfileRequest, + actingAs: HouseholdManager? = null, + ): ApiResult<Profile> = updateProfile(id, request.toProfileUpdate(), actingAs) suspend fun updateProfile( id: String, update: ProfileUpdate, + actingAs: HouseholdManager? = null, ): ApiResult<Profile> = - exchange<ProfileV2>("/api/v2/profiles/${id.encodeURLPathPart()}", HttpMethod.Patch, HttpStatusCode.OK, nonRetryable = true) { + exchange<ProfileV2>( + "/api/v2/profiles/${id.encodeURLPathPart()}", HttpMethod.Patch, HttpStatusCode.OK, + nonRetryable = true, actingAs = actingAs, + ) { contentType(ContentType.Application.Json) setBody(update.toJsonObject()) }.map { profile -> profile.toProfile() } - suspend fun deleteProfile(id: String): ApiResult<Unit> = - exchange("/api/v2/profiles/${id.encodeURLPathPart()}", HttpMethod.Delete, HttpStatusCode.NoContent, nonRetryable = true) + suspend fun deleteProfile(id: String, actingAs: HouseholdManager? = null): ApiResult<Unit> = + exchange( + "/api/v2/profiles/${id.encodeURLPathPart()}", HttpMethod.Delete, HttpStatusCode.NoContent, + nonRetryable = true, actingAs = actingAs, + ) + /** + * Sent with no profile headers, on the account only. Declaring the device's + * selected profile would make the server check that profile's stored token + * first, and a stale one (its PIN or the account's access policy changed + * since) turns every correct PIN into 403 `profile_verification_required`. + */ suspend fun verifyPin(id: String, pin: String): ApiResult<VerifyPinResponse> = - exchange("/api/v2/profiles/${id.encodeURLPathPart()}/verify-pin", HttpMethod.Post, HttpStatusCode.OK) { + exchange( + "/api/v2/profiles/${id.encodeURLPathPart()}/verify-pin", HttpMethod.Post, HttpStatusCode.OK, + withoutProfile = true, + ) { contentType(ContentType.Application.Json) setBody(VerifyPinRequest(pin)) } } +/** + * The profile household management acts as: the account's primary profile, + * plus the token `verify-pin` issued for it when it has a PIN. The server + * lets only the primary profile create, edit, or delete household profiles. + * + * [scope] is the identity the proof was obtained under. A call made after the + * account or server changed fails with `identity_changed` instead of carrying + * this account's profile proof onto another account's request. + */ +class HouseholdManager( + val profileId: String, + val profileToken: String?, + val scope: AuthScopeSnapshot?, +) { + override fun toString(): String = "HouseholdManager(profileId=<redacted>, profileToken=<redacted>, scope=<redacted>)" +} + +/** + * Same signed-in account on the same server as [current], whichever profile + * the device has selected. The picker clears the selection when the selected + * profile is deleted, and that profile switch must not void the manager's + * proof; a server switch, sign-in, sign-out, or remote-playback overlay does. + */ +internal fun AuthScopeSnapshot.isSameAccountAs(current: AuthScopeSnapshot?): Boolean { + if (current == null || current.serverId != serverId) return false + if (current.credentialGenerationId != credentialGenerationId) return false + // Only a saved account's stamped epoch tells two sign-ins apart. Without + // one (an overlay, or an unstamped scope), keep the full identity check. + if (credentialGenerationId != null || credentialEpoch == 0L) return isSameIdentityAs(current) + return current.credentialEpoch == credentialEpoch +} + +private const val PROFILE_ID_HEADER = "X-Profile-Id" +private const val PROFILE_TOKEN_HEADER = "X-Profile-Token" + /** * v1 request semantics on the v2 PATCH: a null member is omitted (unchanged); * an empty string on a clearable member is the v1 clearing form and becomes a diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/RequestsApi.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/RequestsApi.kt index 70b6d4630c..6b5fe8b85a 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/RequestsApi.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/RequestsApi.kt @@ -1,9 +1,13 @@ package org.prairieserver.prairie.network.api +import org.prairieserver.prairie.model.request.AdminRequestAction +import org.prairieserver.prairie.model.request.AdminRequestActionBody +import org.prairieserver.prairie.model.request.AdminRequestCapabilities import org.prairieserver.prairie.model.request.CreateMediaRequest import org.prairieserver.prairie.model.request.MediaRequest import org.prairieserver.prairie.model.request.RequestMediaDetail import org.prairieserver.prairie.model.request.RequestMediaPage +import org.prairieserver.prairie.model.request.RequestMediaType import org.prairieserver.prairie.model.request.RequestsDiscoverResponse import org.prairieserver.prairie.model.request.RequestsFeatureStatus import org.prairieserver.prairie.model.request.RequestsListResponse @@ -54,6 +58,35 @@ interface RequestsApi { suspend fun get(id: String): ApiResult<MediaRequest> suspend fun cancel(id: String): ApiResult<MediaRequest> + + /** + * `GET /api/v2/admin/requests/capabilities`. Answers only for an admin + * acting as the account's primary profile; anyone else gets a problem, + * which callers read as "can't moderate". + */ + suspend fun adminCapabilities(): ApiResult<AdminRequestCapabilities> + + /** + * Every request matching the filter, across all users, loaded completely + * or not at all. [tmdbId] narrows the list to one title through `q`, which + * also matches titles containing the number, so callers still match the id. + */ + suspend fun adminRequests( + status: String? = null, + outcome: String? = null, + mediaType: String? = null, + tmdbId: Int? = null, + ): ApiResult<RequestsListResponse> + + /** + * The first page of [adminRequests] and whether more follow: enough for a + * count badge without reading the whole queue. + */ + suspend fun adminRequestsFirstPage(status: String? = null, outcome: String? = null): ApiResult<RequestsListResponse> = + adminRequests(status, outcome) + + /** Approve, decline, or retry someone's request. `non_retryable`: never resent after an uncertain outcome. */ + suspend fun adminAction(id: String, action: AdminRequestAction, reason: String? = null): ApiResult<MediaRequest> } class DefaultRequestsApi( @@ -106,17 +139,59 @@ class DefaultRequestsApi( offset: Int?, ): ApiResult<RequestsListResponse> { require(offset == null || offset == 0) { "Request paging uses cursors, not offsets." } + return pagedRequests("/api/v2/requests/mine", OwnerPolicy.IDENTITY, maxPages = 100, limit = limit) { + parameter("status", status) + parameter("outcome", outcome) + } + } + + override suspend fun adminRequests( + status: String?, + outcome: String?, + mediaType: String?, + tmdbId: Int?, + ): ApiResult<RequestsListResponse> = + pagedRequests("/api/v2/admin/requests", OwnerPolicy.PROFILE, maxPages = 40, limit = null) { + parameter("status", status) + parameter("outcome", outcome) + parameter("media_type", mediaType?.takeIf { it == RequestMediaType.Movie || it == RequestMediaType.Series }) + parameter("q", tmdbId?.toString()) + } + + override suspend fun adminRequestsFirstPage(status: String?, outcome: String?): ApiResult<RequestsListResponse> { + val pinned = tokenManager?.snapshotCurrentScope() + return ownedV2Call<RequestsListResponse, RequestsListResponse>(gate, tokenManager, pinned, OwnerPolicy.PROFILE, null, { owner -> + client.get("/api/v2/admin/requests") { + owner?.let { authScope(it) } + parameter("status", status) + parameter("outcome", outcome) + parameter("limit", 50) + } + }) { it } + } + + /** + * Follows `page.next_cursor` under one captured owner. A failed page, a + * missing or repeated cursor, or the page bound fails the whole load + * instead of returning a partial list. + */ + private suspend fun pagedRequests( + path: String, + policy: OwnerPolicy, + maxPages: Int, + limit: Int?, + filters: io.ktor.client.request.HttpRequestBuilder.() -> Unit, + ): ApiResult<RequestsListResponse> { val size = (limit ?: 50).coerceIn(1, 50) val pinned = tokenManager?.snapshotCurrentScope() val records = mutableListOf<MediaRequest>() val seen = mutableSetOf<String>() var cursor: String? = null - repeat(100) { - val result = ownedV2Call<RequestsListResponse, RequestsListResponse>(gate, tokenManager, pinned, OwnerPolicy.IDENTITY, null, { owner -> - client.get("/api/v2/requests/mine") { + repeat(maxPages) { + val result = ownedV2Call<RequestsListResponse, RequestsListResponse>(gate, tokenManager, pinned, policy, null, { owner -> + client.get(path) { owner?.let { authScope(it) } - parameter("status", status) - parameter("outcome", outcome) + filters() parameter("limit", size) cursor?.let { parameter("cursor", it) } } @@ -147,4 +222,19 @@ class DefaultRequestsApi( setBody(kotlinx.serialization.json.JsonObject(emptyMap())) } } + + override suspend fun adminCapabilities(): ApiResult<AdminRequestCapabilities> = safeApiV2Call(gate) { + client.get("/api/v2/admin/requests/capabilities") + } + + override suspend fun adminAction( + id: String, + action: AdminRequestAction, + reason: String?, + ): ApiResult<MediaRequest> = safeApiV2Call(gate) { + client.post("/api/v2/admin/requests/$id/${action.path}") { + contentType(ContentType.Application.Json) + setBody(AdminRequestActionBody(reason = reason?.takeIf { it.isNotBlank() })) + } + } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/SectionApi.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/SectionApi.kt index 1edbbd27da..8e657f6eab 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/SectionApi.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/SectionApi.kt @@ -37,6 +37,20 @@ class SectionApi(client: HttpClient, private val v2: CatalogV2Api = CatalogV2Api suspend fun getLibrarySectionItems(libraryId: Int, sectionId: String, owner: AuthScopeSnapshot): ApiResult<HomeSectionItemsResponse> = sectionItems.read(libraryId, sectionId, owner) + /** + * Pages the section's stored admin definition: the server's catalog section source does not apply + * profile overrides, so callers must not refill a `customized` or `is_custom` section through it. + */ + suspend fun getLibrarySectionCatalogItems(libraryId: Int, sectionId: String, + owner: AuthScopeSnapshot, continuation: CatalogContinuationV2?): ApiResult<CatalogResponse> { + if (!sectionItems.current(owner)) return identityChanged() + val result = v2.browse(CatalogQueryV2(source = "section", scope = "library", + libraryId = libraryId.toString(), sectionId = sectionId, limit = 100), continuation) + .map { it.toCatalogResponse() } + if (!sectionItems.current(owner)) return identityChanged() + return result + } + // --- Library Collections --- suspend fun getLibraryCollections(libraryId: Int): ApiResult<LibraryCollectionsResponse> = @@ -70,8 +84,11 @@ class SectionApi(client: HttpClient, private val v2: CatalogV2Api = CatalogV2Api order: String? = null, queryGroups: List<CatalogQueryGroup> = emptyList(), match: String? = null, + mediaType: String? = null, + libraryId: Int? = null, + source: String = "library_collection", ): ApiResult<CatalogResponse> = v2.browse(CatalogQueryV2( - source = "library_collection", collectionId = collectionId, limit = limit, + source = source, collectionId = collectionId, limit = limit, type = mediaType, libraryId = libraryId?.toString(), sort = sort, order = order.takeIf { sort != null }, groups = queryGroups.toV2Groups(), match = match, ), continuation).map { it.toCatalogResponse() } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/ServerIdentityApi.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/ServerIdentityApi.kt new file mode 100644 index 0000000000..8b97bb6da7 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/ServerIdentityApi.kt @@ -0,0 +1,165 @@ +package org.prairieserver.prairie.network.api + +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpStatusCode +import io.ktor.http.isSuccess +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.PrairieJson +import org.prairieserver.prairie.network.apiv2.ApiV2Probe +import org.prairieserver.prairie.network.authScope +import org.prairieserver.prairie.network.singleAttempt +import org.prairieserver.prairie.network.skipPrairieAuth +import org.prairieserver.prairie.pairing.PairingEndpoint + +/** + * The deployment identity contract (prairie-server + * `docs/architecture/server-identity.md`): `GET /api/v2/system/identity` is + * public and answers one stable `server_id` at every address of a deployment; + * `GET /api/v2/system/connections` is authenticated and lists the addresses the + * deployment offers. The id is self-asserted, so it only decides which + * addresses are worth trying and which saved server a link or a TV means. + * Device-login approval remains the proof that two addresses share a backend. + * + * Mirrors prairie-apple `ServerIdentity.swift`. + */ +interface ServerIdentityApi { + /** Classify one address: the identity it reports, an older server, or unreachable. */ + suspend fun probeIdentity(serverUrl: String): ServerIdentityProbe + + /** + * The addresses a saved server offers, read with that server's own + * credentials through [scope]. Null when the feature is unavailable or the + * request fails; callers then offer the saved URL alone. + */ + suspend fun connections(scope: AuthScopeSnapshot): ServerConnections? +} + +sealed interface ServerIdentityProbe { + /** The address answered the identity operation. */ + data class Identity(val serverId: String) : ServerIdentityProbe + + /** The address answered, but it predates the identity contract. */ + data object UnsupportedServer : ServerIdentityProbe + + /** The address could not be reached, or did not answer like a Prairie server. */ + data object Unreachable : ServerIdentityProbe +} + +/** `GET /api/v2/system/connections`, reduced to what other devices can use. */ +data class ServerConnections( + val serverId: String, + /** Addresses another device may try, in the server's order, de-duplicated. */ + val endpoints: List<PairingEndpoint>, +) + +@Serializable +internal data class ServerIdentityV2( + @SerialName("server_id") val serverId: String, +) + +@Serializable +internal data class ServerConnectionsV2( + val state: String? = null, + val allowed: Boolean? = null, + @SerialName("server_id") val serverId: String, + val endpoints: List<Endpoint> = emptyList(), +) { + @Serializable + internal data class Endpoint( + val kind: String, + val url: String? = null, + val provider: String? = null, + @SerialName("display_name") val displayName: String? = null, + val state: String? = null, + ) { + /** + * Only a connected provider carries a URL; a public endpoint always + * does. Anything without one cannot be offered to another device. + */ + fun usable(): PairingEndpoint? { + val url = url?.trim()?.takeIf { it.isNotEmpty() } ?: return null + return when (kind) { + "public" -> PairingEndpoint.of(url = url, kind = PairingEndpoint.Kind.Public) + "provider" -> PairingEndpoint.of( + url = url, + kind = PairingEndpoint.Kind.Provider, + provider = provider, + displayName = displayName, + ) + else -> null + } + } + } + + val isAvailable: Boolean get() = state == "available" && allowed != false + + fun domain(): ServerConnections { + val seen = mutableSetOf<String>() + return ServerConnections( + serverId = serverId, + endpoints = endpoints.mapNotNull { it.usable() }.filter { seen.add(it.url) }, + ) + } +} + +class DefaultServerIdentityApi(private val client: HttpClient) : ServerIdentityApi { + + override suspend fun probeIdentity(serverUrl: String): ServerIdentityProbe { + val base = serverUrl.trim().trimEnd('/') + if (base.isEmpty()) return ServerIdentityProbe.Unreachable + return try { + withTimeoutOrNull(PROBE_TIMEOUT_MS) { + val response = client.get("$base$IDENTITY_PATH") { + skipPrairieAuth() + singleAttempt() + } + val body = response.bodyAsText() + when { + response.status.isSuccess() -> { + val id = PrairieJson.decodeFromString(ServerIdentityV2.serializer(), body) + .serverId.trim() + if (id.isEmpty()) ServerIdentityProbe.Unreachable else ServerIdentityProbe.Identity(id) + } + response.status == HttpStatusCode.NotFound && ApiV2Probe.isLegacyNotFound(body) -> + ServerIdentityProbe.UnsupportedServer + else -> ServerIdentityProbe.Unreachable + } + } ?: ServerIdentityProbe.Unreachable + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + ServerIdentityProbe.Unreachable + } + } + + override suspend fun connections(scope: AuthScopeSnapshot): ServerConnections? = try { + withTimeoutOrNull(PROBE_TIMEOUT_MS) { + val response = client.get(CONNECTIONS_PATH) { + authScope(scope) + singleAttempt() + } + if (!response.status.isSuccess()) return@withTimeoutOrNull null + PrairieJson.decodeFromString(ServerConnectionsV2.serializer(), response.bodyAsText()) + .takeIf { it.isAvailable } + ?.domain() + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null + } + + companion object { + const val IDENTITY_PATH = "/api/v2/system/identity" + const val CONNECTIONS_PATH = "/api/v2/system/connections" + + /** A TV probing several candidate addresses in turn must fail fast on the dead ones. */ + const val PROBE_TIMEOUT_MS = 8_000L + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/SettingsApi.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/SettingsApi.kt index de359bde3f..9cd27dc83e 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/SettingsApi.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/SettingsApi.kt @@ -11,9 +11,10 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement /** - * Admin-configured card-overlay baseline. `enabled` is the global - * kill-switch (admins can disable overlays for everyone); `defaults` is - * the serialized [CardOverlayPrefs] JSON used when a user has no override. + * Admin-configured card-overlay baseline. `enabled` is the server-wide + * default for profiles that have not set `ui.card_overlays_enabled`; + * `defaults` is the serialized [CardOverlayPrefs] JSON used when a user + * has no override. * Mirrors iOS `OverlayConfigResponse` and the server's * `GET /api/v2/settings/overlay-config` shape. */ diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/WatchTogetherApi.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/WatchTogetherApi.kt index c52ae11d8c..f4e9aade18 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/WatchTogetherApi.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/WatchTogetherApi.kt @@ -3,271 +3,407 @@ package org.prairieserver.prairie.network.api import org.prairieserver.prairie.model.watchtogether.AddSuggestionRequest import org.prairieserver.prairie.model.watchtogether.CreateRoomRequest import org.prairieserver.prairie.model.watchtogether.JoinRoomRequest +import org.prairieserver.prairie.model.watchtogether.MemberStateRequest +import org.prairieserver.prairie.model.watchtogether.MemberStateResponse +import org.prairieserver.prairie.model.watchtogether.PickerResponse import org.prairieserver.prairie.model.watchtogether.PromoteSuggestionRequest import org.prairieserver.prairie.model.watchtogether.RoomResponse +import org.prairieserver.prairie.model.watchtogether.SelectionModeRequest import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest +import org.prairieserver.prairie.model.watchtogether.SourceFallbackRequest +import org.prairieserver.prairie.model.watchtogether.SuggestionReceipt import org.prairieserver.prairie.model.watchtogether.SuggestionsResponse import org.prairieserver.prairie.model.watchtogether.UpdatePolicyRequest +import org.prairieserver.prairie.model.watchtogether.WatchTogetherCapabilitiesV2 import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.AuthScopeSnapshot -import org.prairieserver.prairie.network.PrairieJson +import org.prairieserver.prairie.network.TokenManager import org.prairieserver.prairie.network.apiv2.ApiV2Gate -import org.prairieserver.prairie.network.apiv2.safeApiV2Call +import org.prairieserver.prairie.network.apiv2.OwnerPolicy +import org.prairieserver.prairie.network.apiv2.ownedV2Call import org.prairieserver.prairie.network.authScope import org.prairieserver.prairie.network.requirePrairieAuth +import org.prairieserver.prairie.network.singleAttempt import io.ktor.client.HttpClient +import io.ktor.client.request.HttpRequestBuilder import io.ktor.client.request.delete import io.ktor.client.request.get import io.ktor.client.request.header +import io.ktor.client.request.parameter import io.ktor.client.request.patch import io.ktor.client.request.post import io.ktor.client.request.put import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode import io.ktor.http.contentType import io.ktor.http.encodeURLPathPart -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.put -import kotlin.uuid.ExperimentalUuidApi -import kotlin.uuid.Uuid /** - * Watch Together REST surface (`/api/v2/watch-together`). Create/join return a - * second token (the **room JWT**) distinct from the auth JWT; every - * room-scoped call passes it as the `X-Room-Token` header. Behind an - * interface so the repository's tests fake the transport (matching - * NotificationsApi). + * Watch Party REST surface (`/api/v2/watch-together`). * - * The room WS is a separate transport (see WatchTogetherRealtimeClient); this - * is REST only. Suggestion mutations return 201/204 on the server; this - * transport follows each with a suggestions read so callers still receive the - * current list. Non-2xx replies are RFC 9457 problems surfaced as - * [ApiResult.Error]. + * Create and join return a room access token (the room proof) distinct from + * the account token. Member operations send it as `X-Room-Token`; host-only + * operations authorize by host identity and send no proof. Every successful + * room response carries a renewed proof. + * + * Retry classes follow each operation's server `RetrySafety`. The three + * non-retryable operations (start, select, promote) are sent with + * [singleAttempt] so no authentication refresh replays them, and on + * [nonReplayingClient], whose engine never resends a request after its + * connection fails (OkHttp otherwise does on a reused connection). The + * repository reconciles such uncertain outcomes by reading the room. + * + * Suggestion mutations return only a receipt. Reading the list afterwards is + * the caller's reconciliation, so a failed read cannot turn a successful vote + * into a reported failure. */ interface WatchTogetherApi { + /** GET /capabilities (account authority). */ + suspend fun capabilities(scope: AuthScopeSnapshot): ApiResult<WatchTogetherCapabilitiesV2> - /** POST /rooms — caller becomes host; 201 with room + room_access_token. */ + /** POST /rooms — 201; exact replay of the same `room_id` and body returns the same room. */ suspend fun createRoom(request: CreateRoomRequest, scope: AuthScopeSnapshot): ApiResult<RoomResponse> - /** POST /join — resolves a code or join token; 200 with room + room_access_token. */ + /** POST /join — resolves a code or join token and issues proof. */ suspend fun joinRoom(request: JoinRoomRequest, scope: AuthScopeSnapshot): ApiResult<RoomResponse> - /** GET /rooms/{id} — current room snapshot. */ + /** GET /rooms/{id} — current snapshot and renewed proof. */ suspend fun getRoom(roomId: String, roomToken: String, scope: AuthScopeSnapshot): ApiResult<RoomResponse> - /** PUT /rooms/{id}/selection (host-only). */ - suspend fun setSelection( - roomId: String, - roomToken: String, - request: SetSelectionRequest, - scope: AuthScopeSnapshot, - ): ApiResult<RoomResponse> + /** PATCH /rooms/{id}/policy (host). */ + suspend fun updatePolicy(roomId: String, request: UpdatePolicyRequest, scope: AuthScopeSnapshot): ApiResult<RoomResponse> - /** PATCH /rooms/{id}/policy (host-only). */ - suspend fun updatePolicy( - roomId: String, - roomToken: String, - request: UpdatePolicyRequest, - scope: AuthScopeSnapshot, - ): ApiResult<RoomResponse> + /** PUT /rooms/{id}/staged-selection (host, host-pick lobby). Staging never plays. */ + suspend fun stageSelection(roomId: String, request: SetSelectionRequest, scope: AuthScopeSnapshot): ApiResult<RoomResponse> - /** DELETE /rooms/{id} (host-only) — 204 → Unit. */ - suspend fun closeRoom(roomId: String, roomToken: String, scope: AuthScopeSnapshot): ApiResult<Unit> + /** POST /rooms/{id}/playback/start (host). Non-retryable. */ + suspend fun startPlayback(roomId: String, scope: AuthScopeSnapshot): ApiResult<RoomResponse> - /** GET /rooms/{id}/suggestions. */ - suspend fun listSuggestions(roomId: String, roomToken: String, scope: AuthScopeSnapshot): ApiResult<SuggestionsResponse> + /** POST /rooms/{id}/playback/stop (host). Returns everyone to the staged lobby. */ + suspend fun stopPlayback(roomId: String, scope: AuthScopeSnapshot): ApiResult<RoomResponse> - /** POST /rooms/{id}/suggestions. */ - suspend fun addSuggestion( - roomId: String, - roomToken: String, - request: AddSuggestionRequest, - scope: AuthScopeSnapshot, - ): ApiResult<SuggestionsResponse> + /** PATCH /rooms/{id}/selection-mode (host, lobby only). */ + suspend fun setSelectionMode(roomId: String, request: SelectionModeRequest, scope: AuthScopeSnapshot): ApiResult<RoomResponse> - /** DELETE /rooms/{id}/suggestions/{sid} (host or suggester). */ - suspend fun deleteSuggestion( + /** PUT /rooms/{id}/selection (host, host-pick rooms). Starts playback. Non-retryable. */ + suspend fun setSelection(roomId: String, request: SetSelectionRequest, scope: AuthScopeSnapshot): ApiResult<RoomResponse> + + /** DELETE /rooms/{id} (host) — 204. */ + suspend fun closeRoom(roomId: String, scope: AuthScopeSnapshot): ApiResult<Unit> + + /** POST /rooms/{id}/source-fallback (connected member). */ + suspend fun sourceFallback( roomId: String, roomToken: String, - suggestionId: String, + request: SourceFallbackRequest, scope: AuthScopeSnapshot, - ): ApiResult<SuggestionsResponse> + ): ApiResult<RoomResponse> - /** POST /rooms/{id}/suggestions/{sid}/vote — 409 on dup. */ - suspend fun vote( + /** GET /rooms/{id}/suggestions — one page in creation order. */ + suspend fun listSuggestions( roomId: String, roomToken: String, - suggestionId: String, scope: AuthScopeSnapshot, + cursor: String? = null, + limit: Int = SUGGESTION_PAGE_LIMIT, ): ApiResult<SuggestionsResponse> - /** DELETE /rooms/{id}/suggestions/{sid}/vote — 409 if not voted. */ - suspend fun unvote( + /** POST /rooms/{id}/suggestions — 201 receipt echoing the sent id. */ + suspend fun addSuggestion( roomId: String, roomToken: String, - suggestionId: String, + request: AddSuggestionRequest, scope: AuthScopeSnapshot, - ): ApiResult<SuggestionsResponse> + ): ApiResult<SuggestionReceipt> + + /** DELETE /rooms/{id}/suggestions/{sid} (host or suggester) — 204. */ + suspend fun deleteSuggestion(roomId: String, roomToken: String, suggestionId: String, scope: AuthScopeSnapshot): ApiResult<Unit> + + /** POST /rooms/{id}/suggestions/{sid}/vote — 204, also when already voted. */ + suspend fun vote(roomId: String, roomToken: String, suggestionId: String, scope: AuthScopeSnapshot): ApiResult<Unit> + + /** DELETE /rooms/{id}/suggestions/{sid}/vote — 204, also when not voted. */ + suspend fun unvote(roomId: String, roomToken: String, suggestionId: String, scope: AuthScopeSnapshot): ApiResult<Unit> - /** POST /rooms/{id}/suggestions/promote (host-only) → room. */ + /** POST /rooms/{id}/suggestions/promote (host). Starts playback. Non-retryable. */ suspend fun promoteSuggestion( roomId: String, roomToken: String, request: PromoteSuggestionRequest, scope: AuthScopeSnapshot, ): ApiResult<RoomResponse> + + /** POST /rooms/{id}/member-state — a bounded read. */ + suspend fun memberState( + roomId: String, + roomToken: String, + request: MemberStateRequest, + scope: AuthScopeSnapshot, + ): ApiResult<MemberStateResponse> + + /** GET /rooms/{id}/picker. */ + suspend fun picker(roomId: String, roomToken: String, scope: AuthScopeSnapshot): ApiResult<PickerResponse> + + companion object { + const val SUGGESTION_PAGE_LIMIT = 50 + } } -@OptIn(ExperimentalUuidApi::class) class DefaultWatchTogetherApi( private val client: HttpClient, private val gate: ApiV2Gate, + private val tokens: TokenManager? = null, + private val nonReplayingClient: HttpClient = client, ) : WatchTogetherApi { + override suspend fun capabilities(scope: AuthScopeSnapshot): ApiResult<WatchTogetherCapabilitiesV2> = + call<WatchTogetherCapabilitiesV2, WatchTogetherCapabilitiesV2>(scope, HttpStatusCode.OK, { + client.get("$BASE/capabilities") { pin(scope) } + }) { it } + override suspend fun createRoom(request: CreateRoomRequest, scope: AuthScopeSnapshot): ApiResult<RoomResponse> = - safeApiV2Call(gate) { + call<RoomResponse, RoomResponse>(scope, HttpStatusCode.Created, { client.post("$BASE/rooms") { pin(scope) contentType(ContentType.Application.Json) - setBody( - buildJsonObject { - put("room_id", Uuid.random().toString()) - put("selection_mode", request.selectionMode ?: "host_pick") - }, - ) + setBody(request) } - } + }) { it.validated(request.roomId) } override suspend fun joinRoom(request: JoinRoomRequest, scope: AuthScopeSnapshot): ApiResult<RoomResponse> = - safeApiV2Call(gate) { + call<RoomResponse, RoomResponse>(scope, HttpStatusCode.OK, { client.post("$BASE/join") { pin(scope) contentType(ContentType.Application.Json) setBody(request) } - } + }) { it.validated(expectedRoomId = null) } override suspend fun getRoom(roomId: String, roomToken: String, scope: AuthScopeSnapshot): ApiResult<RoomResponse> = - safeApiV2Call(gate) { - client.get(room(roomId)) { pin(scope, roomToken) } + roomCall(roomId, scope) { client.get(room(roomId)) { pin(scope, roomToken) } } + + override suspend fun updatePolicy( + roomId: String, + request: UpdatePolicyRequest, + scope: AuthScopeSnapshot, + ): ApiResult<RoomResponse> = roomCall(roomId, scope) { + client.patch("${room(roomId)}/policy") { + pin(scope) + contentType(ContentType.Application.Json) + setBody(request) + } + } + + override suspend fun stageSelection( + roomId: String, + request: SetSelectionRequest, + scope: AuthScopeSnapshot, + ): ApiResult<RoomResponse> = roomCall(roomId, scope) { + client.put("${room(roomId)}/staged-selection") { + pin(scope) + contentType(ContentType.Application.Json) + setBody(selectionBody(request)) + } + } + + override suspend fun startPlayback(roomId: String, scope: AuthScopeSnapshot): ApiResult<RoomResponse> = + roomCall(roomId, scope) { + nonReplayingClient.post("${room(roomId)}/playback/start") { pin(scope); singleAttempt() } } + override suspend fun stopPlayback(roomId: String, scope: AuthScopeSnapshot): ApiResult<RoomResponse> = + roomCall(roomId, scope) { client.post("${room(roomId)}/playback/stop") { pin(scope) } } + + override suspend fun setSelectionMode( + roomId: String, + request: SelectionModeRequest, + scope: AuthScopeSnapshot, + ): ApiResult<RoomResponse> = roomCall(roomId, scope) { + client.patch("${room(roomId)}/selection-mode") { + pin(scope) + contentType(ContentType.Application.Json) + setBody(request) + } + } + override suspend fun setSelection( roomId: String, - roomToken: String, request: SetSelectionRequest, scope: AuthScopeSnapshot, - ): ApiResult<RoomResponse> = safeApiV2Call(gate) { - client.put("${room(roomId)}/selection") { - pin(scope, roomToken) + ): ApiResult<RoomResponse> = roomCall(roomId, scope) { + nonReplayingClient.put("${room(roomId)}/selection") { + pin(scope) + singleAttempt() contentType(ContentType.Application.Json) - setBody( - buildJsonObject { - put("content_id", request.contentId) - request.fileId?.let { put("file_id", it.toString()) } - request.libraryId?.let { put("library_id", it.toString()) } - }, - ) + setBody(selectionBody(request)) } } - override suspend fun updatePolicy( + override suspend fun closeRoom(roomId: String, scope: AuthScopeSnapshot): ApiResult<Unit> = + call<Unit, Unit>(scope, HttpStatusCode.NoContent, { client.delete(room(roomId)) { pin(scope) } }) { it } + + override suspend fun sourceFallback( roomId: String, roomToken: String, - request: UpdatePolicyRequest, + request: SourceFallbackRequest, scope: AuthScopeSnapshot, - ): ApiResult<RoomResponse> = safeApiV2Call(gate) { - client.patch("${room(roomId)}/policy") { + ): ApiResult<RoomResponse> = roomCall(roomId, scope) { + client.post("${room(roomId)}/source-fallback") { pin(scope, roomToken) contentType(ContentType.Application.Json) setBody(request) } } - override suspend fun closeRoom(roomId: String, roomToken: String, scope: AuthScopeSnapshot): ApiResult<Unit> = - safeApiV2Call(gate) { - client.delete(room(roomId)) { pin(scope, roomToken) } - } - override suspend fun listSuggestions( roomId: String, roomToken: String, scope: AuthScopeSnapshot, - ): ApiResult<SuggestionsResponse> = safeApiV2Call(gate) { - client.get("${room(roomId)}/suggestions") { pin(scope, roomToken) } - } + cursor: String?, + limit: Int, + ): ApiResult<SuggestionsResponse> = + call<SuggestionsResponse, SuggestionsResponse>(scope, HttpStatusCode.OK, { + client.get("${room(roomId)}/suggestions") { + pin(scope, roomToken) + parameter("limit", limit) + cursor?.let { parameter("cursor", it) } + } + }) { page -> + require(page.suggestions.all { it.id.isNotBlank() && it.roomId == roomId }) { + "The suggestion page named another room." + } + require(page.page?.hasMore != true || !page.page.nextCursor.isNullOrBlank()) { + "The suggestion page has more items but no cursor." + } + page + } override suspend fun addSuggestion( roomId: String, roomToken: String, request: AddSuggestionRequest, scope: AuthScopeSnapshot, - ): ApiResult<SuggestionsResponse> = thenList(roomId, roomToken, scope) { - client.post("${room(roomId)}/suggestions") { - pin(scope, roomToken) - contentType(ContentType.Application.Json) - val fields = PrairieJson.encodeToJsonElement(AddSuggestionRequest.serializer(), request).jsonObject - setBody(JsonObject(fields + ("suggestion_id" to JsonPrimitive(Uuid.random().toString())))) + ): ApiResult<SuggestionReceipt> = + call<SuggestionReceipt, SuggestionReceipt>(scope, HttpStatusCode.Created, { + client.post("${room(roomId)}/suggestions") { + pin(scope, roomToken) + contentType(ContentType.Application.Json) + setBody(request) + } + }) { receipt -> + require(receipt.suggestionId == request.suggestionId) { "The suggestion receipt named another suggestion." } + receipt } - } override suspend fun deleteSuggestion( roomId: String, roomToken: String, suggestionId: String, scope: AuthScopeSnapshot, - ): ApiResult<SuggestionsResponse> = thenList(roomId, roomToken, scope) { + ): ApiResult<Unit> = call<Unit, Unit>(scope, HttpStatusCode.NoContent, { client.delete(suggestion(roomId, suggestionId)) { pin(scope, roomToken) } - } + }) { it } override suspend fun vote( roomId: String, roomToken: String, suggestionId: String, scope: AuthScopeSnapshot, - ): ApiResult<SuggestionsResponse> = thenList(roomId, roomToken, scope) { + ): ApiResult<Unit> = call<Unit, Unit>(scope, HttpStatusCode.NoContent, { client.post("${suggestion(roomId, suggestionId)}/vote") { pin(scope, roomToken) } - } + }) { it } override suspend fun unvote( roomId: String, roomToken: String, suggestionId: String, scope: AuthScopeSnapshot, - ): ApiResult<SuggestionsResponse> = thenList(roomId, roomToken, scope) { + ): ApiResult<Unit> = call<Unit, Unit>(scope, HttpStatusCode.NoContent, { client.delete("${suggestion(roomId, suggestionId)}/vote") { pin(scope, roomToken) } - } + }) { it } override suspend fun promoteSuggestion( roomId: String, roomToken: String, request: PromoteSuggestionRequest, scope: AuthScopeSnapshot, - ): ApiResult<RoomResponse> = safeApiV2Call(gate) { - client.post("${room(roomId)}/suggestions/promote") { + ): ApiResult<RoomResponse> = roomCall(roomId, scope) { + nonReplayingClient.post("${room(roomId)}/suggestions/promote") { pin(scope, roomToken) + singleAttempt() contentType(ContentType.Application.Json) setBody(request) } } - /** Runs a bodiless suggestion mutation, then reads the current list. */ - private suspend inline fun thenList( + override suspend fun memberState( roomId: String, roomToken: String, + request: MemberStateRequest, scope: AuthScopeSnapshot, - mutation: () -> io.ktor.client.statement.HttpResponse, - ): ApiResult<SuggestionsResponse> { - return when (val mutated = safeApiV2Call<Unit>(gate, mutation)) { - is ApiResult.Success -> listSuggestions(roomId, roomToken, scope) - is ApiResult.Error -> mutated - is ApiResult.NetworkError -> mutated + ): ApiResult<MemberStateResponse> { + if (request.contentIds.isEmpty() || request.contentIds.any { it.isBlank() }) { + return ApiResult.Error(422, "validation_failed", "Member state needs at least one content id.") + } + val requested = request.contentIds.toSet() + return call<MemberStateResponse, MemberStateResponse>(scope, HttpStatusCode.OK, { + client.post("${room(roomId)}/member-state") { + pin(scope, roomToken) + contentType(ContentType.Application.Json) + setBody(request) + } + }) { body -> + require(body.items.all { it.contentId in requested }) { "Member state named an unrequested item." } + body } } + override suspend fun picker(roomId: String, roomToken: String, scope: AuthScopeSnapshot): ApiResult<PickerResponse> = + call<PickerResponse, PickerResponse>(scope, HttpStatusCode.OK, { + client.get("${room(roomId)}/picker") { pin(scope, roomToken) } + }) { body -> + require((body.continueTogether + body.watchlistUnion).none { it.item.contentId.isBlank() }) { + "The picker returned a card without an id." + } + body + } + + /** + * One owned exchange; the captured [scope] must still own the session + * before and after. Without a token manager there is nothing to compare + * against, so only the status and body checks apply. + */ + private suspend inline fun <reified T, R> call( + scope: AuthScopeSnapshot, + expected: HttpStatusCode, + crossinline block: suspend () -> HttpResponse, + crossinline project: (T) -> R, + ): ApiResult<R> = + ownedV2Call<T, R>(gate, tokens, scope.takeIf { tokens != null }, OwnerPolicy.PROFILE, expected, { block() }, project) + + private suspend inline fun roomCall( + roomId: String, + scope: AuthScopeSnapshot, + crossinline block: suspend () -> HttpResponse, + ): ApiResult<RoomResponse> = + call<RoomResponse, RoomResponse>(scope, HttpStatusCode.OK, block) { it.validated(roomId) } + + private fun RoomResponse.validated(expectedRoomId: String?): RoomResponse { + require(room.roomId.isNotBlank() && roomAccessToken.isNotBlank()) { "The room response was incomplete." } + require(expectedRoomId == null || room.roomId == expectedRoomId) { "The room response named another room." } + return this + } + + private fun selectionBody(request: SetSelectionRequest) = buildJsonObject { + put("content_id", request.contentId) + request.fileId?.let { put("file_id", it.toString()) } + request.libraryId?.let { put("library_id", it.toString()) } + } + private companion object { const val BASE = "/api/v2/watch-together" } @@ -277,9 +413,11 @@ class DefaultWatchTogetherApi( private fun suggestion(roomId: String, suggestionId: String) = "${room(roomId)}/suggestions/${suggestionId.encodeURLPathPart()}" - private fun io.ktor.client.request.HttpRequestBuilder.pin(scope: AuthScopeSnapshot, roomToken: String? = null) { + private fun HttpRequestBuilder.pin(scope: AuthScopeSnapshot, roomToken: String? = null) { authScope(scope) requirePrairieAuth() - roomToken?.let { header("X-Room-Token", it) } + roomToken?.let { header(ROOM_TOKEN_HEADER, it) } } } + +internal const val ROOM_TOKEN_HEADER = "X-Room-Token" diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/ApiV2Call.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/ApiV2Call.kt index 351f03860c..54751c9810 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/ApiV2Call.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/ApiV2Call.kt @@ -14,6 +14,7 @@ import org.prairieserver.prairie.network.AuthScopeSnapshot import org.prairieserver.prairie.network.resolveArtworkUrls import org.prairieserver.prairie.network.PrairieJson import org.prairieserver.prairie.network.TokenManager +import org.prairieserver.prairie.network.reportStaleProfileIfRefused /** * Wraps one v2 exchange: a 2xx body decodes to [T] with the production @@ -53,9 +54,13 @@ internal suspend fun HttpResponse.toApiV2Error(): ApiResult.Error { } catch (_: Exception) { null } + val code = problem?.code ?: "" + // A stale profile proof is the server's way of asking for the PIN again + // after an access change; the recovery decides whether it applies. + reportStaleProfileIfRefused(code) return ApiResult.Error( code = status.value, - error = problem?.code ?: "", + error = code, message = problem?.detail ?: "", ) } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/ApiV2Gate.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/ApiV2Gate.kt index a6b7e9cf6a..06630b66db 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/ApiV2Gate.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/ApiV2Gate.kt @@ -9,12 +9,24 @@ import org.prairieserver.prairie.network.ServerRegistry * [ServerContract.UPDATE_REQUIRED] state. The state comes from the registry * entry (set by [ApiV2Probe] on connect); nothing here ever performs a * request. + * + * A call scoped to one saved server, which need not be the active one, is + * gated on that server's own entry instead: [forServer]. */ -class ApiV2Gate(private val registry: ServerRegistry? = null) { +class ApiV2Gate( + private val registry: ServerRegistry? = null, + /** The saved server this gate checks; null for the active one. */ + private val serverId: String? = null, +) { /** The error to return instead of calling the server, or null when the call may proceed. */ - fun blocked(): ApiResult.Error? = - if (registry?.activeEntry?.value?.contract == ServerContract.UPDATE_REQUIRED) { + fun blocked(): ApiResult.Error? { + val entry = if (serverId == null) { + registry?.activeEntry?.value + } else { + registry?.entries?.value?.firstOrNull { it.id == serverId } + } + return if (entry?.contract == ServerContract.UPDATE_REQUIRED) { ApiResult.Error( code = 0, // no HTTP exchange happened; distinguishes the gate from any server status error = UPDATE_REQUIRED_ERROR, @@ -23,6 +35,13 @@ class ApiV2Gate(private val registry: ServerRegistry? = null) { } else { null } + } + + /** + * The gate for a call scoped to [serverId]'s saved server: that entry's + * verdict applies, not the active server's. + */ + fun forServer(serverId: String): ApiV2Gate = ApiV2Gate(registry, serverId) companion object { const val UPDATE_REQUIRED_ERROR = "update_server" diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/CatalogDetailReadV2Models.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/CatalogDetailReadV2Models.kt index ab94f49873..c3e029a644 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/CatalogDetailReadV2Models.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/CatalogDetailReadV2Models.kt @@ -27,6 +27,7 @@ internal data class ItemDetailReadV2( val year: Int = 0, val overview: String? = null, @SerialName("pending_translation_language") val pendingTranslationLanguage: String? = null, + @SerialName("machine_translated_fields") val machineTranslatedFields: List<String> = emptyList(), val tagline: String? = null, val runtime: Int = 0, @SerialName("content_rating") val contentRating: String? = null, @@ -35,6 +36,9 @@ internal data class ItemDetailReadV2( @SerialName("rating_tmdb") val ratingTmdb: Double? = null, @SerialName("rating_rt_critic") val ratingRtCritic: Int? = null, @SerialName("rating_rt_audience") val ratingRtAudience: Int? = null, + // Absent on servers that predate the title-page ratings row. + @Serializable(with = DisplayRatingListSerializer::class) + val ratings: List<DisplayRating>? = null, @SerialName("imdb_id") val imdbId: String? = null, @SerialName("tmdb_id") val tmdbId: String? = null, @SerialName("tvdb_id") val tvdbId: String? = null, @@ -47,6 +51,8 @@ internal data class ItemDetailReadV2( @SerialName("release_date") val releaseDate: String? = null, @SerialName("first_air_date") val firstAirDate: String? = null, @SerialName("last_air_date") val lastAirDate: String? = null, + @SerialName("poster_is_episode_still") val posterIsEpisodeStill: Boolean? = null, + @SerialName("backdrop_is_episode_still") val backdropIsEpisodeStill: Boolean? = null, @SerialName("poster_url") val posterUrl: String? = null, @SerialName("poster_thumbhash") val posterThumbhash: String? = null, @SerialName("backdrop_url") val backdropUrl: String? = null, @@ -88,6 +94,7 @@ internal data class ItemDetailReadV2( year = year, overview = overview, pendingTranslationLanguage = pendingTranslationLanguage, + machineTranslatedFields = machineTranslatedFields, tagline = tagline, runtime = runtime, contentRating = contentRating, @@ -96,6 +103,7 @@ internal data class ItemDetailReadV2( ratingTmdb = ratingTmdb, ratingRtCritic = ratingRtCritic, ratingRtAudience = ratingRtAudience, + ratings = ratings, imdbId = imdbId, tmdbId = tmdbId, tvdbId = tvdbId, @@ -108,6 +116,8 @@ internal data class ItemDetailReadV2( releaseDate = releaseDate, firstAirDate = firstAirDate, lastAirDate = lastAirDate, + posterIsEpisodeStill = posterIsEpisodeStill, + backdropIsEpisodeStill = backdropIsEpisodeStill, posterUrl = posterUrl, posterThumbhash = posterThumbhash, backdropUrl = backdropUrl, @@ -271,10 +281,13 @@ internal data class EpisodeListItemReadV2( @SerialName("imdb_id") val imdbId: String? = null, @SerialName("tmdb_id") val tmdbId: String? = null, @SerialName("tvdb_id") val tvdbId: String? = null, + @SerialName("still_is_episode_still") val stillIsEpisodeStill: Boolean? = null, @SerialName("still_url") val stillUrl: String? = null, @SerialName("still_thumbhash") val stillThumbhash: String? = null, @SerialName("user_data") val userData: LeafItemUserDataReadV2? = null, - val files: List<EpisodeFileReadV2> = emptyList() + val files: List<EpisodeFileReadV2> = emptyList(), + @SerialName("pending_translation_language") val pendingTranslationLanguage: String? = null, + @SerialName("machine_translated_fields") val machineTranslatedFields: List<String> = emptyList(), ) { fun toDomain() = EpisodeListItem( contentId = contentId, @@ -287,10 +300,13 @@ internal data class EpisodeListItemReadV2( imdbId = imdbId, tmdbId = tmdbId, tvdbId = tvdbId, + stillIsEpisodeStill = stillIsEpisodeStill, stillUrl = stillUrl, stillThumbhash = stillThumbhash, userData = userData?.toDomain(), files = files.map { it.toDomain() }, + pendingTranslationLanguage = pendingTranslationLanguage, + machineTranslatedFields = machineTranslatedFields, ) } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/EventsSocketV2Api.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/EventsSocketV2Api.kt index a6a0bf7851..e96bd6bb76 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/EventsSocketV2Api.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/EventsSocketV2Api.kt @@ -1,6 +1,7 @@ package org.prairieserver.prairie.network.apiv2 import io.ktor.client.HttpClient +import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession import io.ktor.client.plugins.websocket.webSocket import io.ktor.client.request.* import io.ktor.http.* @@ -8,6 +9,7 @@ import io.ktor.websocket.* import kotlinx.coroutines.* import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow +import org.prairieserver.prairie.model.notifications.WsFrameEnvelope import org.prairieserver.prairie.model.notifications.WsSubscribe import org.prairieserver.prairie.model.notifications.WsTicketResponse import org.prairieserver.prairie.network.* @@ -18,6 +20,8 @@ class EventsSocketV2Api( private val client: HttpClient, private val tokens: TokenManager, private val gate: ApiV2Gate, + /** Told when the server reports that this viewer's access changed. */ + private val accessChanges: AccessChangeSignals? = null, ) { internal suspend fun current(scope: AuthScopeSnapshot): Boolean = scope.stillOwns(tokens, OwnerPolicy.FULL) @@ -43,6 +47,8 @@ class EventsSocketV2Api( is ApiResult.NetworkError -> throw EventsTicketFailure(0) } if (!current(scope) || mintedAt.elapsedNow().inWholeMilliseconds >= proof.expiresIn * 1000L) return@flow + // Taken after the mint, so the ticket's policy is no newer than this. + val connectedAt = accessChanges?.connectionTime() // Do not retry this upgrade, even on a lost handshake response: its // single-use proof may already be consumed. Reconnect collects afresh. withTimeoutOrNull(proof.maxConnectionSeconds * 1000L) { @@ -59,12 +65,30 @@ class EventsSocketV2Api( } } } + // The server announces an access change with a frame and then a + // 4001 close; one connection reports it once. The caller's + // reconnect then mints a fresh ticket under the new policy. + var accessChangeReported = false + suspend fun reportAccessChange() { + if (accessChangeReported || !current(scope)) return + accessChangeReported = true + accessChanges?.reportAccessChanged(connectedAt) + } try { send(Frame.Text(PrairieJson.encodeToString(WsSubscribe.serializer(),WsSubscribe(channels = channels)))) + var identityLost = false for (frame in incoming) { - if (!current(scope)) break - if (frame is Frame.Text) this@flow.emit(frame.readText()) + if (!current(scope)) { + identityLost = true + break + } + if (frame is Frame.Text) { + val text = frame.readText() + if (isAccessChangedFrame(text)) reportAccessChange() + this@flow.emit(text) + } } + if (!identityLost && isAccessChangedClose(closeReasonIfClosed())) reportAccessChange() } finally { authorityWatcher.cancel() } } } @@ -73,6 +97,32 @@ class EventsSocketV2Api( internal class EventsTicketFailure(val code: Int) : Exception("Realtime ticket unavailable") +/** Whether [raw] is the server's `{"type":"access_changed"}` notice. */ +internal fun isAccessChangedFrame(raw: String): Boolean = try { + PrairieJson.decodeFromString(WsFrameEnvelope.serializer(), raw).type == AccessChangeSignals.ACCESS_CHANGED_FRAME_TYPE +} catch (_: Exception) { + false +} + +/** Whether the server closed the socket because the viewer's access changed. */ +internal fun isAccessChangedClose(reason: CloseReason?): Boolean = + reason?.code == AccessChangeSignals.ACCESS_CHANGED_CLOSE_CODE + +/** + * The close reason once the incoming channel has ended. A peer that drops the + * connection without a close frame leaves none, which reads as null here. + */ +private suspend fun DefaultClientWebSocketSession.closeReasonIfClosed(): CloseReason? = + try { + withTimeoutOrNull(CLOSE_REASON_WAIT_MS) { closeReason.await() } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null + } + +private const val CLOSE_REASON_WAIT_MS = 1_000L + internal const val EVENTS_PROTOCOL = "prairie.events.v2" internal fun validTicket(ticket: WsTicketResponse): Boolean = diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/HomeSectionsV2Api.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/HomeSectionsV2Api.kt index 8228fb3d8b..21f314998a 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/HomeSectionsV2Api.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/HomeSectionsV2Api.kt @@ -3,6 +3,9 @@ package org.prairieserver.prairie.network.apiv2 import io.ktor.client.HttpClient import io.ktor.client.request.* import io.ktor.http.* +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.serialization.json.* import org.prairieserver.prairie.model.section.SectionsResponse import org.prairieserver.prairie.model.section.ResolvedSection @@ -22,7 +25,17 @@ internal fun decodeSectionsV2(body: JsonObject): SectionsResponse { return SectionsResponse(rows.map { decodeSectionV2(it.jsonObject) }) } -class HomeSectionsV2Api(private val client: HttpClient, private val tokens: TokenManager, private val gate: ApiV2Gate) { +class HomeSectionsV2Api( + private val client: HttpClient, + private val tokens: TokenManager, + private val gate: ApiV2Gate, + /** + * Where Home responses are read and decoded. With 100-item rows the full + * Home response is over 1 MB, which takes about a second to read and decode + * on a low-end TV, so it must not run on the caller's (often the main) thread. + */ + private val decodeDispatcher: CoroutineDispatcher = Dispatchers.Default, +) { suspend fun capture(): AuthScopeSnapshot? = tokens.captureProfileScope() suspend fun current(owner: AuthScopeSnapshot): Boolean = owner.stillOwns(tokens, OwnerPolicy.FULL) suspend fun dismiss(surface: String, itemId: String, anchor: String, owner: AuthScopeSnapshot): ApiResult<Unit> { @@ -38,19 +51,22 @@ class HomeSectionsV2Api(private val client: HttpClient, private val tokens: Toke }) { } } - suspend fun list(owner: AuthScopeSnapshot): ApiResult<SectionsResponse> = + suspend fun list(owner: AuthScopeSnapshot): ApiResult<SectionsResponse> = withContext(decodeDispatcher) { ownedV2Call<JsonObject, SectionsResponse>(gate, tokens, owner, OwnerPolicy.FULL, HttpStatusCode.OK, { scope -> client.get("/api/v2/home/sections") { authScope(scope!!); requirePrairieAuth() } }) { decodeSectionsV2(it) } + } suspend fun section(id: String, owner: AuthScopeSnapshot): ApiResult<HomeSectionItemsResponse> { if (id.isBlank()) return ApiResult.Error(422, "validation_failed", "Invalid home section.") - return ownedV2Call<JsonObject, HomeSectionItemsResponse>(gate, tokens, owner, OwnerPolicy.FULL, HttpStatusCode.OK, { scope -> - client.get("/api/v2/home/sections/${id.encodeURLPathPart()}/items") { authScope(scope!!); requirePrairieAuth() } - }) { row -> - val section = decodeSectionV2(row) - require(section.id == id) { "Wrong section identity." } - HomeSectionItemsResponse(section, section.items) + return withContext(decodeDispatcher) { + ownedV2Call<JsonObject, HomeSectionItemsResponse>(gate, tokens, owner, OwnerPolicy.FULL, HttpStatusCode.OK, { scope -> + client.get("/api/v2/home/sections/${id.encodeURLPathPart()}/items") { authScope(scope!!); requirePrairieAuth() } + }) { row -> + val section = decodeSectionV2(row) + require(section.id == id) { "Wrong section identity." } + HomeSectionItemsResponse(section, section.items) + } } } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/PlaybackV2Api.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/PlaybackV2Api.kt index e953b5e236..f4cbf1511a 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/PlaybackV2Api.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/PlaybackV2Api.kt @@ -26,7 +26,6 @@ object PlaybackMutationOutcomeV2 { const val STALE_SAMPLE = "stale_sample" const val STOPPED = "stopped" val PROGRESS = setOf(APPLIED, REPLAYED, STALE_SAMPLE) - val STOP = setOf(STOPPED, REPLAYED) } /** `revision` and `deliveries` are served but never read by Android. */ diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/ShufflesV2Api.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/ShufflesV2Api.kt new file mode 100644 index 0000000000..aafda44f32 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/ShufflesV2Api.kt @@ -0,0 +1,117 @@ +package org.prairieserver.prairie.network.apiv2 + +import io.ktor.client.HttpClient +import io.ktor.client.request.* +import io.ktor.http.* +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.prairieserver.prairie.model.shuffle.Shuffle +import org.prairieserver.prairie.model.shuffle.ShuffleCapability +import org.prairieserver.prairie.model.shuffle.ShuffleScopeKind +import org.prairieserver.prairie.network.* + +@Serializable +private data class ShuffleScopeRequestV2(val kind: String, val id: String) +@Serializable +private data class CreateShuffleRequestV2(val scope: ShuffleScopeRequestV2) +@Serializable +private data class AdvanceShuffleRequestV2(@SerialName("from_content_id") val fromContentId: String) +@Serializable +private data class SkipShuffleItemRequestV2(@SerialName("next_content_id") val nextContentId: String) + +/** + * `/api/v2/shuffles`: the server picks every item of a shuffle, so the client + * only starts one, moves it on, replaces its next pick, and stops it. + * Advance and skip name the item that holds the position, so a retry after a + * lost response returns the same shuffle unchanged. `404` means the profile + * cannot see the shuffle or its scope; `409` means nothing in it can play. + */ +class ShufflesV2Api( + private val client: HttpClient, + private val tokens: TokenManager, + private val gate: ApiV2Gate, +) { + suspend fun capability(): ApiResult<ShuffleCapability> = + exchange(HttpMethod.Get, "/api/v2/shuffles/capabilities", HttpStatusCode.OK) + + suspend fun create(kind: ShuffleScopeKind, id: String): ApiResult<Shuffle> { + if (id.isBlank()) return ApiResult.Error(422, "validation_failed", "Invalid shuffle scope.") + return shuffleExchange(HttpMethod.Post, "/api/v2/shuffles", HttpStatusCode.Created) { + contentType(ContentType.Application.Json) + setBody(CreateShuffleRequestV2(ShuffleScopeRequestV2(kind.wire, id))) + } + } + + suspend fun get(shuffleId: String): ApiResult<Shuffle> { + if (shuffleId.isBlank()) return invalidShuffle() + return shuffleExchange(HttpMethod.Get, path(shuffleId), HttpStatusCode.OK) + } + + /** Moves the shuffle past [fromContentId], the item that finished: `next` becomes `current`. */ + suspend fun advance(shuffleId: String, fromContentId: String): ApiResult<Shuffle> { + if (shuffleId.isBlank() || fromContentId.isBlank()) return invalidShuffle() + return shuffleExchange(HttpMethod.Post, "${path(shuffleId)}/advance", HttpStatusCode.OK) { + contentType(ContentType.Application.Json) + setBody(AdvanceShuffleRequestV2(fromContentId)) + } + } + + /** Pick Another: replaces [nextContentId], the announced next item, with another pick. */ + suspend fun skip(shuffleId: String, nextContentId: String): ApiResult<Shuffle> { + if (shuffleId.isBlank() || nextContentId.isBlank()) return invalidShuffle() + return shuffleExchange(HttpMethod.Post, "${path(shuffleId)}/skip", HttpStatusCode.OK) { + contentType(ContentType.Application.Json) + setBody(SkipShuffleItemRequestV2(nextContentId)) + } + } + + suspend fun delete(shuffleId: String): ApiResult<Unit> { + if (shuffleId.isBlank()) return invalidShuffle() + return exchange(HttpMethod.Delete, path(shuffleId), HttpStatusCode.NoContent) + } + + private fun path(shuffleId: String) = "/api/v2/shuffles/${shuffleId.encodeURLPathPart()}" + + private fun invalidShuffle(): ApiResult.Error = ApiResult.Error(422, "validation_failed", "Invalid shuffle request.") + + private suspend inline fun shuffleExchange( + method: HttpMethod, + path: String, + expected: HttpStatusCode, + noinline configure: HttpRequestBuilder.() -> Unit = {}, + ): ApiResult<Shuffle> = exchange<Shuffle, Shuffle>(method, path, expected, { + parameter("image_size", "large") + configure() + }) { shuffle -> + require(shuffle.id.isNotBlank() && shuffle.scope.id.isNotBlank()) { "The shuffle response has no identity." } + require(shuffle.current.contentId.isNotBlank() && shuffle.next.contentId.isNotBlank()) { + "The shuffle response has no pick." + } + shuffle + } + + private suspend inline fun <reified T> exchange( + method: HttpMethod, + path: String, + expected: HttpStatusCode, + noinline configure: HttpRequestBuilder.() -> Unit = {}, + ): ApiResult<T> = exchange<T, T>(method, path, expected, configure) { it } + + private suspend inline fun <reified T, R> exchange( + method: HttpMethod, + path: String, + expected: HttpStatusCode, + noinline configure: HttpRequestBuilder.() -> Unit, + crossinline project: (T) -> R, + ): ApiResult<R> { + val owner = tokens.captureProfileScope() ?: return identityChanged() + return ownedV2Call<T, R>(gate, tokens, owner, OwnerPolicy.PROFILE, expected, { scope -> + client.request(path) { + this.method = method + authScope(scope!!); requirePrairieAuth() + if (method != HttpMethod.Get) singleAttempt() + configure() + } + }) { project(it) } + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/SubtitleDownloadV2Api.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/SubtitleDownloadV2Api.kt index e5701ba0d2..f16c1fab93 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/SubtitleDownloadV2Api.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/SubtitleDownloadV2Api.kt @@ -27,10 +27,15 @@ internal data class StoredSubtitleV2( val score: Double, @SerialName("hearing_impaired") val hearingImpaired: Boolean, @SerialName("created_at") val createdAt: String, + val timing: SubtitleTiming = SubtitleTiming(), + val sync: SubtitleSyncJob? = null, ) { fun project(expectedFile: Int): DownloadedSubtitle { require(mediaFileId == expectedFile.toString()) - return DownloadedSubtitle(checkedPositiveId(id), expectedFile, provider, language, format, releaseName, score, hearingImpaired, createdAt) + return DownloadedSubtitle( + checkedPositiveId(id), expectedFile, provider, language, format, releaseName, score, hearingImpaired, createdAt, + timing, sync, + ) } } @Serializable private data class DownloadEnvelopeV2(val subtitle: StoredSubtitleV2) diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/SubtitleSyncV2Api.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/SubtitleSyncV2Api.kt new file mode 100644 index 0000000000..8ba17ef9de --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/SubtitleSyncV2Api.kt @@ -0,0 +1,104 @@ +package org.prairieserver.prairie.network.apiv2 + +import io.ktor.client.HttpClient +import io.ktor.client.request.* +import io.ktor.http.* +import kotlinx.serialization.Serializable +import org.prairieserver.prairie.model.subtitles.* +import org.prairieserver.prairie.network.* + +@Serializable private data class SyncListEnvelopeV2(val subtitles: List<SubtitleSyncState>) +@Serializable private data class SyncStateEnvelopeV2(val subtitle: SubtitleSyncState) + +/** The server's sync key grammar; anything else is refused before it reaches a URL. */ +private val syncKeyPattern = Regex("^(stored-[1-9][0-9]*|external-[0-9a-f]{64})$") + +fun isSubtitleSyncKey(key: String): Boolean = syncKeyPattern.matches(key) + +/** + * Subtitle sync (prairie-server "Subtitle sync"): the capability probe, and the + * operations on one media file's syncable subtitles, each named by the sync + * key the playback inventory publishes. Anyone who can play the file may + * start a sync or set the timing; demo mode answers 403. + */ +class SubtitleSyncV2Api(private val client: HttpClient, private val tokens: TokenManager, private val gate: ApiV2Gate) { + + /** GET /api/v2/subtitles/sync/status */ + suspend fun status(): ApiResult<SubtitleSyncCapability> { + val scope = tokens.snapshotCurrentScope() ?: return identityChanged() + return ownedV2Call<SubtitleSyncCapability, SubtitleSyncCapability>(gate, tokens, scope, OwnerPolicy.IDENTITY, HttpStatusCode.OK, { owner -> + client.get("/api/v2/subtitles/sync/status") { authScope(owner!!); requirePrairieAuth() } + }) { it } + } + + /** GET /api/v2/subtitles/{media_file_id}/sync: every syncable subtitle of the file. */ + suspend fun list(mediaFileId: Int): ApiResult<List<SubtitleSyncState>> { + val scope = tokens.snapshotCurrentScope() ?: return identityChanged() + if (mediaFileId <= 0) return invalidFile() + return ownedV2Call<SyncListEnvelopeV2, List<SubtitleSyncState>>(gate, tokens, scope, OwnerPolicy.IDENTITY, HttpStatusCode.OK, { owner -> + client.get("/api/v2/subtitles/$mediaFileId/sync") { authScope(owner!!); requirePrairieAuth() } + }) { body -> body.subtitles.onEach { requireFile(it, mediaFileId) } } + } + + /** GET /api/v2/subtitles/{media_file_id}/sync/{key}: one subtitle with its timing and latest job. */ + suspend fun read(mediaFileId: Int, key: String): ApiResult<SubtitleSyncState> = + readWithValidator(mediaFileId, key).map { it.first } + + /** POST /api/v2/subtitles/{media_file_id}/sync/{key}: 202 with the subtitle and its new or active job. */ + suspend fun start(mediaFileId: Int, key: String): ApiResult<SubtitleSyncState> { + val scope = tokens.snapshotCurrentScope() ?: return identityChanged() + invalidTarget(mediaFileId, key)?.let { return it } + return ownedV2Call<SyncStateEnvelopeV2, SubtitleSyncState>(gate, tokens, scope, OwnerPolicy.IDENTITY, HttpStatusCode.Accepted, { owner -> + client.post(syncPath(mediaFileId, key)) { authScope(owner!!); requirePrairieAuth(); singleAttempt() } + }) { requireFile(it.subtitle, mediaFileId, key) } + } + + /** + * PUT /api/v2/subtitles/{media_file_id}/sync/{key}/timing. The PUT needs + * the validator a read returns; a stale one answers 412. `{0, 1}` restores + * the original timing. + */ + suspend fun setTiming(mediaFileId: Int, key: String, timing: SubtitleTiming): ApiResult<SubtitleSyncState> { + val scope = tokens.snapshotCurrentScope() ?: return identityChanged() + val validator = when (val read = readWithValidator(mediaFileId, key)) { + is ApiResult.Success -> read.data.second + ?: return ApiResult.Error(0, "missing_etag", "Couldn't read the subtitle's current version.") + is ApiResult.Error -> return read + is ApiResult.NetworkError -> return read + } + return ownedV2Call<SyncStateEnvelopeV2, SubtitleSyncState>(gate, tokens, scope, OwnerPolicy.IDENTITY, HttpStatusCode.OK, { owner -> + client.put("${syncPath(mediaFileId, key)}/timing") { + authScope(owner!!); requirePrairieAuth(); singleAttempt() + header(HttpHeaders.IfMatch, validator) + contentType(ContentType.Application.Json) + setBody(timing) + } + }) { requireFile(it.subtitle, mediaFileId, key) } + } + + private suspend fun readWithValidator(mediaFileId: Int, key: String): ApiResult<Pair<SubtitleSyncState, String?>> { + val scope = tokens.snapshotCurrentScope() ?: return identityChanged() + invalidTarget(mediaFileId, key)?.let { return it } + var etag: String? = null + return ownedV2Call<SyncStateEnvelopeV2, Pair<SubtitleSyncState, String?>>(gate, tokens, scope, OwnerPolicy.IDENTITY, HttpStatusCode.OK, { owner -> + client.get(syncPath(mediaFileId, key)) { authScope(owner!!); requirePrairieAuth() } + .also { etag = it.headers[HttpHeaders.ETag]?.takeIf(String::isNotBlank) } + }) { requireFile(it.subtitle, mediaFileId, key) to etag } + } + + private fun syncPath(mediaFileId: Int, key: String) = "/api/v2/subtitles/$mediaFileId/sync/$key" + + private fun invalidTarget(mediaFileId: Int, key: String): ApiResult.Error? = when { + mediaFileId <= 0 -> invalidFile() + !isSubtitleSyncKey(key) -> ApiResult.Error(0, "invalid_sync_key", "This subtitle can't be synced.") + else -> null + } + + private fun invalidFile() = ApiResult.Error(0, "invalid_media_file", "A media file is required.") + + private fun requireFile(state: SubtitleSyncState, mediaFileId: Int, key: String? = null): SubtitleSyncState { + require(state.mediaFileId == mediaFileId.toString()) { "The subtitle belongs to another file." } + require(key == null || state.key == key) { "The server answered for another subtitle." } + return state + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/WatchDetailV2Api.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/WatchDetailV2Api.kt index fb94376510..76bc570fc8 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/WatchDetailV2Api.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/WatchDetailV2Api.kt @@ -11,18 +11,20 @@ import org.prairieserver.prairie.network.* class WatchDetailV2Api(private val client: HttpClient, private val tokens: TokenManager, private val gate: ApiV2Gate) { suspend fun capture(): AuthScopeSnapshot? = tokens.captureProfileScope() suspend fun current(owner: AuthScopeSnapshot): Boolean = owner.stillOwns(tokens, OwnerPolicy.FULL) - suspend fun detail(id: String, owner: AuthScopeSnapshot, libraryId: Int? = null): ApiResult<WatchDetail> = + /** [fileId] names the file being played, so the server fills that version's on-demand markers. */ + suspend fun detail(id: String, owner: AuthScopeSnapshot, libraryId: Int? = null, fileId: Int? = null): ApiResult<WatchDetail> = ownedV2Call<JsonObject, WatchDetail>(gate, tokens, owner, OwnerPolicy.FULL, HttpStatusCode.OK, { scope -> client.get("/api/v2/watch/${id.encodeURLPathPart()}") { authScope(scope!!) requirePrairieAuth() libraryId?.let { parameter("library_id", it) } + fileId?.let { parameter("file_id", it) } } }) { decodeWatchDetail(it, id) } } /** Adapt only fields consumed by WatchDetail; do not change the legacy model wire contract. */ -private fun decodeWatchDetail(body: JsonObject, id: String): WatchDetail { +internal fun decodeWatchDetail(body: JsonObject, id: String): WatchDetail { val content = body["content_id"] as? JsonPrimitive check(content?.isString == true && content.content == id) fun numericId(value: JsonElement): JsonPrimitive { @@ -30,6 +32,15 @@ private fun decodeWatchDetail(body: JsonObject, id: String): WatchDetail { check(text.isString) return JsonPrimitive(checkedPositiveId(text.content)) } + fun adaptMarkers(row: MutableMap<String, JsonElement>) { + for (key in listOf("intro", "credits", "recap", "preview")) { + val marker = row[key]?.takeUnless { it is JsonNull }?.jsonObject ?: continue + row[key] = buildJsonObject { + put("start", marker.getValue("start_seconds")) + put("end", marker.getValue("end_seconds")) + } + } + } val versions = body["versions"] as? JsonArray ?: error("Missing versions") val adapted = body.toMutableMap() adapted["versions"] = JsonArray(versions.map { value -> @@ -38,15 +49,10 @@ private fun decodeWatchDetail(body: JsonObject, id: String): WatchDetail { val duration = row["duration_seconds"] as? JsonPrimitive ?: error("Missing duration") check(!duration.isString && duration.double.isFinite() && duration.double >= 0) row["duration"] = duration + adaptMarkers(row) JsonObject(row) }) - for (key in listOf("intro", "credits", "recap", "preview")) { - val marker = body[key]?.takeUnless { it is JsonNull }?.jsonObject ?: continue - adapted[key] = buildJsonObject { - put("start", marker.getValue("start_seconds")) - put("end", marker.getValue("end_seconds")) - } - } + adaptMarkers(adapted) body["user_data"]?.takeUnless { it is JsonNull }?.jsonObject?.let { data -> val row = data.toMutableMap() row["last_file_id"]?.takeUnless { it is JsonNull }?.let { row["last_file_id"] = numericId(it) } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/pairing/PairingMessage.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/pairing/PairingMessage.kt index 4e4b4ad3eb..94dbcd9cb1 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/pairing/PairingMessage.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/pairing/PairingMessage.kt @@ -1,6 +1,8 @@ package org.prairieserver.prairie.pairing import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonArray @@ -40,6 +42,74 @@ enum class PairingServerStatus(val wire: String) { } } +/** + * Why a pushed server failed on the TV. Carried in [PairingMessage.ServerResult.error] + * so the phone can say what to fix. Older TVs send only [AuthFailed] (or, on + * older Android TVs, free text), and any value this build doesn't know reads + * as that generic failure. Mirrors prairie-apple `PairingFailureCode`. + */ +enum class PairingFailureCode(val wire: String) { + /** Device authorization could not be started or completed; the legacy catch-all. */ + AuthFailed("auth_failed"), + + /** The phone's user declined the request, or the server refused it. */ + Denied("denied"), + + /** The device code expired before approval, or was already used. */ + Expired("expired"), + + /** The TV could not reach the pushed address, and no offered alternative worked. */ + Unreachable("unreachable"), + + /** An address answered with a different deployment identity. */ + IdentityMismatch("identity_mismatch"), + + /** The server is v1-only, or no longer accepts this app version. */ + UpdateRequired("update_required"); + + companion object { + fun fromWire(value: String?): PairingFailureCode = + entries.firstOrNull { it.wire == value } ?: AuthFailed + } +} + +/** + * One address a deployment offers. The same value crosses the + * `system/connections` document and the pairing push, so a TV that can't reach + * the phone's address can verify and use one it can. Mirrors prairie-apple + * `ServerEndpoint`. Build it with [of] so [url] carries no trailing slash. + */ +data class PairingEndpoint( + val url: String, + val kind: Kind, + /** Provider slug for [Kind.Provider], e.g. `tailscale`. */ + val provider: String? = null, + /** Provider display name from its manifest; never derived from the hostname. */ + val displayName: String? = null, +) { + enum class Kind(val wire: String) { + Public("public"), + Provider("provider"); + + companion object { + /** Unknown kinds read as public, matching prairie-apple. */ + fun fromWire(value: String?): Kind = entries.firstOrNull { it.wire == value } ?: Public + } + } + + companion object { + /** Builds an endpoint with [url] normalized the way both platforms compare addresses. */ + fun of( + url: String, + kind: Kind, + provider: String? = null, + displayName: String? = null, + ): PairingEndpoint = PairingEndpoint(normalizeEndpointUrl(url), kind, provider, displayName) + } +} + +fun normalizeEndpointUrl(url: String): String = url.trim().trimEnd('/') + /** Thrown when a message cannot be encoded or decoded against the wire schema. */ class PairingMessageException(message: String) : Exception(message) @@ -59,10 +129,18 @@ sealed class PairingMessage { val supportedVersions: List<Int>, ) : PairingMessage() - /** phone → TV, one per chosen server. */ + /** + * phone → TV, one per chosen server. [serverIdentity] and [endpoints] are + * optional additions (protocol still v1): the deployment identity the phone + * verified at [serverURL], and the other addresses the deployment offers, + * so a TV that cannot reach the phone's address can verify and use one it + * can. Older peers omit and ignore them. + */ data class PushServer( val serverURL: String, val serverName: String?, + val serverIdentity: String? = null, + val endpoints: List<PairingEndpoint>? = null, ) : PairingMessage() /** @@ -76,7 +154,11 @@ sealed class PairingMessage { val matchCode: String, ) : PairingMessage() - /** TV → phone, terminal per-server outcome. */ + /** + * TV → phone, terminal per-server outcome. [serverURL] always echoes the + * pushed URL, even when the TV signed in at another address. [error] is a + * [PairingFailureCode] wire value on failure. + */ data class ServerResult( val serverURL: String, val status: PairingServerStatus, @@ -135,6 +217,10 @@ object PairingMessageCodec { put(KEY_TYPE, JsonPrimitive("pushServer")) put("serverURL", JsonPrimitive(msg.serverURL)) if (msg.serverName != null) put("serverName", JsonPrimitive(msg.serverName)) + if (msg.serverIdentity != null) put("serverIdentity", JsonPrimitive(msg.serverIdentity)) + msg.endpoints?.let { endpoints -> + put("endpoints", buildJsonArray { endpoints.forEach { add(it.toJson()) } }) + } } is PairingMessage.DeviceStarted -> { put(KEY_TYPE, JsonPrimitive("deviceStarted")) @@ -170,6 +256,17 @@ object PairingMessageCodec { "pushServer" -> PairingMessage.PushServer( serverURL = obj.requireString("serverURL"), serverName = obj.optionalString("serverName"), + serverIdentity = obj.optionalString("serverIdentity"), + endpoints = (obj["endpoints"] as? JsonArray)?.map { element -> + val endpoint = element as? JsonObject + ?: throw PairingMessageException("endpoints must hold objects") + PairingEndpoint.of( + url = endpoint.requireString("url"), + kind = PairingEndpoint.Kind.fromWire(endpoint.requireString("kind")), + provider = endpoint.optionalString("provider"), + displayName = endpoint.optionalString("displayName"), + ) + }, ) "deviceStarted" -> PairingMessage.DeviceStarted( serverURL = obj.requireString("serverURL"), @@ -189,6 +286,13 @@ object PairingMessageCodec { } } + private fun PairingEndpoint.toJson(): JsonObject = buildJsonObject { + put("url", JsonPrimitive(url)) + put("kind", JsonPrimitive(kind.wire)) + if (provider != null) put("provider", JsonPrimitive(provider)) + if (displayName != null) put("displayName", JsonPrimitive(displayName)) + } + private fun JsonObject.requireString(key: String): String { val prim = (this[key] as? JsonPrimitive) ?: throw PairingMessageException("Missing field: $key") @@ -196,7 +300,7 @@ object PairingMessageCodec { } private fun JsonObject.optionalString(key: String): String? = - (this[key] as? JsonPrimitive)?.content + (this[key] as? JsonPrimitive)?.takeIf { it !is JsonNull }?.content private fun JsonObject.requireIntArray(key: String): List<Int> { val arr = this[key]?.jsonArray diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/PlaybackMarkersUpdate.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/PlaybackMarkersUpdate.kt index b1f4bfdc89..21b0422f31 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/PlaybackMarkersUpdate.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/PlaybackMarkersUpdate.kt @@ -5,6 +5,7 @@ import org.prairieserver.prairie.network.PlaybackRealtimeEvent import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.intOrNull /** * Parsed `markers_updated` server event: the server recomputed marker ranges @@ -12,13 +13,16 @@ import kotlinx.serialization.json.doubleOrNull * the fresh values so skip-intro, the credits-based auto-advance, and the * timeline marker bands use them. All four marker kinds are surfaced; only * [intro] drives auto-skip and only [credits] drives auto-advance. A `null` - * range means "no such marker" (the server clears it). + * range means "no such marker" (the server clears it). [fileId] names the file + * the ranges belong to; the server also sends a file's update to sessions that + * requested it but are playing another file, so players must check it. */ data class PlaybackMarkersUpdate( val intro: TimeRange?, val credits: TimeRange?, val recap: TimeRange?, val preview: TimeRange?, + val fileId: Int? = null, ) /** @@ -41,6 +45,7 @@ fun decodeMarkersUpdate(payload: JsonObject): PlaybackMarkersUpdate { credits = range("credits"), recap = range("recap"), preview = range("preview"), + fileId = (payload["file_id"] as? JsonPrimitive)?.takeIf { !it.isString }?.intOrNull?.takeIf { it > 0 }, ) } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/PlaybackParts.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/PlaybackParts.kt new file mode 100644 index 0000000000..c576afe11e --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/PlaybackParts.kt @@ -0,0 +1,45 @@ +package org.prairieserver.prairie.playback + +import org.prairieserver.prairie.model.catalog.FileVersion + +private val SEQUENTIAL_PART_KINDS = setOf("multipart_movie", "split_episode") + +private fun FileVersion.isSequentialPart(): Boolean = + presentationKind in SEQUENTIAL_PART_KINDS && presentationPartIndex != null + +private fun FileVersion.isSamePresentationAs(other: FileVersion): Boolean = + presentationKind == other.presentationKind && + presentationGroupKey == other.presentationGroupKey && + editionKey == other.editionKey + +/** + * The file that plays after [currentFileId] when it is one part of a + * multi-part movie or split episode: the lowest-numbered later part of the + * same presentation and edition, preferring the current file's resolution. + * Null when [currentFileId] is not a part or is the last one. + */ +fun nextPlaybackPartFileId(versions: List<FileVersion>, currentFileId: Int?): Int? { + val current = versions.firstOrNull { it.fileId == currentFileId } ?: return null + if (!current.isSequentialPart()) return null + val currentIndex = current.presentationPartIndex ?: return null + val later = versions.filter { + it.isSequentialPart() && it.isSamePresentationAs(current) && it.presentationPartIndex!! > currentIndex + } + val nextIndex = later.minOfOrNull { it.presentationPartIndex!! } ?: return null + val candidates = later.filter { it.presentationPartIndex == nextIndex } + return (candidates.firstOrNull { it.resolution == current.resolution } ?: candidates.first()).fileId +} + +/** + * The first part of [selected]'s presentation, for a start from the + * beginning: a multi-part item starts at its lowest-numbered part, preferring + * [selected]'s resolution. Anything that is not a later part is returned as is. + */ +fun firstPlaybackPart(versions: List<FileVersion>, selected: FileVersion): FileVersion { + if (!selected.isSequentialPart()) return selected + val parts = versions.filter { it.isSequentialPart() && it.isSamePresentationAs(selected) } + val firstIndex = parts.minOfOrNull { it.presentationPartIndex!! } ?: return selected + if (selected.presentationPartIndex == firstIndex) return selected + val candidates = parts.filter { it.presentationPartIndex == firstIndex } + return candidates.firstOrNull { it.resolution == selected.resolution } ?: candidates.first() +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/PlaybackSubtitleReady.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/PlaybackSubtitleReady.kt index 2eb7bf5c2e..56abe8cb64 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/PlaybackSubtitleReady.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/PlaybackSubtitleReady.kt @@ -72,6 +72,7 @@ fun applyAuthoritativeSubtitleReadyTrack( downloadId = update.subtitleId.takeIf { item.source == SUBTITLE_SOURCE_DOWNLOADED }, serverTrackId = item.trackId, serverDelivery = item.delivery, + syncKey = item.syncKey, ) val rows = (existing.filterNot { it.serverTrackId == item.trackId || it.index == item.combinedIndex diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/PlaybackSubtitleTimingChanged.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/PlaybackSubtitleTimingChanged.kt new file mode 100644 index 0000000000..24f80a8fac --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/PlaybackSubtitleTimingChanged.kt @@ -0,0 +1,96 @@ +package org.prairieserver.prairie.playback + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo +import org.prairieserver.prairie.model.subtitles.SubtitleSyncJob +import org.prairieserver.prairie.model.subtitles.SubtitleTiming +import org.prairieserver.prairie.network.PlaybackRealtimeEvent +import org.prairieserver.prairie.network.PrairieJson + +/** + * `subtitle_timing_changed`: the server retimed a subtitle of the file, a + * stored one or a sidecar (a sync result, or a timing set or reset). The + * track's URL is unchanged but now serves the new timing, so a player showing + * it must fetch it again. + */ +data class PlaybackSubtitleTimingChanged( + val sessionId: String?, + val mediaFileId: Int?, + /** The stored subtitle's ID; absent for a sidecar. */ + val subtitleId: Int?, + /** The track's sync key, as the inventory's `sync_key`; absent from servers that predate sidecar sync. */ + val syncKey: String? = null, +) + +fun decodePlaybackSubtitleTimingChanged(event: PlaybackRealtimeEvent.ServerEvent): PlaybackSubtitleTimingChanged = + decodePlaybackSubtitleTimingChanged(event.payload) + +fun decodePlaybackSubtitleTimingChanged(payload: JsonObject): PlaybackSubtitleTimingChanged = + PlaybackSubtitleTimingChanged( + sessionId = payload.string("session_id"), + mediaFileId = (payload["file_id"] as? JsonPrimitive)?.intOrNull, + subtitleId = (payload["subtitle_id"] as? JsonPrimitive)?.intOrNull, + syncKey = payload.string("sync_key"), + ) + +/** + * `subtitle_sync_updated`: one step of a sync job of the file (queued, each + * progress update, and the outcome), with the subtitle's timing after it. + * Best effort; the sync operations stay authoritative. + */ +data class PlaybackSubtitleSyncUpdated( + val sessionId: String?, + val mediaFileId: Int?, + val syncKey: String, + val subtitleId: Int?, + val timing: SubtitleTiming, + val job: SubtitleSyncJob, +) + +/** Null when the payload lacks the sync key, the timing, or a readable job. */ +fun decodePlaybackSubtitleSyncUpdated(event: PlaybackRealtimeEvent.ServerEvent): PlaybackSubtitleSyncUpdated? = + decodePlaybackSubtitleSyncUpdated(event.payload) + +fun decodePlaybackSubtitleSyncUpdated(payload: JsonObject): PlaybackSubtitleSyncUpdated? { + val syncKey = payload.string("sync_key")?.takeIf(String::isNotBlank) ?: return null + val timing = payload["timing"] as? JsonObject ?: return null + val job = payload["job"] as? JsonObject ?: return null + return try { + PlaybackSubtitleSyncUpdated( + sessionId = payload.string("session_id"), + mediaFileId = (payload["file_id"] as? JsonPrimitive)?.intOrNull, + syncKey = syncKey, + subtitleId = (payload["subtitle_id"] as? JsonPrimitive)?.intOrNull, + timing = PrairieJson.decodeFromJsonElement(SubtitleTiming.serializer(), timing), + job = PrairieJson.decodeFromJsonElement(SubtitleSyncJob.serializer(), job), + ) + } catch (_: IllegalArgumentException) { + null + } +} + +private fun JsonObject.string(name: String): String? = (this[name] as? JsonPrimitive)?.contentOrNull + +private val storedSubtitleIdParam = Regex("[?&]downloaded_subtitle_id=([1-9][0-9]*)(?:[&#]|$)") + +/** + * The stored (downloaded or uploaded) subtitle behind this row, or null for + * embedded, external, and generated-live tracks. Rows merged from the stored + * list carry it as [PlayerSubtitleInfo.downloadId]; v3 inventory rows carry it + * only as the `downloaded_subtitle_id` the server pins on their sidecar URL. + */ +fun PlayerSubtitleInfo.storedSubtitleId(): Int? = + downloadId ?: storedSubtitleIdParam.find(url)?.groupValues?.get(1)?.toIntOrNull() + +/** + * True when [mounted] includes the subtitle this event retimed: by sync key, + * or by stored subtitle ID from a server that sends no key. + */ +fun PlaybackSubtitleTimingChanged.affects(mounted: List<PlayerSubtitleInfo>): Boolean { + syncKey?.takeIf(String::isNotBlank)?.let { key -> return mounted.any { it.syncKey == key } } + val id = subtitleId ?: return false + return mounted.any { it.storedSubtitleId() == id } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/ShufflePlayback.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/ShufflePlayback.kt new file mode 100644 index 0000000000..5ec8f40e87 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/ShufflePlayback.kt @@ -0,0 +1,157 @@ +package org.prairieserver.prairie.playback + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import org.prairieserver.prairie.model.section.SectionItem +import org.prairieserver.prairie.model.settings.EpisodeSpoilers +import org.prairieserver.prairie.model.shuffle.Shuffle +import org.prairieserver.prairie.model.shuffle.ShuffleScope +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.apiv2.ShufflesV2Api + +/** A shuffle's next pick in the shape the post-roll renders. */ +data class ShuffleNextPick( + val contentId: String, + /** False for a movie, which has no series, season, or episode line. */ + val isEpisode: Boolean, + val title: String, + val seriesTitle: String?, + val seasonNumber: Int, + val episodeNumber: Int, + val stillUrl: String?, + val stillThumbhash: String?, + val overview: String?, + val runtimeMinutes: Int, + /** An episode the profile hasn't started, by the pick's own watch state; never a movie. */ + val isUnwatched: Boolean, +) + +/** Names what a shuffle draws from: "Movies", or "Breaking Bad · Season 2" for a season. */ +fun ShuffleScope.label(): String = + parentTitle?.takeIf { it.isNotBlank() }?.let { "$it · $title" } ?: title + +/** + * The pick that plays after [playingContentId], or null when the shuffle is + * finished: a scope with one playable item announces that item again, and + * playing it would only restart what just played. + */ +fun Shuffle.nextPickAfter(playingContentId: String): ShuffleNextPick? = + next.takeIf { it.contentId != playingContentId }?.toShuffleNextPick() + +internal fun SectionItem.toShuffleNextPick(): ShuffleNextPick { + val episode = type == "episode" + return ShuffleNextPick( + contentId = contentId, + isEpisode = episode, + title = title, + seriesTitle = seriesTitle.takeIf { episode }, + seasonNumber = if (episode) seasonNumber ?: 0 else 0, + episodeNumber = if (episode) episodeNumber ?: 0 else 0, + // An episode card's poster is its 16:9 still; a movie's poster is + // portrait, so the post-roll shows its backdrop instead. + stillUrl = (if (episode) posterUrl else null) ?: backdropUrl, + stillThumbhash = (if (episode) posterThumbhash else null) ?: backdropThumbhash, + overview = overview, + runtimeMinutes = runtime ?: 0, + isUnwatched = episode && EpisodeSpoilers.isUnwatched(this), + ) +} + +/** + * The client half of one running shuffle, owned by a player. The server picks + * every item; this keeps the last shuffle it returned so the post-roll can + * announce `next`, and remembers when the server said nothing in the scope can + * play any more. + * + * A failed read keeps the last pick: advancing re-checks it on the server, + * which replaces a pick that can no longer play. + */ +class ShufflePlayback( + private val api: ShufflesV2Api, + val shuffleId: String, + initial: Shuffle? = null, +) { + /** The last shuffle the server returned. */ + var latest: Shuffle? = initial + private set + + /** The server answered `409`: nothing in the scope can play any more. */ + var exhausted: Boolean = false + private set + + /** The scope label once the shuffle has been read, else null. */ + val scopeLabel: String? get() = latest?.scope?.label() + + /** The pick after [playingContentId]; null when finished or not yet read. */ + fun nextPickAfter(playingContentId: String): ShuffleNextPick? = + if (exhausted) null else latest?.nextPickAfter(playingContentId) + + // Counts advance and skip calls, and how many are still waiting for the + // server. A read that overlaps a mutation may answer with the state that + // mutation replaced, so its answer is dropped. + private var mutations = 0 + private var mutationsInFlight = 0 + + /** + * Re-reads the shuffle; the server may replace a `next` that can no longer + * play. The answer is ignored when an advance or skip was in flight when + * the read started or started while it ran. + */ + suspend fun refresh(): ApiResult<Shuffle> { + val startedAfter = mutations + val overlapped = mutationsInFlight > 0 + val result = api.get(shuffleId) + return if (!overlapped && mutations == startedAfter) record(result) else result + } + + /** + * Moves the shuffle past [fromContentId], the item that finished. Play the + * returned `current`. A retry for the same item changes nothing. + */ + suspend fun advance(fromContentId: String): ApiResult<Shuffle> = + mutate { api.advance(shuffleId, fromContentId) } + + /** Pick Another: replaces the announced next item with another pick. */ + suspend fun pickAnother(): ApiResult<Shuffle> { + val next = latest?.next ?: return ApiResult.Error(0, "not_loaded", "The shuffle has not loaded yet.") + return mutate { api.skip(shuffleId, next.contentId) } + } + + /** Stop shuffling. */ + suspend fun stop(): ApiResult<Unit> = api.delete(shuffleId) + + /** + * Stop shuffling without waiting for the answer. The player leaves right + * away, so the request must outlive it; a failure only leaves an unused + * shuffle the server deletes later. + */ + fun stopInBackground() { + detachedScope.launch { stop() } + } + + private suspend fun mutate(call: suspend () -> ApiResult<Shuffle>): ApiResult<Shuffle> { + mutations++ + mutationsInFlight++ + try { + return record(call()) + } finally { + mutationsInFlight-- + } + } + + private fun record(result: ApiResult<Shuffle>): ApiResult<Shuffle> { + when (result) { + is ApiResult.Success -> { + latest = result.data + exhausted = false + } + is ApiResult.Error -> if (result.code == 409) exhausted = true + is ApiResult.NetworkError -> Unit + } + return result + } +} + +private val detachedScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/SubtitleSyncController.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/SubtitleSyncController.kt new file mode 100644 index 0000000000..961e65b598 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/SubtitleSyncController.kt @@ -0,0 +1,458 @@ +package org.prairieserver.prairie.playback + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo +import org.prairieserver.prairie.model.playback.SubtitleIdentity +import org.prairieserver.prairie.model.subtitles.DownloadedSubtitle +import org.prairieserver.prairie.model.subtitles.SubtitleSyncJob +import org.prairieserver.prairie.model.subtitles.SubtitleSyncResultLine +import org.prairieserver.prairie.model.subtitles.SubtitleSyncState +import org.prairieserver.prairie.model.subtitles.SubtitleTiming +import org.prairieserver.prairie.model.subtitles.subtitleSyncResultLine +import org.prairieserver.prairie.model.subtitles.subtitleSyncStatusLabel +import org.prairieserver.prairie.model.subtitles.syncPhaseLabel +import org.prairieserver.prairie.model.subtitles.syncProgressPercent +import org.prairieserver.prairie.model.subtitles.syncState +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.repository.SubtitleSyncSource + +/** What the player knows about one syncable subtitle's timing and sync. */ +data class SubtitleSyncEntry( + val state: SubtitleSyncState, + /** A sync or timing request is on the wire. */ + val busy: Boolean = false, + /** The server refused to let this viewer retime the subtitle (403, as in demo mode). */ + val forbidden: Boolean = false, + /** The subtitle's format cannot be synced (422). */ + val unsupported: Boolean = false, + /** The last action's failure, in plain words. */ + val error: String? = null, + /** Polling gave up; reopening the subtitle menu re-arms it. */ + val pollExpired: Boolean = false, + /** + * A job this viewer started: a sync they asked for, or the automatic sync + * of a subtitle they just downloaded or uploaded. The player reports its + * progress and outcome to them. + */ + val watchedJobId: String? = null, +) { + val statusLabel: String? get() = subtitleSyncStatusLabel(state.timing, state.sync) + val inProgress: Boolean get() = state.sync?.inProgress == true +} + +data class SubtitleSyncUiState( + /** The server can align subtitles to audio for this viewer. */ + val available: Boolean = false, + /** Subtitle files next to the media can be synced too. */ + val externalAvailable: Boolean = false, + /** Entries by sync key. */ + val entries: Map<String, SubtitleSyncEntry> = emptyMap(), +) { + fun canSync(entry: SubtitleSyncEntry): Boolean = + available && !entry.unsupported && (!entry.state.isSidecar || externalAvailable) +} + +/** The sync status line for a row of the subtitle menu, or null when it has none. */ +fun SubtitleSyncUiState.statusLabelFor(track: PlayerSubtitleInfo?): String? = + track?.syncKey?.let(entries::get)?.statusLabel + +/** + * The timing section the player shows for its selected subtitle: "Sync to + * audio" with a running job's progress, the last result, and "Reset timing". + * Anyone who can play the file may retime it; a refusal (demo mode) replaces + * the actions with a short explanation. + */ +data class SubtitleTimingActions( + val key: String, + val statusLabel: String?, + val canSync: Boolean, + val canReset: Boolean, + val inProgress: Boolean, + /** 0..100 while a job runs. */ + val percent: Int?, + /** What a running job is doing. */ + val phaseLabel: String?, + /** The last finished job's outcome. */ + val result: SubtitleSyncResultLine?, + /** What a sync does, shown before the first one. */ + val note: String?, + val busy: Boolean, + val forbidden: Boolean, + val error: String?, +) { + val actionsEnabled: Boolean get() = !busy && !inProgress + + companion object { + const val FORBIDDEN_MESSAGE = "This server doesn't allow changing subtitle timing." + const val SIDECAR_NOTE = "Matches the timing to the audio for everyone. The file itself isn't changed." + const val STORED_NOTE = "Matches the timing to the audio for everyone watching." + } +} + +fun SubtitleSyncUiState.timingActionsFor(key: String?): SubtitleTimingActions? { + val entry = key?.let(entries::get) ?: return null + val canSync = canSync(entry) + val canReset = !entry.state.timing.isIdentity + if (!entry.forbidden && !canSync && !canReset && entry.error == null) return null + val job = entry.state.sync + val inProgress = entry.inProgress + val result = if (inProgress || entry.error != null) null else subtitleSyncResultLine(entry.state.timing, job) + return SubtitleTimingActions( + key = entry.state.key, + statusLabel = entry.statusLabel, + canSync = canSync, + canReset = canReset, + inProgress = inProgress, + percent = syncProgressPercent(job), + phaseLabel = syncPhaseLabel(job), + result = result, + note = when { + entry.forbidden || !canSync || inProgress || result != null -> null + entry.state.isSidecar -> SubtitleTimingActions.SIDECAR_NOTE + else -> SubtitleTimingActions.STORED_NOTE + }, + busy = entry.busy, + forbidden = entry.forbidden, + error = entry.error, + ) +} + +/** The sync key of the subtitle a selection plays, or null when it has none. */ +fun List<PlayerSubtitleInfo>.syncKeyFor(identity: SubtitleIdentity?): String? = when (identity) { + is SubtitleIdentity.ServerSidecar -> firstOrNull { it.index == identity.serverIndex }?.syncKey + is SubtitleIdentity.Downloaded -> firstOrNull { it.downloadId == identity.downloadId }?.syncKey + else -> null +} + +/** + * The sync key of the subtitle on screen: the [selected] one, when the player + * has it mounted. A legacy session mounts every row, so the first mounted key + * is not necessarily the one showing. + */ +fun List<PlayerSubtitleInfo>.activeSyncKey(selected: SubtitleIdentity?, mounted: List<PlayerSubtitleInfo>): String? = + syncKeyFor(selected)?.takeIf(mounted::includesSyncKey) + +/** + * True when [rows] include the subtitle named by [key]: by its inventory sync + * key, or, for a row from a server that publishes none, by its stored ID. + */ +fun List<PlayerSubtitleInfo>.includesSyncKey(key: String): Boolean = any { row -> + row.syncKey == key || + (row.syncKey == null && row.storedSubtitleId()?.let(SubtitleSyncState::storedKey) == key) +} + +/** + * Tracks the timing and latest sync job of the playing file's syncable + * subtitles (stored ones and subtitle files next to the media), keyed by the + * sync key the playback inventory publishes. It loads them, follows running + * jobs through realtime updates with polling as the fallback, and performs + * "Sync to audio" and "Reset timing". + * + * [onTimingChanged] fires once for each timing change it observes after a + * subtitle's first read, so the player can fetch that track's cues again. + * Results that land after the file changed are dropped; the repository drops + * them after an account or profile change. + */ +class SubtitleSyncController( + private val repository: SubtitleSyncSource, + private val scope: CoroutineScope, + private val onTimingChanged: (String) -> Unit = {}, + private val pollIntervalMs: Long = POLL_INTERVAL_MS, + private val pollLimitMs: Long = POLL_LIMIT_MS, +) { + private val _state = MutableStateFlow(SubtitleSyncUiState()) + val state: StateFlow<SubtitleSyncUiState> = _state.asStateFlow() + + private var mediaFileId: Int? = null + private var syncKeys: Set<String> = emptySet() + private var generation = 0L + private var loadJob: Job? = null + private var pollJob: Job? = null + + /** The timing each subtitle's cues were last loaded with, as far as this controller knows. */ + private val loadedTiming = mutableMapOf<String, SubtitleTiming>() + /** Subtitles a realtime event said were retimed, whose next read must reload cues. */ + private val reloadOnNextRead = mutableSetOf<String>() + /** Subtitles a realtime update reported on since the last poll; the next poll skips them. */ + private val pushedSincePoll = mutableSetOf<String>() + /** How long each running job has been polled. */ + private val polledMs = mutableMapOf<String, Long>() + /** Numbers every read as it is sent and every realtime update as it arrives. */ + private var sequence = 0L + /** The number of the read or update each subtitle's entry shows. */ + private val shownSequence = mutableMapOf<String, Long>() + + /** + * Follows the playing file and its inventory's sync keys. A new file (or + * none) drops everything in flight; a key the inventory gains re-reads the + * file's subtitles. + */ + fun bind(mediaFileId: Int?, syncKeys: Set<String>) { + if (mediaFileId != this.mediaFileId) { + this.mediaFileId = mediaFileId + this.syncKeys = syncKeys + generation++ + loadJob?.cancel() + pollJob?.cancel() + loadedTiming.clear() + reloadOnNextRead.clear() + pushedSincePoll.clear() + polledMs.clear() + shownSequence.clear() + _state.value = SubtitleSyncUiState() + if (mediaFileId != null && syncKeys.isNotEmpty()) reload() + return + } + val gained = syncKeys.any { it !in this.syncKeys && it !in _state.value.entries } + this.syncKeys = syncKeys + if (gained) reload() + } + + /** Re-reads the capability and the file's syncable subtitles, re-arming expired polls. */ + fun reload() { + val fileId = mediaFileId ?: return + if (syncKeys.isEmpty() && _state.value.entries.isEmpty()) return + val owner = generation + polledMs.clear() + _state.update { current -> + current.copy(entries = current.entries.mapValues { (_, entry) -> entry.copy(pollExpired = false) }) + } + loadJob?.cancel() + loadJob = scope.launch { + val capability = (repository.syncCapability() as? ApiResult.Success)?.data + val sent = ++sequence + val listed = (repository.listSync(fileId) as? ApiResult.Success)?.data + if (owner != generation) return@launch + // A probe that failed keeps what the last one said. + if (capability != null) { + _state.update { + it.copy(available = capability.available, externalAvailable = capability.available && capability.external) + } + } + listed?.forEach { observe(it, sent) } + startPolling() + } + } + + /** Records a subtitle returned by a download or upload, following its automatic sync. */ + fun remember(subtitle: DownloadedSubtitle) { + if (subtitle.mediaFileId != mediaFileId) return + val state = subtitle.syncState() + polledMs.remove(state.key) + observe(state, ++sequence, watch = state.sync?.takeIf { it.inProgress }?.id) + startPolling() + } + + /** + * The server announced new timing for [key] (realtime): re-read it, and + * reload its cues unless a realtime update already brought that timing. + */ + fun timingChanged(key: String) { + val fileId = mediaFileId ?: return + val owner = generation + reloadOnNextRead += key + scope.launch { + val sent = ++sequence + val read = repository.readSync(fileId, key) + if (owner != generation) return@launch + when (read) { + is ApiResult.Success -> { + observe(read.data, sent) + startPolling() + } + // The event is authoritative enough to reload on its own. + else -> if (reloadOnNextRead.remove(key)) onTimingChanged(key) + } + } + } + + /** The server reported a step of a sync job (realtime). */ + fun syncUpdated(update: PlaybackSubtitleSyncUpdated) { + if (update.mediaFileId != null && update.mediaFileId != mediaFileId) return + val entry = _state.value.entries[update.syncKey] + if (entry == null) { + // Not loaded yet (a track the inventory just gained): read them all. + reload() + return + } + pushedSincePoll += update.syncKey + observe(entry.state.copy(timing = update.timing, sync = update.job), ++sequence) + startPolling() + } + + fun requestSync(key: String) { + val fileId = mediaFileId ?: return + if (_state.value.entries[key]?.busy != false) return + val owner = generation + polledMs.remove(key) + patch(key) { it.copy(busy = true, error = null, pollExpired = false) } + scope.launch { + val sent = ++sequence + val result = repository.startSync(fileId, key) + if (owner != generation) return@launch + when (result) { + is ApiResult.Success -> { + observe(result.data, sent, watch = result.data.sync?.id) + patch(key) { it.copy(busy = false) } + startPolling() + } + else -> fail(key, result, "Sync failed") + } + } + } + + fun resetTiming(key: String) { + val fileId = mediaFileId ?: return + if (_state.value.entries[key]?.busy != false) return + val owner = generation + patch(key) { it.copy(busy = true, error = null) } + scope.launch { + val sent = ++sequence + val result = repository.resetTiming(fileId, key) + if (owner != generation) return@launch + when (result) { + is ApiResult.Success -> { + observe(result.data, sent) + patch(key) { it.copy(busy = false) } + } + else -> fail(key, result, "Couldn't reset timing") + } + } + } + + private fun fail(key: String, result: ApiResult<*>, fallback: String) { + val error = result as? ApiResult.Error + patch(key) { entry -> + when (error?.code) { + 403 -> entry.copy(busy = false, forbidden = true, error = null) + 422 -> entry.copy(busy = false, unsupported = true, error = "This format can't be synced.") + 412 -> entry.copy(busy = false, error = "The subtitle changed. Try again.") + else -> entry.copy(busy = false, error = error?.message?.takeIf { it.isNotBlank() } ?: fallback) + } + } + } + + /** + * Records a server view of a subtitle from the read sent, or the update + * received, as number [sent]; [watch] marks a job this viewer started. + */ + private fun observe(state: SubtitleSyncState, sent: Long, watch: String? = null) { + val key = state.key + val known = _state.value.entries[key] + if (known != null && isStale(state.sync, sent, known)) { + // A response that arrived after a newer one: keep what the newer + // one said, but still follow the job if this viewer started it. + if (watch != null && watch == known.state.sync?.id) patch(key) { it.copy(watchedJobId = watch) } + return + } + shownSequence[key] = sent + val previous = loadedTiming.put(key, state.timing) + val forced = reloadOnNextRead.remove(key) + val inProgress = state.sync?.inProgress == true + if (!inProgress) polledMs.remove(key) + _state.update { current -> + val entry = current.entries[key] ?: SubtitleSyncEntry(state) + current.copy( + entries = current.entries + (key to entry.copy( + state = state, + pollExpired = inProgress && entry.pollExpired, + watchedJobId = watch ?: entry.watchedJobId, + )), + ) + } + // A realtime event about a subtitle never read before reloads it; a + // known one reloads only when its timing really moved, so the event + // that follows a realtime update carrying the new timing is a no-op. + if (if (previous == null) forced else previous != state.timing) onTimingChanged(key) + } + + /** + * True when a view reporting [incoming], from read or update number [sent], + * is older than [known]. The server does not sequence its responses and + * realtime updates, so a response can arrive after a newer one. A view of + * an older job is stale. So is one from a read sent before [known] arrived, + * unless it reports a job [known] has not reached, which the server can only + * have produced later. + */ + private fun isStale(incoming: SubtitleSyncJob?, sent: Long, known: SubtitleSyncEntry): Boolean { + val current = known.state.sync + if (isOlderJob(incoming, current)) return true + val sentBefore = sent < (shownSequence[known.state.key] ?: 0L) + val laterJob = incoming != null && (current == null || isOlderJob(current, incoming)) + return sentBefore && !laterJob + } + + /** + * True when [incoming] describes an earlier point than [current]: an older + * job, or the same job further back (running after it finished, or at + * less progress). + */ + private fun isOlderJob(incoming: SubtitleSyncJob?, current: SubtitleSyncJob?): Boolean { + if (incoming == null || current == null) return false + if (incoming.id != current.id) { + return incoming.createdAt.isNotEmpty() && current.createdAt.isNotEmpty() && incoming.createdAt < current.createdAt + } + return jobStep(incoming) < jobStep(current) + } + + /** How far a job has got: its progress while active, past every progress once finished. */ + private fun jobStep(job: SubtitleSyncJob): Double = if (job.inProgress) job.progress ?: 0.0 else 2.0 + + private fun patch(key: String, change: (SubtitleSyncEntry) -> SubtitleSyncEntry) { + _state.update { current -> + val entry = current.entries[key] ?: return@update current + current.copy(entries = current.entries + (key to change(entry))) + } + } + + /** + * Polls every running job until it ends, its limit passes, or the file + * changes. Realtime updates usually arrive first; a subtitle that had one + * since the last poll skips it. + */ + private fun startPolling() { + if (pollJob?.isActive == true) return + val fileId = mediaFileId ?: return + val owner = generation + pollJob = scope.launch { + while (owner == generation) { + if (pollable().isEmpty()) return@launch + delay(pollIntervalMs) + if (owner != generation) return@launch + for (key in pollable()) { + val polled = (polledMs[key] ?: 0L) + pollIntervalMs + polledMs[key] = polled + if (polled > pollLimitMs) { + polledMs.remove(key) + patch(key) { it.copy(pollExpired = true) } + continue + } + if (pushedSincePoll.remove(key)) continue + val sent = ++sequence + when (val read = repository.readSync(fileId, key)) { + is ApiResult.Success -> if (owner == generation) observe(read.data, sent) + // A subtitle the server stops answering for (deleted, + // access lost) is not polled again until a reload. + is ApiResult.Error -> if (owner == generation) patch(key) { it.copy(pollExpired = true) } + is ApiResult.NetworkError -> Unit + } + } + } + } + } + + private fun pollable(): List<String> = + _state.value.entries.values.filter { it.inProgress && !it.pollExpired }.map { it.state.key } + + companion object { + const val POLL_INTERVAL_MS = 3_000L + const val POLL_LIMIT_MS = 5 * 60_000L + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/SubtitleSyncFeedback.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/SubtitleSyncFeedback.kt new file mode 100644 index 0000000000..13009ebcc5 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/SubtitleSyncFeedback.kt @@ -0,0 +1,309 @@ +package org.prairieserver.prairie.playback + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.prairieserver.prairie.model.subtitles.SubtitleSyncJob +import org.prairieserver.prairie.model.subtitles.SubtitleSyncState +import org.prairieserver.prairie.model.subtitles.SubtitleTiming +import org.prairieserver.prairie.model.subtitles.describeSyncTiming +import org.prairieserver.prairie.model.subtitles.syncFailureMessage +import org.prairieserver.prairie.model.subtitles.syncPhaseLabel +import org.prairieserver.prairie.model.subtitles.syncProgressPercent + +/** What the player's subtitle sync card shows. */ +data class SubtitleSyncNotice( + /** Stays the same while one job runs, so the card updates in place. */ + val id: String, + val tone: Tone, + val title: String, + val detail: String? = null, + /** 0..100 while a job runs. */ + val percent: Int? = null, +) { + enum class Tone { Progress, Success, Info, Warning } +} + +/** The most recent job this viewer started, with its subtitle. */ +data class SubtitleSyncWatched( + val key: String, + /** The subtitle's language name, or null when it has none to show. */ + val name: String?, + val job: SubtitleSyncJob, + val timing: SubtitleTiming, + /** The subtitle's cue revision now. */ + val revision: Int, +) + +/** + * What one update tells the feedback about sync state and the screen. A cue + * revision grows each time the player fetches a track again because its + * timing changed; the loaded revision is the one whose cues are on screen. + */ +data class SubtitleSyncFeedbackInput( + val watched: SubtitleSyncWatched?, + /** Sync key of the track on screen. */ + val activeKey: String?, + val activeRevision: Int = 0, + val activeLoadedRevision: Int = 0, + /** The entry of the track on screen. */ + val active: SubtitleSyncEntry? = null, +) + +data class SubtitleSyncFeedbackState( + val input: SubtitleSyncFeedbackInput? = null, + val notice: SubtitleSyncNotice? = null, + /** Watched jobs whose outcome was announced. */ + val announced: Set<String> = emptySet(), + /** Watched synced jobs whose corrected cues are showing. */ + val applied: Set<String> = emptySet(), + /** Cue revision of a watched job's track when the job was first seen. */ + val startRevision: Map<String, Int> = emptyMap(), + val applying: Applying? = null, + /** A timing change of the track on screen nobody here asked for, until it shows. */ + val foreign: Foreign? = null, +) { + data class Applying( + val key: String, + val jobId: String, + val detail: String, + /** The track's cue revision when the job started; a later one that loads shows the result. */ + val startRevision: Int, + ) + + data class Foreign(val key: String, val revision: Int) +} + +/** Shows the success the applying job earned once its cues show. */ +fun SubtitleSyncFeedbackState.finishApplying(): SubtitleSyncFeedbackState { + val applying = applying ?: return this + return copy( + applying = null, + applied = applied + applying.jobId, + notice = SubtitleSyncNotice( + id = applying.jobId, + tone = SubtitleSyncNotice.Tone.Success, + title = "Subtitles synced", + detail = applying.detail, + ), + ) +} + +private fun subtitlesOf(name: String?): String = name?.let { "$it subtitles" } ?: "subtitles" + +private fun outcomeNotice(watched: SubtitleSyncWatched): SubtitleSyncNotice? { + val job = watched.job + val subject = subtitlesOf(watched.name).replaceFirstChar(Char::uppercaseChar) + return when (job.status) { + SubtitleSyncJob.SYNCED -> SubtitleSyncNotice( + id = job.id, + tone = SubtitleSyncNotice.Tone.Success, + title = "$subject synced", + detail = describeSyncTiming(watched.timing), + ) + SubtitleSyncJob.ALREADY_SYNCED -> SubtitleSyncNotice( + id = job.id, + tone = SubtitleSyncNotice.Tone.Info, + title = "$subject already match the audio", + ) + SubtitleSyncJob.NO_MATCH -> SubtitleSyncNotice( + id = job.id, + tone = SubtitleSyncNotice.Tone.Warning, + title = "$subject don't match the audio", + detail = "They're probably for another release. The timing wasn't changed.", + ) + SubtitleSyncJob.FAILED -> SubtitleSyncNotice( + id = job.id, + tone = SubtitleSyncNotice.Tone.Warning, + title = "Couldn't sync ${subtitlesOf(watched.name)}", + detail = syncFailureMessage(job.failure), + ) + else -> null + } +} + +/** + * Advances the feedback by one update: the progress, then the outcome, of a + * sync this viewer started; when it changed the subtitle on screen, the + * moment its corrected cues show; and a note when someone else's change to + * the subtitle on screen shows. Mirrors the web player's feedback. + */ +fun SubtitleSyncFeedbackState.step(input: SubtitleSyncFeedbackInput): SubtitleSyncFeedbackState { + val before = this.input + var next = copy(input = input) + fun show(notice: SubtitleSyncNotice?) { + if (next.notice != notice) next = next.copy(notice = notice) + } + fun loadedAfter(key: String, revision: Int) = key == input.activeKey && input.activeLoadedRevision > revision + + val watched = input.watched + if (watched != null) { + val job = watched.job + if (job.id !in next.startRevision) { + next = next.copy(startRevision = next.startRevision + (job.id to watched.revision)) + } + if (job.inProgress) { + show( + SubtitleSyncNotice( + id = job.id, + tone = SubtitleSyncNotice.Tone.Progress, + title = "Syncing ${subtitlesOf(watched.name)}", + detail = syncPhaseLabel(job), + percent = syncProgressPercent(job) ?: 0, + ), + ) + } else if (job.id !in next.announced) { + next = next.copy(announced = next.announced + job.id) + if (job.status == SubtitleSyncJob.SYNCED && watched.key == input.activeKey) { + next = next.copy( + applying = SubtitleSyncFeedbackState.Applying( + key = watched.key, + jobId = job.id, + detail = describeSyncTiming(watched.timing), + startRevision = next.startRevision[job.id] ?: watched.revision, + ), + ) + show(SubtitleSyncNotice(job.id, SubtitleSyncNotice.Tone.Progress, "Applying new timing…", percent = 100)) + } else { + show(outcomeNotice(watched)) + } + } + } + // A progress card whose job is no longer followed (another file, or its + // polling gave up) would otherwise stay up forever. + next.notice?.let { notice -> + if (notice.tone == SubtitleSyncNotice.Tone.Progress && + notice.id != next.applying?.jobId && + notice.id != watched?.job?.id + ) { + show(null) + } + } + + next.applying?.let { applying -> + if (applying.key != input.activeKey || loadedAfter(applying.key, applying.startRevision)) { + next = next.finishApplying() + } + } + + val activeKey = input.activeKey + if (activeKey != null && before?.activeKey == activeKey && input.activeRevision > before.activeRevision) { + val active = input.active + val job = active?.state?.sync + val own = active?.watchedJobId != null && job?.id == active.watchedJobId && + (job.inProgress || (job.status == SubtitleSyncJob.SYNCED && job.id !in next.applied)) + if (!own) next = next.copy(foreign = SubtitleSyncFeedbackState.Foreign(activeKey, before.activeRevision)) + } + next.foreign?.let { foreign -> + if (foreign.key != activeKey) { + next = next.copy(foreign = null) + } else if (loadedAfter(foreign.key, foreign.revision)) { + next = next.copy(foreign = null) + show( + SubtitleSyncNotice( + id = "timing:$activeKey:${input.activeRevision}", + tone = SubtitleSyncNotice.Tone.Info, + title = "Subtitle timing updated", + ), + ) + } + } + return next +} + +/** The most recent job this viewer started, among the file's subtitles, while it is still followed. */ +fun SubtitleSyncUiState.watchedEntry(): SubtitleSyncEntry? = + entries.values + .filter { entry -> + entry.watchedJobId != null && entry.state.sync?.id == entry.watchedJobId && + !(entry.inProgress && entry.pollExpired) + } + .maxByOrNull { it.state.sync?.createdAt.orEmpty() } + +/** Builds one feedback update from the sync state and what the player shows. */ +fun SubtitleSyncUiState.feedbackInput( + activeKey: String?, + cueRevisions: Map<String, Int>, + loadedCueRevisions: Map<String, Int>, + nameOf: (SubtitleSyncState) -> String?, +): SubtitleSyncFeedbackInput { + val watched = watchedEntry()?.let { entry -> + SubtitleSyncWatched( + key = entry.state.key, + name = nameOf(entry.state)?.takeIf(String::isNotBlank), + job = entry.state.sync ?: return@let null, + timing = entry.state.timing, + revision = cueRevisions[entry.state.key] ?: 0, + ) + } + return SubtitleSyncFeedbackInput( + watched = watched, + activeKey = activeKey, + activeRevision = activeKey?.let(cueRevisions::get) ?: 0, + activeLoadedRevision = activeKey?.let(loadedCueRevisions::get) ?: 0, + active = activeKey?.let(entries::get), + ) +} + +/** + * Turns subtitle sync state into one notice at a time for the viewer (see + * [step]). Finished notices leave on their own; "Applying new timing" gives + * way to its success after [applyTimeoutMs] even when the reload never + * reports. + */ +class SubtitleSyncFeedbackTracker( + private val scope: CoroutineScope, + private val visibleMs: Long = NOTICE_VISIBLE_MS, + private val warningVisibleMs: Long = WARNING_VISIBLE_MS, + private val applyTimeoutMs: Long = APPLY_TIMEOUT_MS, +) { + private var state = SubtitleSyncFeedbackState() + private val _notice = MutableStateFlow<SubtitleSyncNotice?>(null) + val notice: StateFlow<SubtitleSyncNotice?> = _notice.asStateFlow() + + private var applyTimer: Job? = null + private var hideTimer: Job? = null + + fun update(input: SubtitleSyncFeedbackInput) { + if (state.input == input) return + set(state.step(input)) + } + + fun dismiss() = set(state.copy(notice = null)) + + private fun set(next: SubtitleSyncFeedbackState) { + val previous = state + state = next + if (next.applying != previous.applying) { + applyTimer?.cancel() + val applying = next.applying + applyTimer = applying?.let { + scope.launch { + delay(applyTimeoutMs) + if (state.applying == applying) set(state.finishApplying()) + } + } + } + if (next.notice != previous.notice) { + hideTimer?.cancel() + val notice = next.notice + _notice.value = notice + hideTimer = notice?.takeIf { it.tone != SubtitleSyncNotice.Tone.Progress }?.let { + scope.launch { + delay(if (notice.tone == SubtitleSyncNotice.Tone.Warning) warningVisibleMs else visibleMs) + if (state.notice == notice) set(state.copy(notice = null)) + } + } + } + } + + companion object { + const val NOTICE_VISIBLE_MS = 6_000L + const val WARNING_VISIBLE_MS = 9_000L + const val APPLY_TIMEOUT_MS = 8_000L + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/AuthRepository.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/AuthRepository.kt index 5a1385d2eb..f503600444 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/AuthRepository.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/AuthRepository.kt @@ -135,9 +135,47 @@ class AuthRepository( authApi.login(LoginRequest(username, password), it.serverUrl) } - suspend fun loginForTokens(username: String, password: String, expected: AccountSessionExpectation? = null): ApiResult<LoginResponse> { + /** + * Signs in with another operation that answers `login`'s token pair, such + * as the network identity sign-in (prairie-server `docs/auth-api.md`, + * "External sign-in"). [exchange] runs against the active server's saved + * base URL it is given, and the session lands exactly like a password + * sign-in's. + */ + suspend fun signInWith(exchange: suspend (serverUrl: String) -> ApiResult<LoginResponse>): ApiResult<User> = + authenticate { exchange(it.serverUrl) } + + /** + * Signs in with the one-time code a native OAuth flow returned to the app + * (prairie-server `docs/auth-api.md`, "OAuth sign-in flows"). The flow must + * still belong to [serverId], the saved server the app started it for and + * the active one, or nothing is redeemed. [redeem] spends the code at the + * saved base URL it is given (the caller checks it is still the origin the + * flow started on), and the session lands on that server exactly like a + * password sign-in. The saved server keeps its address and id. + */ + suspend fun completeNativeOAuthLogin( + serverId: String, + redeem: suspend (serverUrl: String) -> ApiResult<LoginResponse>, + ): ApiResult<User> = authenticate { expected -> + if ((expected.serverId ?: serverRegistry?.activeServerId?.value) != serverId) staleSession() else redeem(expected.serverUrl) + } + + suspend fun loginForTokens(username: String, password: String, expected: AccountSessionExpectation? = null): ApiResult<LoginResponse> = + tokensFor(expected) { serverUrl -> authApi.login(LoginRequest(username, password), serverUrl) } + + /** + * [loginForTokens] for another operation that answers `login`'s token + * pair: the tokens, not yet saved, from [exchange] run against the saved + * base URL of the sign-in context [expected] (the current one when null), + * and only while that context still holds. + */ + suspend fun tokensFor( + expected: AccountSessionExpectation? = null, + exchange: suspend (serverUrl: String) -> ApiResult<LoginResponse>, + ): ApiResult<LoginResponse> { val captured = expected ?: tokenManager.captureAccountSessionExpectation() ?: return staleSession() - val result = authApi.login(LoginRequest(username, password), captured.serverUrl) + val result = exchange(captured.serverUrl) return if (captured.isSameSession(tokenManager.captureAccountSessionExpectation())) result else staleSession() } @@ -215,6 +253,10 @@ class AuthRepository( suspend fun getCurrentUser(): ApiResult<User> = authApi.getMe() + /** Fetches the account signed in on [scope]'s server. */ + suspend fun getCurrentUser(scope: org.prairieserver.prairie.network.AuthScopeSnapshot): ApiResult<User> = + authApi.getMe(scope) + /** * Logs out by clearing all persisted tokens and profile state for the * **currently active** server. The [ServerRegistry] entry is kept so the diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/CatalogRepository.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/CatalogRepository.kt index 0589a5688a..5698db8f32 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/CatalogRepository.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/CatalogRepository.kt @@ -1,5 +1,7 @@ package org.prairieserver.prairie.repository +import org.prairieserver.prairie.domain.settings.CatalogLanguageRevision +import org.prairieserver.prairie.repository.port.CatalogCacheScope import org.prairieserver.prairie.model.catalog.CatalogFiltersResponse import org.prairieserver.prairie.model.catalog.CatalogQueryGroup import org.prairieserver.prairie.model.catalog.CatalogResponse @@ -35,6 +37,7 @@ class CatalogRepository( private val catalogCache: CatalogCachePort = NoOpCatalogCachePort, private val identityTransitions: IdentityTransitionBarrier = DefaultIdentityTransitionBarrier(), requestDispatcher: CoroutineDispatcher = Dispatchers.Default, + private val catalogLanguage: CatalogLanguageRevision? = null, ) { /** * Home may warm a detail immediately before its destination requests the @@ -44,18 +47,38 @@ class CatalogRepository( */ private val detailRequestScope = CoroutineScope(SupervisorJob() + requestDispatcher) private val detailRequestMutex = Mutex() + private data class DetailRequestKey( + val identityGeneration: Long, + val languageRevision: Long, + val contentId: String, + ) private val itemDetailInFlight = - mutableMapOf<Pair<Long, String>, Deferred<ApiResult<ItemDetail>>>() + mutableMapOf<DetailRequestKey, Deferred<ApiResult<ItemDetail>>>() private val seasonsInFlight = - mutableMapOf<Pair<Long, String>, Deferred<ApiResult<SeasonsResponse>>>() + mutableMapOf<DetailRequestKey, Deferred<ApiResult<SeasonsResponse>>>() private data class EpisodesRequestKey( val identityGeneration: Long, + val languageRevision: Long, val seriesId: String, val seasonNumber: Int, ) private val episodesInFlight = mutableMapOf<EpisodesRequestKey, Deferred<ApiResult<EpisodesResponse>>>() + /** + * Whether a library holds any item at all, whatever the viewer's filters: + * one unfiltered item, no total. A screen asks only once its own view came + * back empty, to tell an empty library from filters that match nothing + * (#451). Read straight from the API so a one-item page never replaces the + * cached default page [browse] keeps for offline. Null when the server + * could not answer. + */ + suspend fun libraryHasItems(libraryId: Int, mediaType: String? = null): Boolean? = + when (val result = catalogApi.getCatalog(libraryId = libraryId, mediaType = mediaType, limit = 1, skipTotal = true)) { + is ApiResult.Success -> result.data.items.isNotEmpty() + else -> null + } + /** Browse the catalog with optional filters, sorting, and pagination. */ suspend fun browse( source: String? = null, @@ -159,13 +182,23 @@ class CatalogRepository( // Keeping them live prevents versions from leaking between browse contexts. /** Fetches full metadata for a single catalog item (offline: last cached detail). */ - suspend fun getItemDetail(contentId: String, libraryId: Int? = null): ApiResult<ItemDetail> { + /** + * Live item detail. By default it joins a Home warm-up already in flight + * for the same title; [joinWarmup] = false sends a fresh request instead, + * for a reload whose answer must postdate an access change. + */ + suspend fun getItemDetail( + contentId: String, + libraryId: Int? = null, + joinWarmup: Boolean = true, + ): ApiResult<ItemDetail> { if (libraryId != null) return catalogApi.getItemDetail(contentId, libraryId) val requestIdentityGeneration = identityTransitions.generation.value - val warmRequest = detailRequestMutex.withLock { - itemDetailInFlight[requestIdentityGeneration to contentId] + val requestLanguageRevision = currentLanguageRevision + val warmRequest = if (!joinWarmup) null else detailRequestMutex.withLock { + itemDetailInFlight[DetailRequestKey(requestIdentityGeneration, requestLanguageRevision, contentId)] } - return warmRequest?.await() ?: fetchItemDetail(contentId, requestIdentityGeneration) + return warmRequest?.await() ?: fetchItemDetail(contentId, requestIdentityGeneration, requestLanguageRevision) } /** @@ -174,17 +207,18 @@ class CatalogRepository( */ suspend fun warmItemDetail(contentId: String): ApiResult<ItemDetail> { val requestIdentityGeneration = identityTransitions.generation.value + val requestLanguageRevision = currentLanguageRevision return coalescedDetailRequest( requests = itemDetailInFlight, - key = requestIdentityGeneration to contentId, + key = DetailRequestKey(requestIdentityGeneration, requestLanguageRevision, contentId), ) { - fetchItemDetail(contentId, requestIdentityGeneration) + fetchItemDetail(contentId, requestIdentityGeneration, requestLanguageRevision) } } /** Returns the last cached item detail without touching the network. */ suspend fun getCachedItemDetail(contentId: String, libraryId: Int? = null): ItemDetail? = - if (libraryId != null) null else catalogCache.getCachedItemDetail(contentId) + if (libraryId != null) null else readLocalizedCache { catalogCache.getCachedItemDetail(contentId) } /** * Cache-first detail for speculative UI enrichment. Unlike a detail screen, @@ -192,58 +226,77 @@ class CatalogRepository( */ suspend fun getItemDetailForPrefetch(contentId: String, libraryId: Int? = null): ApiResult<ItemDetail> { if (libraryId != null) return catalogApi.getItemDetail(contentId, libraryId) - catalogCache.getCachedItemDetail(contentId)?.let { return ApiResult.Success(it) } + getCachedItemDetail(contentId)?.let { return ApiResult.Success(it) } return getItemDetail(contentId) } /** Fetches playback-oriented detail (versions, user progress, intro/credits markers). */ suspend fun captureWatchAuthority() = catalogApi.captureWatchAuthority() suspend fun isWatchAuthorityCurrent(owner: org.prairieserver.prairie.network.AuthScopeSnapshot) = catalogApi.isWatchAuthorityCurrent(owner) - suspend fun getWatchDetail(contentId: String, owner: org.prairieserver.prairie.network.AuthScopeSnapshot, libraryId: Int? = null) = catalogApi.getWatchDetail(contentId, owner, libraryId) + suspend fun getWatchDetail(contentId: String, owner: org.prairieserver.prairie.network.AuthScopeSnapshot, libraryId: Int? = null, fileId: Int? = null) = catalogApi.getWatchDetail(contentId, owner, libraryId, fileId) suspend fun getWatchDetail(contentId: String, libraryId: Int? = null): ApiResult<WatchDetail> = catalogApi.getWatchDetail(contentId, libraryId) - /** Lists seasons for a series (offline: last cached seasons). */ - suspend fun getSeasons(seriesId: String, libraryId: Int? = null): ApiResult<SeasonsResponse> { + /** + * Lists seasons for a series (offline: last cached seasons). + * + * [fresh] reads the server only: it neither joins a request already in + * flight nor falls back to the cache, both of which can still hold the + * state from before a write the caller just made. + */ + suspend fun getSeasons(seriesId: String, libraryId: Int? = null, fresh: Boolean = false): ApiResult<SeasonsResponse> { if (libraryId != null) return catalogApi.getSeasons(seriesId, libraryId) val requestIdentityGeneration = identityTransitions.generation.value + val requestLanguageRevision = currentLanguageRevision + if (fresh) return fetchSeasons(seriesId, requestIdentityGeneration, requestLanguageRevision, serveCacheOnFailure = false) val warmRequest = detailRequestMutex.withLock { - seasonsInFlight[requestIdentityGeneration to seriesId] + seasonsInFlight[DetailRequestKey(requestIdentityGeneration, requestLanguageRevision, seriesId)] } - return warmRequest?.await() ?: fetchSeasons(seriesId, requestIdentityGeneration) + return warmRequest?.await() ?: fetchSeasons(seriesId, requestIdentityGeneration, requestLanguageRevision) } /** Starts a process-owned live season-list warm-up for a pending detail route. */ suspend fun warmSeasons(seriesId: String): ApiResult<SeasonsResponse> { val requestIdentityGeneration = identityTransitions.generation.value + val requestLanguageRevision = currentLanguageRevision return coalescedDetailRequest( requests = seasonsInFlight, - key = requestIdentityGeneration to seriesId, + key = DetailRequestKey(requestIdentityGeneration, requestLanguageRevision, seriesId), ) { - fetchSeasons(seriesId, requestIdentityGeneration) + fetchSeasons(seriesId, requestIdentityGeneration, requestLanguageRevision) } } /** Returns the last cached season list without touching the network. */ suspend fun getCachedSeasons(seriesId: String, libraryId: Int? = null): SeasonsResponse? = - if (libraryId != null) null else catalogCache.getCachedSeasons(seriesId) + if (libraryId != null) null else readLocalizedCache { catalogCache.getCachedSeasons(seriesId) } /** Cache-first season list for speculative detail navigation. */ suspend fun getSeasonsForPrefetch(seriesId: String, libraryId: Int? = null): ApiResult<SeasonsResponse> { if (libraryId != null) return catalogApi.getSeasons(seriesId, libraryId) - catalogCache.getCachedSeasons(seriesId)?.let { return ApiResult.Success(it) } + getCachedSeasons(seriesId)?.let { return ApiResult.Success(it) } return getSeasons(seriesId) } - /** Lists episodes for a specific season of a series (offline: last cached episodes). */ - suspend fun getEpisodes(seriesId: String, seasonNumber: Int, libraryId: Int? = null): ApiResult<EpisodesResponse> { + /** + * Lists episodes for a specific season of a series (offline: last cached + * episodes). [fresh] behaves as on [getSeasons]. + */ + suspend fun getEpisodes( + seriesId: String, + seasonNumber: Int, + libraryId: Int? = null, + fresh: Boolean = false, + ): ApiResult<EpisodesResponse> { if (libraryId != null) return catalogApi.getEpisodes(seriesId, seasonNumber, libraryId) val requestIdentityGeneration = identityTransitions.generation.value - val requestKey = EpisodesRequestKey(requestIdentityGeneration, seriesId, seasonNumber) + val requestLanguageRevision = currentLanguageRevision + if (fresh) return fetchEpisodes(seriesId, seasonNumber, requestIdentityGeneration, requestLanguageRevision, serveCacheOnFailure = false) + val requestKey = EpisodesRequestKey(requestIdentityGeneration, requestLanguageRevision, seriesId, seasonNumber) val warmRequest = detailRequestMutex.withLock { episodesInFlight[requestKey] } return warmRequest?.await() - ?: fetchEpisodes(seriesId, seasonNumber, requestIdentityGeneration) + ?: fetchEpisodes(seriesId, seasonNumber, requestIdentityGeneration, requestLanguageRevision) } /** Starts a process-owned live episode-list warm-up for a pending detail route. */ @@ -252,17 +305,18 @@ class CatalogRepository( seasonNumber: Int, ): ApiResult<EpisodesResponse> { val requestIdentityGeneration = identityTransitions.generation.value + val requestLanguageRevision = currentLanguageRevision return coalescedDetailRequest( requests = episodesInFlight, - key = EpisodesRequestKey(requestIdentityGeneration, seriesId, seasonNumber), + key = EpisodesRequestKey(requestIdentityGeneration, requestLanguageRevision, seriesId, seasonNumber), ) { - fetchEpisodes(seriesId, seasonNumber, requestIdentityGeneration) + fetchEpisodes(seriesId, seasonNumber, requestIdentityGeneration, requestLanguageRevision) } } /** Returns one cached season's episodes without touching the network. */ suspend fun getCachedEpisodes(seriesId: String, seasonNumber: Int, libraryId: Int? = null): EpisodesResponse? = - if (libraryId != null) null else catalogCache.getCachedEpisodes(seriesId, seasonNumber) + if (libraryId != null) null else readLocalizedCache { catalogCache.getCachedEpisodes(seriesId, seasonNumber) } /** Cache-first episode list for speculative detail navigation. */ suspend fun getEpisodesForPrefetch( @@ -271,7 +325,7 @@ class CatalogRepository( libraryId: Int? = null, ): ApiResult<EpisodesResponse> { if (libraryId != null) return catalogApi.getEpisodes(seriesId, seasonNumber, libraryId) - catalogCache.getCachedEpisodes(seriesId, seasonNumber)?.let { + getCachedEpisodes(seriesId, seasonNumber)?.let { return ApiResult.Success(it) } return getEpisodes(seriesId, seasonNumber) @@ -321,16 +375,17 @@ class CatalogRepository( private suspend fun fetchItemDetail( contentId: String, requestIdentityGeneration: Long, + requestLanguageRevision: Long, ): ApiResult<ItemDetail> { val result = catalogApi.getItemDetail(contentId) if (result is ApiResult.Success) { - writeIfIdentityUnchanged(requestIdentityGeneration) { cacheWriteLease -> + writeLocalizedCache(requestIdentityGeneration, requestLanguageRevision) { cacheWriteLease -> catalogCache.cacheItemDetail(contentId, result.data, cacheWriteLease) } return result } if (result.canServeCache()) { - catalogCache.getCachedItemDetail(contentId)?.let { return ApiResult.Success(it) } + getCachedItemDetail(contentId)?.let { return ApiResult.Success(it) } } return result } @@ -338,16 +393,18 @@ class CatalogRepository( private suspend fun fetchSeasons( seriesId: String, requestIdentityGeneration: Long, + requestLanguageRevision: Long, + serveCacheOnFailure: Boolean = true, ): ApiResult<SeasonsResponse> { val result = catalogApi.getSeasons(seriesId) if (result is ApiResult.Success) { - writeIfIdentityUnchanged(requestIdentityGeneration) { cacheWriteLease -> + writeLocalizedCache(requestIdentityGeneration, requestLanguageRevision) { cacheWriteLease -> catalogCache.cacheSeasons(seriesId, result.data, cacheWriteLease) } return result } - if (result.canServeCache()) { - catalogCache.getCachedSeasons(seriesId)?.let { return ApiResult.Success(it) } + if (serveCacheOnFailure && result.canServeCache()) { + getCachedSeasons(seriesId)?.let { return ApiResult.Success(it) } } return result } @@ -356,10 +413,12 @@ class CatalogRepository( seriesId: String, seasonNumber: Int, requestIdentityGeneration: Long, + requestLanguageRevision: Long, + serveCacheOnFailure: Boolean = true, ): ApiResult<EpisodesResponse> { val result = catalogApi.getEpisodes(seriesId, seasonNumber) if (result is ApiResult.Success) { - writeIfIdentityUnchanged(requestIdentityGeneration) { cacheWriteLease -> + writeLocalizedCache(requestIdentityGeneration, requestLanguageRevision) { cacheWriteLease -> catalogCache.cacheEpisodes( seriesId, seasonNumber, @@ -369,14 +428,60 @@ class CatalogRepository( } return result } - if (result.canServeCache()) { - catalogCache.getCachedEpisodes(seriesId, seasonNumber)?.let { + if (serveCacheOnFailure && result.canServeCache()) { + getCachedEpisodes(seriesId, seasonNumber)?.let { return ApiResult.Success(it) } } return result } + private val localizedCacheMutex = Mutex() + private val invalidatedLanguageRevisions = mutableMapOf<CatalogCacheScope?, Long>() + private val currentLanguageRevision: Long + get() = catalogLanguage?.changes?.revision ?: 0L + + /** Purge durable descriptions before a new language can reuse them. */ + suspend fun invalidateLocalizedMetadata() { + localizedCacheMutex.withLock { synchronizeLocalizedCache() } + } + + // Called with localizedCacheMutex held, including across Room writes. An + // old write completes before the purge, or fails its language check after it. + private suspend fun synchronizeLocalizedCache(): Long { + val changes = catalogLanguage?.changes ?: return 0L + // Purge the writer's scope even after a profile/server switch. Track + // each owner independently, including changes before lazy construction. + for ((scope, revision) in changes.scopes) { + if (revision > (invalidatedLanguageRevisions[scope] ?: 0L)) { + catalogCache.invalidateDetailMetadata(scope) + invalidatedLanguageRevisions[scope] = revision + } + } + return changes.revision + } + + private suspend fun <T> readLocalizedCache(read: suspend () -> T?): T? = + localizedCacheMutex.withLock { + val revision = synchronizeLocalizedCache() + read().takeIf { revision == currentLanguageRevision } + } + + private suspend fun writeLocalizedCache( + requestIdentityGeneration: Long, + requestLanguageRevision: Long, + write: suspend (CatalogCacheWriteLease) -> Unit, + ) { + localizedCacheMutex.withLock { + val revision = synchronizeLocalizedCache() + if (requestLanguageRevision == revision && + requestIdentityGeneration == identityTransitions.generation.value + ) { + write(CatalogCacheWriteLease(requestIdentityGeneration)) + } + } + } + private suspend fun writeIfIdentityUnchanged( requestGeneration: Long, write: suspend (CatalogCacheWriteLease) -> Unit, diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/DeviceLoginRepository.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/DeviceLoginRepository.kt index 87754a0eed..55a049ad4d 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/DeviceLoginRepository.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/DeviceLoginRepository.kt @@ -1,48 +1,247 @@ package org.prairieserver.prairie.repository +import org.prairieserver.prairie.model.auth.DeviceLoginCapabilityResponse import org.prairieserver.prairie.model.auth.DeviceLoginPollResponse import org.prairieserver.prairie.model.auth.DeviceLoginStartResponse import org.prairieserver.prairie.model.auth.DeviceLoginStatus import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.AuthScopeSnapshot import org.prairieserver.prairie.network.api.DeviceLoginApi -import kotlinx.coroutines.CancellationException +import org.prairieserver.prairie.network.apiv2.ApiV2Gate +import org.prairieserver.prairie.util.parseRfc3339ToEpochMillis import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlin.time.TimeSource + +/** Monotonic milliseconds, injectable so tests can drive deadlines with virtual time. */ +fun interface DeviceLoginClock { + fun nowMs(): Long + + companion object { + private val origin = TimeSource.Monotonic.markNow() + val System = DeviceLoginClock { origin.elapsedNow().inWholeMilliseconds } + } +} /** - * Owns the OAuth device-login state machine: initiate → poll loop → - * terminal (Approved / Failed). Exposes a single [state] StateFlow the - * UI can observe. + * How a failed device-login call should be handled. Shared by the pairing + * receiver's poll loop and the TV sign-in screen so both treat the same + * server answer the same way. + */ +enum class DeviceLoginErrorKind { + /** Network failure, timeout or 5xx: the request may still be live; back off and retry. */ + Transient, + + /** 429: back off and retry; tell the person if it persists. */ + RateLimited, + + /** 404 (or 501): the server has no such request (poll) or no device sign-in (start). */ + NotFound, + + /** 410, 426, or the v2 gate: the server or this app must be updated first. */ + UpdateRequired, + + /** Any other 4xx: retrying the same call won't help. */ + Rejected; + + companion object { + fun of(result: ApiResult<*>): DeviceLoginErrorKind = when (result) { + is ApiResult.NetworkError -> Transient + is ApiResult.Error -> when { + result.code == 0 && result.error == ApiV2Gate.UPDATE_REQUIRED_ERROR -> UpdateRequired + result.code == 429 -> RateLimited + result.code == 404 || result.code == 501 -> NotFound + result.code == 410 || result.code == 426 -> UpdateRequired + result.code == 408 || result.code >= 500 -> Transient + // code 0 without the gate marker: the response could not be read. + result.code == 0 -> Transient + else -> Rejected + } + is ApiResult.Success -> error("not a failure") + } + } +} + +/** Timing rules for device-login polling. */ +object DeviceLoginTiming { + /** Backoff ceiling for network errors, 5xx and 429. */ + const val MAX_BACKOFF_MS = 30_000L + + /** Next backoff after a failure: doubles from the poll interval up to [MAX_BACKOFF_MS]. */ + fun nextBackoffMs(current: Long, intervalMs: Long): Long = + if (current <= 0L) intervalMs.coerceIn(1_000L, MAX_BACKOFF_MS) + else (current * 2).coerceAtMost(MAX_BACKOFF_MS) +} + +/** + * One live device code's polling, shared by [DeviceSignInMachine] and + * [DeviceLoginRepository]'s pairing loop so both read a server answer the + * same way. [poll] makes one call and says what it means; the caller decides + * what the screen shows and whether to wait, renew or stop. * - * Mirrors Apple's [QRLoginViewModel.swift] polling behavior: - * - polls immediately after start (don't wait the first interval) - * - swallows transient network errors and keeps polling - * - 404 on poll = server cleaned up the row → terminal Failed(Expired) - * - honors server-requested poll-after override on Pending responses + * Deadline: starts at `expires_in` from the start answer. A pending answer + * carries the request's current `expires_at`, which an approver's lookup + * extends; the deadline moves to it (never earlier), converted from server + * time to this device's clock through the start answer's own + * `expires_at`/`expires_in` pair, so clock skew between the two cancels out. + * Waits are clamped to the deadline, so the last call before a caller gives + * up on a code is always a poll. + */ +class DeviceLoginPoller( + private val pollOnce: suspend (deviceCode: String) -> DeviceLoginRepository.PollOutcome, + val session: DeviceLoginStartResponse, + private val clock: DeviceLoginClock, +) { + /** What one poll answer means. */ + sealed interface Step { + /** Still pending: wait [waitMs] (already clamped to the deadline) before the next poll. */ + data class Pending(val waitMs: Long) : Step + + /** Approved with tokens; they are in [response] and nowhere else. */ + data class Approved(val response: DeviceLoginPollResponse) : Step + + /** Approved, but the answer carried no tokens. */ + data object MissingTokens : Step + + data object Denied : Step + + /** + * The server has no live request for this code any more: [status] is + * `expired`, `consumed` or `canceled`, or null for a 404 or another + * 4xx that retrying won't change. + */ + data class Gone(val status: DeviceLoginStatus?) : Step + + /** 410, 426 or the v2 gate: the server or this app must be updated. */ + data object UpdateRequired : Step + + /** A status this build doesn't know. */ + data class Unknown(val status: String) : Step + + /** Network error, 5xx or 429: the request may still be live. Wait [waitMs] (clamped to the deadline) and poll again. */ + data class Retry(val kind: DeviceLoginErrorKind, val waitMs: Long) : Step + } + + private val startedAt = clock.nowMs() + + /** Local-clock ms minus server-clock ms, or null when the start answer's `expires_at` can't be read. */ + private val serverToLocalMs: Long? = parseRfc3339ToEpochMillis(session.expiresAt) + ?.let { serverExpiry -> startedAt + session.expiresIn.coerceAtLeast(1) * 1_000L - serverExpiry } + + /** Local time after which the code is treated as expired. */ + var deadline: Long = startedAt + session.expiresIn.coerceAtLeast(1) * 1_000L + private set + + /** True once an approver has looked the request up (the server's opened signal). */ + var opened: Boolean = false + private set + + private var intervalMs = session.interval.coerceAtLeast(1) * 1_000L + private var backoffMs = 0L + + /** Whether the deadline has passed. */ + val isExpired: Boolean + get() = clock.nowMs() >= deadline + + suspend fun poll(): Step = when (val outcome = pollOnce(session.deviceCode)) { + is DeviceLoginRepository.PollOutcome.Answered -> { + backoffMs = 0L + answered(outcome.response) + } + is DeviceLoginRepository.PollOutcome.Failed -> when (outcome.kind) { + DeviceLoginErrorKind.NotFound, DeviceLoginErrorKind.Rejected -> Step.Gone(null) + DeviceLoginErrorKind.UpdateRequired -> Step.UpdateRequired + DeviceLoginErrorKind.Transient, DeviceLoginErrorKind.RateLimited -> { + backoffMs = DeviceLoginTiming.nextBackoffMs(backoffMs, intervalMs) + Step.Retry(outcome.kind, clampToDeadline(backoffMs)) + } + } + } + + private fun answered(response: DeviceLoginPollResponse): Step = when (val status = DeviceLoginStatus.fromWire(response.status)) { + DeviceLoginStatus.Pending -> { + if (response.opened) opened = true + val serverExpiry = response.expiresAt?.let(::parseRfc3339ToEpochMillis) + if (serverExpiry != null && serverToLocalMs != null) { + deadline = maxOf(deadline, serverExpiry + serverToLocalMs) + } + response.pollAfter?.let { intervalMs = it.coerceAtLeast(1) * 1_000L } + Step.Pending(clampToDeadline(intervalMs)) + } + DeviceLoginStatus.Approved -> + if (response.accessToken.isNullOrBlank() || response.refreshToken.isNullOrBlank()) { + Step.MissingTokens + } else { + Step.Approved(response) + } + DeviceLoginStatus.Denied -> Step.Denied + DeviceLoginStatus.Expired, + DeviceLoginStatus.Consumed, + DeviceLoginStatus.Canceled, + -> Step.Gone(status) + DeviceLoginStatus.Unknown -> Step.Unknown(response.status) + } + + private fun clampToDeadline(waitMs: Long): Long = + waitMs.coerceAtMost((deadline - clock.nowMs()).coerceAtLeast(0L)) +} + +/** + * Device-login operations and the single-attempt state machine used by LAN + * companion pairing: start → poll → terminal (Approved / Failed). The TV + * sign-in screen drives its own renewing session through [DeviceSignInMachine] + * on top of the same operations. + * + * Both read each poll answer through [DeviceLoginPoller]: + * - polls immediately after start, then honors `interval` / `poll_after` + * - network errors, 5xx and 429 back off exponentially up to 30 s + * - a local deadline from `expires_in`, moved to a pending answer's + * `expires_at`, ends the attempt even while the server can't be reached; + * the last call before it ends is a poll + * - 404 (or another 4xx) on poll = the server has no such request → Failed(Expired) */ class DeviceLoginRepository( private val api: DeviceLoginApi, + private val clock: DeviceLoginClock = DeviceLoginClock.System, ) { sealed class DeviceLoginState { object Idle : DeviceLoginState() object Initiating : DeviceLoginState() + data class Awaiting(val session: DeviceLoginStartResponse) : DeviceLoginState() + data class Approved(val response: DeviceLoginPollResponse) : DeviceLoginState() data class Failed(val reason: FailureReason, val message: String? = null) : DeviceLoginState() } enum class FailureReason { - StartFailed, // initiate POST returned non-2xx - Expired, // polled row gone (404) or status=expired + StartFailed, // start answered with an error that retrying won't fix + Unreachable, // start could not reach the server + RateLimited, // start answered 429 + Unsupported, // start answered 404: no device sign-in on this server + UpdateRequired, // 410 / v1-only / app too old + Expired, // polled row gone (404), status=expired, or the local deadline passed Denied, // status=denied + Canceled, // status=canceled Consumed, // status=consumed (already used) MissingTokens, // status=approved but no access_token returned UnknownStatus, // server returned a status we don't recognize } + /** Result of one start call. */ + sealed interface StartOutcome { + data class Started(val session: DeviceLoginStartResponse) : StartOutcome + data class Failed(val kind: DeviceLoginErrorKind) : StartOutcome + } + + /** Result of one poll call. */ + sealed interface PollOutcome { + data class Answered(val response: DeviceLoginPollResponse) : PollOutcome + data class Failed(val kind: DeviceLoginErrorKind) : PollOutcome + } + private val _state = MutableStateFlow<DeviceLoginState>(DeviceLoginState.Idle) val state: StateFlow<DeviceLoginState> = _state.asStateFlow() @@ -64,24 +263,17 @@ class DeviceLoginRepository( suspend fun beginAt(serverUrl: String?, deviceName: String?, devicePlatform: String?) { _state.value = DeviceLoginState.Initiating - val startResult = if (serverUrl == null) { - api.startDeviceLogin(deviceName, devicePlatform) - } else { - api.startDeviceLoginAt(serverUrl, deviceName, devicePlatform) - } - val session = when (val r = startResult) { - is ApiResult.Success -> r.data - is ApiResult.Error -> { - _state.value = DeviceLoginState.Failed( - FailureReason.StartFailed, - "Server returned ${r.code}: ${r.message}", - ) - return - } - is ApiResult.NetworkError -> { + val session = when (val outcome = start(serverUrl, deviceName, devicePlatform)) { + is StartOutcome.Started -> outcome.session + is StartOutcome.Failed -> { _state.value = DeviceLoginState.Failed( - FailureReason.StartFailed, - r.exception.message, + when (outcome.kind) { + DeviceLoginErrorKind.Transient -> FailureReason.Unreachable + DeviceLoginErrorKind.RateLimited -> FailureReason.RateLimited + DeviceLoginErrorKind.NotFound -> FailureReason.Unsupported + DeviceLoginErrorKind.UpdateRequired -> FailureReason.UpdateRequired + DeviceLoginErrorKind.Rejected -> FailureReason.StartFailed + }, ) return } @@ -95,6 +287,43 @@ class DeviceLoginRepository( _state.value = DeviceLoginState.Idle } + /** One start call against [serverUrl], or the active server when null. */ + suspend fun start(serverUrl: String?, deviceName: String?, devicePlatform: String?): StartOutcome { + val result = if (serverUrl == null) { + api.startDeviceLogin(deviceName, devicePlatform) + } else { + api.startDeviceLoginAt(serverUrl, deviceName, devicePlatform) + } + return when (result) { + is ApiResult.Success -> StartOutcome.Started(result.data) + else -> StartOutcome.Failed(DeviceLoginErrorKind.of(result)) + } + } + + /** One poll call against [serverUrl], or the active server when null. */ + suspend fun poll(serverUrl: String?, deviceCode: String): PollOutcome { + val result = if (serverUrl == null) { + api.pollDeviceLogin(deviceCode) + } else { + api.pollDeviceLoginAt(serverUrl, deviceCode) + } + return when (result) { + is ApiResult.Success -> PollOutcome.Answered(result.data) + else -> PollOutcome.Failed(DeviceLoginErrorKind.of(result)) + } + } + + /** The capability document at [serverUrl], or null when it can't be read. */ + suspend fun capability(serverUrl: String): DeviceLoginCapabilityResponse? = + (api.deviceLoginCapabilityAt(serverUrl) as? ApiResult.Success)?.data + + /** + * Withdraw [deviceCode] on the server so an abandoned code can't be + * approved later. Best effort: a failure leaves the request to expire. + */ + suspend fun cancel(serverUrl: String, deviceCode: String): Boolean = + api.cancelDeviceLoginAt(serverUrl, deviceCode) is ApiResult.Success + suspend fun lookup(token: String?, code: String?) = api.lookupDeviceLogin(token = token, code = code) @@ -114,88 +343,51 @@ class DeviceLoginRepository( api.denyDeviceLoginForScope(scope, code) private suspend fun runPollLoop(session: DeviceLoginStartResponse, serverUrl: String?) { - var intervalMs = session.interval.coerceAtLeast(1) * 1_000L - + val poller = DeviceLoginPoller(pollOnce = { poll(serverUrl, it) }, session = session, clock = clock) while (true) { - try { - val pollResult = if (serverUrl == null) { - api.pollDeviceLogin(session.deviceCode) - } else { - api.pollDeviceLoginAt(serverUrl, session.deviceCode) + when (val step = poller.poll()) { + is DeviceLoginPoller.Step.Pending -> { + if (poller.isExpired) break + delay(step.waitMs) } - val response = when (val r = pollResult) { - is ApiResult.Success -> r.data - is ApiResult.Error -> { - if (r.code == 404) { - _state.value = DeviceLoginState.Failed( - FailureReason.Expired, - "This sign-in request has expired.", - ) - return - } - // Transient (5xx, proxy hiccup): the pending authorization is - // still valid on the server, so keep polling. A consumed code - // answers 404 above, which already ends the flow. - delay(intervalMs) - continue - } - is ApiResult.NetworkError -> { - delay(intervalMs) - continue - } + is DeviceLoginPoller.Step.Retry -> { + if (poller.isExpired) break + delay(step.waitMs) } - - // Honor server's poll-after override if present. - response.pollAfter?.let { intervalMs = it.coerceAtLeast(1) * 1_000L } - - when (DeviceLoginStatus.fromWire(response.status)) { - DeviceLoginStatus.Pending -> { - delay(intervalMs) - } - DeviceLoginStatus.Approved -> { - if (response.accessToken.isNullOrBlank() || - response.refreshToken.isNullOrBlank()) { - _state.value = DeviceLoginState.Failed( - FailureReason.MissingTokens, - "Server approved the session but did not return tokens.", - ) - return - } - _state.value = DeviceLoginState.Approved(response) - return - } - DeviceLoginStatus.Denied -> { - _state.value = DeviceLoginState.Failed( - FailureReason.Denied, - "Sign-in was denied on the other device.", - ) - return - } - DeviceLoginStatus.Expired -> { - _state.value = DeviceLoginState.Failed( - FailureReason.Expired, - "This code expired before it was approved.", - ) - return - } - DeviceLoginStatus.Consumed -> { - _state.value = DeviceLoginState.Failed( - FailureReason.Consumed, - "This code has already been used.", - ) - return - } - DeviceLoginStatus.Unknown -> { - _state.value = DeviceLoginState.Failed( - FailureReason.UnknownStatus, - "Unexpected status: ${response.status}", - ) - return + is DeviceLoginPoller.Step.Approved -> { + _state.value = DeviceLoginState.Approved(step.response) + return + } + DeviceLoginPoller.Step.MissingTokens -> { + fail(FailureReason.MissingTokens, "Server approved the session but did not return tokens.") + return + } + DeviceLoginPoller.Step.Denied -> { + fail(FailureReason.Denied, "Sign-in was declined on the other device.") + return + } + is DeviceLoginPoller.Step.Gone -> { + when (step.status) { + DeviceLoginStatus.Canceled -> fail(FailureReason.Canceled, "This sign-in request was canceled.") + DeviceLoginStatus.Consumed -> fail(FailureReason.Consumed, "This code has already been used.") + else -> fail(FailureReason.Expired, "This code expired before it was approved.") } + return + } + DeviceLoginPoller.Step.UpdateRequired -> { + fail(FailureReason.UpdateRequired) + return + } + is DeviceLoginPoller.Step.Unknown -> { + fail(FailureReason.UnknownStatus, "Unexpected status: ${step.status}") + return } - } catch (e: CancellationException) { - throw e } } + fail(FailureReason.Expired, "This code expired before it was approved.") + } + + private fun fail(reason: FailureReason, message: String? = null) { + _state.value = DeviceLoginState.Failed(reason, message) } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/DeviceSignInMachine.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/DeviceSignInMachine.kt new file mode 100644 index 0000000000..61ae0827fa --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/DeviceSignInMachine.kt @@ -0,0 +1,399 @@ +package org.prairieserver.prairie.repository + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import org.prairieserver.prairie.model.auth.DeviceLoginCapabilityResponse +import org.prairieserver.prairie.model.auth.DeviceLoginPollResponse +import org.prairieserver.prairie.model.auth.DeviceLoginStartResponse + +/** What the TV sign-in screen shows for its device code. See [DeviceSignInMachine]. */ +sealed interface DeviceSignInState { + /** Asking the server for a code. */ + data object GettingCode : DeviceSignInState + + /** + * A live code. [opened] is true once an approver has looked it up (the + * server's opened signal); [renewed] is true when this code replaced an + * expired one without the person asking. + */ + data class ShowingCode( + val session: DeviceLoginStartResponse, + val opened: Boolean = false, + val renewed: Boolean = false, + ) : DeviceSignInState + + /** The approver signed this TV in; the tokens are in [response] and nowhere else. */ + data class Approved(val response: DeviceLoginPollResponse) : DeviceSignInState + + /** The approver declined. */ + data object Denied : DeviceSignInState + + /** Codes renewed for about an hour with no approval; waiting for the person. */ + data object Paused : DeviceSignInState + + /** + * Calls keep failing with network errors or 5xx. Still retrying with + * backoff. [session] is the last code, when it is still live. + */ + data class Unreachable(val session: DeviceLoginStartResponse?) : DeviceSignInState + + /** The server has answered 429 for over a minute. Still retrying. */ + data class TooManyRequests(val session: DeviceLoginStartResponse?) : DeviceSignInState + + /** The server doesn't offer device sign-in; only a password works. */ + data object NoDeviceSignIn : DeviceSignInState + + /** The server or this app must be updated first. */ + data object UpdateRequired : DeviceSignInState + + /** The server answered in a way retrying won't fix (missing tokens, unknown status, 4xx on start). */ + data object Failed : DeviceSignInState +} + +/** Timing for [DeviceSignInMachine]; shortened in tests. */ +data class DeviceSignInConfig( + /** Stop renewing codes after this long without the person doing anything. */ + val pauseAfterMs: Long = 60 * 60_000L, + /** Show "can't reach" when getting a code has failed for this long. */ + val startUnreachableAfterMs: Long = 10_000L, + /** Show "can't reach" when polling has failed for this long. */ + val pollUnreachableAfterMs: Long = 30_000L, + /** Show "too many requests" when 429s have persisted this long. */ + val rateLimitedAfterMs: Long = 60_000L, + /** Bound on the best-effort cancel call when the code is abandoned. */ + val cancelTimeoutMs: Long = 5_000L, +) + +/** + * The TV sign-in screen's device code: gets a code, polls it, renews it in + * place when it expires while the screen is visible, pauses after about an + * hour of renewals, and names the failure when the server can't be reached or + * keeps answering 429. Prairie-Server/prairie-server TV sign-in spec, states table. + * Each poll answer is read through [DeviceLoginPoller], the same step the + * pairing receiver's loop uses. + * + * [run] drives the machine until it reaches a state that waits for the + * person ([DeviceSignInState.Approved], Denied, Paused, NoDeviceSignIn, + * UpdateRequired, Failed), or until [requestStop]. Stopping keeps the current + * code, so the screen can stop polling when the app goes to the background and + * call [run] again when it returns: the code is polled at once and replaced + * only if the server no longer holds it. + * + * A code is replaced only after a poll: when the server says it is gone, or + * when a poll finds the local deadline passed. A code replaced while the + * server still holds it is withdrawn there first (when the server supports + * cancel), so it can't be approved after the TV moved on. + * + * Each [run] is numbered. A new [run], [restart] or [abandon] supersedes the + * run before it: when a call returns to a superseded run, the run drops the + * answer and returns without publishing, replacing or starting anything, so + * a run left blocked in a slow call can't bring back a withdrawn code or drop + * the newer run's code. + * + * Not thread-safe: call everything from one coroutine context. + */ +class DeviceSignInMachine( + private val repository: DeviceLoginRepository, + val serverUrl: String, + private val deviceName: String?, + private val devicePlatform: String?, + private val clock: DeviceLoginClock = DeviceLoginClock.System, + private val config: DeviceSignInConfig = DeviceSignInConfig(), +) { + private val _state = MutableStateFlow<DeviceSignInState>(DeviceSignInState.GettingCode) + val state: StateFlow<DeviceSignInState> = _state.asStateFlow() + + private var capability: DeviceLoginCapabilityResponse? = null + private var poller: DeviceLoginPoller? = null + private var renewed = false + + /** + * [Runs.current] numbers the run that owns the machine; [Runs.stopped] is + * the run [requestStop] asked to return at its next wait or before its + * next call. + */ + private data class Runs(val current: Long = 0L, val stopped: Long = -1L) + + private val runs = MutableStateFlow(Runs()) + + /** Start of the current run of automatic renewals; reset by [restart] and [noteActivity]. */ + private var renewalBudgetStart: Long? = null + + /** Whether the machine is waiting for the person rather than working. */ + val isSettled: Boolean + get() = when (_state.value) { + is DeviceSignInState.Approved, + DeviceSignInState.Denied, + DeviceSignInState.Paused, + DeviceSignInState.NoDeviceSignIn, + DeviceSignInState.UpdateRequired, + DeviceSignInState.Failed, + -> true + else -> false + } + + /** The person is here (screen came back to the foreground): renewals start their hour again. */ + fun noteActivity() { + renewalBudgetStart = clock.nowMs() + } + + /** + * Ask [run] to return. A wait between calls ends at once; a call already + * in flight finishes first and its answer is handled, so a poll the server + * already answered with the one-time tokens is never dropped. + */ + fun requestStop() { + runs.update { it.copy(stopped = it.current) } + } + + /** + * Forget the current code and start from "Getting a sign-in code". Call + * [abandon] first when the old code may still be live on the server. + */ + fun restart() { + supersede() + poller = null + renewed = false + renewalBudgetStart = null + _state.value = DeviceSignInState.GettingCode + } + + /** + * Withdraw the current code on the server (when it supports cancel) so it + * can't be approved after the TV moved on. The call runs in [scope], which + * must outlive the screen; the code is forgotten immediately and any run + * still in a call is superseded. + */ + fun abandon(scope: CoroutineScope) { + supersede() + val abandoned = poller?.session ?: return + poller = null + if (!mayWithdraw) return + scope.launch { withdraw(abandoned) } + } + + suspend fun run() { + if (isSettled) return + val mine = runs.updateAndGet { it.copy(current = it.current + 1) }.current + fun superseded() = runs.value.current != mine + fun stopped() = runs.value.let { it.current != mine || it.stopped == mine } + val budgetStart = renewalBudgetStart ?: clock.nowMs().also { renewalBudgetStart = it } + + if (capability == null) capability = repository.capability(serverUrl) + if (superseded()) return + if (capability?.deviceLoginAvailable == false) { + publish(DeviceSignInState.NoDeviceSignIn) + return + } + + var failingSince: Long? = null + var rateLimitedSince: Long? = null + var startBackoffMs = 0L + + fun noteFailure(kind: DeviceLoginErrorKind, starting: Boolean) { + val now = clock.nowMs() + if (kind == DeviceLoginErrorKind.RateLimited) { + val since = rateLimitedSince ?: now.also { rateLimitedSince = it } + if (now - since >= config.rateLimitedAfterMs) { + publish(DeviceSignInState.TooManyRequests(liveSession())) + } + } else { + val since = failingSince ?: now.also { failingSince = it } + val threshold = if (starting) config.startUnreachableAfterMs else config.pollUnreachableAfterMs + if (now - since >= threshold) { + publish(DeviceSignInState.Unreachable(liveSession())) + } + } + } + + fun recovered() { + failingSince = null + rateLimitedSince = null + startBackoffMs = 0L + } + + while (true) { + // A cancelled run (the screen left, or a password sign-in won) must + // not reach the server again. A wait that times out as it is + // cancelled can return normally, and the next steps may not + // suspend before they ask for a new code. + currentCoroutineContext().ensureActive() + if (stopped()) return + val live = poller + if (live == null) { + // About an hour without the person doing anything: stop asking + // the server for codes nobody is looking at. + if (clock.nowMs() - budgetStart >= config.pauseAfterMs) { + publish(DeviceSignInState.Paused) + return + } + if (_state.value !is DeviceSignInState.Unreachable && + _state.value !is DeviceSignInState.TooManyRequests && + _state.value !is DeviceSignInState.ShowingCode + ) { + publish(DeviceSignInState.GettingCode) + } + val outcome = repository.start(serverUrl, deviceName, devicePlatform) + if (superseded()) { + // Nobody will show or poll this code: withdraw it. + if (outcome is DeviceLoginRepository.StartOutcome.Started && mayWithdraw) { + withdraw(outcome.session) + } + return + } + when (outcome) { + is DeviceLoginRepository.StartOutcome.Started -> { + recovered() + val started = DeviceLoginPoller( + pollOnce = { repository.poll(serverUrl, it) }, + session = outcome.session, + clock = clock, + ) + poller = started + publish(DeviceSignInState.ShowingCode(started.session, opened = false, renewed = renewed)) + // Poll at once: fall through. + } + is DeviceLoginRepository.StartOutcome.Failed -> when (outcome.kind) { + DeviceLoginErrorKind.NotFound -> { + publish(DeviceSignInState.NoDeviceSignIn) + return + } + DeviceLoginErrorKind.UpdateRequired -> { + publish(DeviceSignInState.UpdateRequired) + return + } + DeviceLoginErrorKind.Rejected -> { + publish(DeviceSignInState.Failed) + return + } + DeviceLoginErrorKind.Transient, DeviceLoginErrorKind.RateLimited -> { + noteFailure(outcome.kind, starting = true) + startBackoffMs = DeviceLoginTiming.nextBackoffMs(startBackoffMs, 1_000L) + if (!pause(startBackoffMs, mine)) return + continue + } + } + } + if (stopped()) return + } + + val current = poller ?: continue + val step = current.poll() + // Restarted or abandoned during the call: this answer is for a + // code the screen no longer shows. + if (superseded()) return + when (step) { + is DeviceLoginPoller.Step.Pending -> { + recovered() + publish(DeviceSignInState.ShowingCode(current.session, opened = current.opened, renewed = renewed)) + if (current.isExpired) { + replace(current, stillLive = true, mine) + } else if (!pause(step.waitMs, mine)) { + return + } + } + is DeviceLoginPoller.Step.Approved -> { + poller = null + publish(DeviceSignInState.Approved(step.response)) + return + } + DeviceLoginPoller.Step.MissingTokens, is DeviceLoginPoller.Step.Unknown -> { + poller = null + publish(DeviceSignInState.Failed) + return + } + DeviceLoginPoller.Step.Denied -> { + poller = null + publish(DeviceSignInState.Denied) + return + } + // Gone on the server: replace it in place. + is DeviceLoginPoller.Step.Gone -> replace(current, stillLive = false, mine) + DeviceLoginPoller.Step.UpdateRequired -> { + poller = null + publish(DeviceSignInState.UpdateRequired) + return + } + is DeviceLoginPoller.Step.Retry -> { + noteFailure(step.kind, starting = false) + // Past the local deadline without an answer: the server may + // still hold the code (an approver can extend it, and a 429 + // or 5xx doesn't prove it gone), so withdraw it, bounded, + // before asking for a new one. + if (current.isExpired) { + replace(current, stillLive = true, mine) + } else if (!pause(step.waitMs, mine)) { + return + } + } + } + } + } + + /** + * Drop [current] for a new code. [stillLive]: the server still holds it + * (the local deadline passed first), so withdraw it before moving on. + * + * [current] stays the machine's code until it has been withdrawn, so an + * [abandon] that lands meanwhile still finds and withdraws it; the + * withdraw itself can't be cut short by cancelling the run. + */ + private suspend fun replace(current: DeviceLoginPoller, stillLive: Boolean, mine: Long) { + // A capability read that failed earlier gets another chance, so a + // server that does support cancel gets its codes withdrawn. + if (capability == null) capability = repository.capability(serverUrl) + // Restarted or abandoned meanwhile: abandon took the code. + if (runs.value.current != mine) return + if (stillLive && mayWithdraw) withdraw(current.session) + if (runs.value.current != mine) return + poller = null + renewed = true + } + + /** + * Whether a code may be withdrawn on the server: every withdraw decision + * uses this one rule. Unknown support (the capability read failed) still + * gets the cancel: a server without it answers 404, which is harmless. + */ + private val mayWithdraw: Boolean + get() = capability?.cancel != false + + /** Best-effort, bounded cancel of [session] that survives cancelling the caller. */ + private suspend fun withdraw(session: DeviceLoginStartResponse) { + withContext(NonCancellable) { + withTimeoutOrNull(config.cancelTimeoutMs) { + repository.cancel(serverUrl, session.deviceCode) + } + } + } + + /** Retire the current run: it returns at its next check without publishing. */ + private fun supersede() { + runs.update { it.copy(current = it.current + 1) } + } + + /** Wait [ms]; false when [requestStop], [restart] or [abandon] ended the wait for run [mine]. */ + private suspend fun pause(ms: Long, mine: Long): Boolean { + fun Runs.ends() = current != mine || stopped == mine + if (ms <= 0L) return !runs.value.ends() + return withTimeoutOrNull(ms) { runs.first { it.ends() } } == null + } + + private fun liveSession(): DeviceLoginStartResponse? = + poller?.takeIf { !it.isExpired }?.session + + private fun publish(state: DeviceSignInState) { + _state.value = state + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/ExternalSignInRepository.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/ExternalSignInRepository.kt new file mode 100644 index 0000000000..83c8604e5a --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/ExternalSignInRepository.kt @@ -0,0 +1,90 @@ +package org.prairieserver.prairie.repository + +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import org.prairieserver.prairie.model.auth.AccountIdentities +import org.prairieserver.prairie.model.auth.AccountIdentity +import org.prairieserver.prairie.model.auth.AccountIdentityLinkTicket +import org.prairieserver.prairie.model.auth.ExternalSignInCapabilities +import org.prairieserver.prairie.model.auth.LoginResponse +import org.prairieserver.prairie.model.auth.OAuthHandshakeCapabilities +import org.prairieserver.prairie.model.auth.SignInOptions +import org.prairieserver.prairie.model.auth.SignInProviders +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.api.ExternalSignInApi + +/** + * External sign-in (OIDC, LDAP, network identity) for the apps: what a + * sign-in screen offers, and the account's Sign-in section. The native OAuth + * handoff itself (browser, PKCE, the app redirect) belongs to the phone app; + * TVs never run it. The network identity sign-in needs neither and runs on both. + */ +class ExternalSignInRepository( + private val api: ExternalSignInApi, +) { + /** + * What to offer on [serverUrl]'s sign-in screen, or null when it couldn't + * be asked just now (a network failure, a 5xx or a 429 on either read): + * the screen then offers the password form and a retry rather than + * settling on no providers. A server that answers without provider + * discovery (an older one) gets the password form + * alone, as before external sign-in; one that answers without the OAuth + * handshake capability shows no provider buttons. + */ + suspend fun signInOptions(serverUrl: String): SignInOptions? = coroutineScope { + val providers = async { api.listProviders(serverUrl) } + val handshake = async { api.oauthCapabilities(serverUrl) } + val listed = when (val result = providers.await()) { + is ApiResult.Success -> result.data + is ApiResult.Error -> return@coroutineScope if (result.isTemporary()) null else SignInOptions.PasswordOnly + is ApiResult.NetworkError -> return@coroutineScope null + } + val capabilities = when (val result = handshake.await()) { + is ApiResult.Success -> result.data + is ApiResult.Error -> if (result.isTemporary()) return@coroutineScope null else OAuthHandshakeCapabilities.None + is ApiResult.NetworkError -> return@coroutineScope null + } + SignInOptions.of(listed, capabilities) + } + + /** A refusal that says nothing about what the server offers: try again later. */ + private fun ApiResult.Error.isTemporary(): Boolean = code >= 500 || code == 429 + + suspend fun providers(serverUrl: String): ApiResult<SignInProviders> = api.listProviders(serverUrl) + + suspend fun oauthCapabilities(serverUrl: String): ApiResult<OAuthHandshakeCapabilities> = + api.oauthCapabilities(serverUrl) + + suspend fun externalSignInCapabilities(scope: AuthScopeSnapshot): ApiResult<ExternalSignInCapabilities> = + api.externalSignInCapabilities(scope) + + suspend fun identities(scope: AuthScopeSnapshot): ApiResult<AccountIdentities> = api.listIdentities(scope) + + suspend fun disconnect(scope: AuthScopeSnapshot, identityId: String): ApiResult<Unit> = + api.deleteIdentity(scope, identityId) + + suspend fun linkTicket( + scope: AuthScopeSnapshot, + installationId: String, + password: String, + ): ApiResult<AccountIdentityLinkTicket> = api.createLinkTicket(scope, installationId, password) + + suspend fun linkWithCredentials( + scope: AuthScopeSnapshot, + installationId: String, + password: String, + username: String, + directoryPassword: String, + ): ApiResult<AccountIdentity> = api.linkWithCredentials(scope, installationId, password, username, directoryPassword) + + /** The token pair for this device's owner at a network provider; see [ExternalSignInApi.signInWithNetworkIdentity]. */ + suspend fun signInWithNetworkIdentity(serverUrl: String, signInPath: String): ApiResult<LoginResponse> = + api.signInWithNetworkIdentity(serverUrl, signInPath) + + suspend fun linkWithNetwork( + scope: AuthScopeSnapshot, + installationId: String, + password: String, + ): ApiResult<AccountIdentity> = api.linkWithNetwork(scope, installationId, password) +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/ForegroundAccessCheck.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/ForegroundAccessCheck.kt new file mode 100644 index 0000000000..17723ee7ca --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/ForegroundAccessCheck.kt @@ -0,0 +1,26 @@ +package org.prairieserver.prairie.repository + +import org.prairieserver.prairie.network.AccessChangeSignals + +/** + * Catches an access change made while the app was in the background. + * + * The events sockets are the only source of [AccessChangeSignals.reportAccessChanged], + * and they close when the app stops. The server compares each connection with + * the policy its own ticket was minted under, so a connection opened after the + * change never reports it. When the app returns to the foreground, this check + * fetches the library list and reports an access change when the set of + * libraries differs from the cached list the screens were built from. + * + * It sees only changes to which libraries are visible. Other policy changes + * (maturity limits, playback quality) still surface when the affected screen + * next loads. + */ +class ForegroundAccessCheck( + private val personalDataRepository: PersonalDataRepository, + private val accessChanges: AccessChangeSignals, +) { + suspend fun afterReturnToForeground() { + if (personalDataRepository.libraryListChangedSinceCached()) accessChanges.reportAccessChanged() + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/HiddenLibrariesStore.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/HiddenLibrariesStore.kt new file mode 100644 index 0000000000..1976ab7882 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/HiddenLibrariesStore.kt @@ -0,0 +1,116 @@ +package org.prairieserver.prairie.repository + +import kotlin.concurrent.Volatile +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.doubleOrNull +import org.prairieserver.prairie.model.personal.UserLibrary +import org.prairieserver.prairie.model.settings.SettingKeys +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionBarrier +import org.prairieserver.prairie.network.PrairieJson + +/** + * The libraries the active profile hid from its own navigation + * (`ui.disabled_library_ids`, the web's Settings → Libraries → "Visible in + * navigation"). A preference, not access control: for a profile without a + * library limit the server still lists these libraries in + * `GET /user/libraries`, so the client leaves them out the way the web does. + * + * The set is bound to the identity generation it was read under: after a + * profile or server switch nothing reads as hidden until the new profile's + * value arrives, and a read that straddles a switch is dropped. Reads run one + * at a time, so an older answer never lands over a newer one. + */ +class HiddenLibrariesStore( + private val settingsRepository: SettingsRepository, + private val identityTransitions: IdentityTransitionBarrier = DefaultIdentityTransitionBarrier(), +) { + private data class Snapshot(val generation: Long, val ids: Set<Int>) + + @Volatile + private var snapshot = Snapshot(generation = -1, ids = emptySet()) + private val readMutex = Mutex() + private var failedReadGeneration = -1L + private val _revision = MutableStateFlow(0) + + /** + * Bumps when the set changes, so screens that loaded the library list + * re-load it. A first read made by a list load ([ensureLoaded]) doesn't + * bump it, since that load applies the result, unless an earlier read for + * this profile failed and lists went out unfiltered. + */ + val revision: StateFlow<Int> = _revision.asStateFlow() + + fun current(): Set<Int> = snapshot.idsFor(identityTransitions.generation.value) + + /** Reads the setting unless this profile's value is already known. */ + suspend fun ensureLoaded() { + if (snapshot.generation != identityTransitions.generation.value) read(announce = false) + } + + /** Re-reads the setting. A failed read keeps the last value read for this profile. */ + suspend fun refresh() = read(announce = true) + + private suspend fun read(announce: Boolean) = readMutex.withLock { + val generation = identityTransitions.generation.value + // A read that finished while this one waited already answered it. + if (!announce && snapshot.generation == generation) return@withLock + // A switch advances the generation before it installs the new + // identity; wait it out so this read can't answer for the old one. + identityTransitions.withCurrentGeneration(generation) { } ?: return@withLock + val result = settingsRepository.getEffectiveValues(listOf(KEY)) + if (generation != identityTransitions.generation.value) return@withLock + if (result !is ApiResult.Success) { + failedReadGeneration = generation + return@withLock + } + val previous = snapshot.idsFor(generation) + val ids = parseLibraryIds(result.data[KEY]?.value) + snapshot = Snapshot(generation, ids) + val listsWentOutUnfiltered = failedReadGeneration == generation + if ((announce || listsWentOutUnfiltered) && ids != previous) _revision.update { it + 1 } + } + + private fun Snapshot.idsFor(generation: Long): Set<Int> = + if (this.generation == generation) ids else emptySet() + + companion object { + const val KEY = SettingKeys.UI_DISABLED_LIBRARY_IDS + + /** + * Same rules as the web's `parseLibraryIDList`: the canonical array, + * the legacy JSON-string encoding of one, or null. Entries that are not + * positive whole numbers are dropped. + */ + fun parseLibraryIds(value: JsonElement?): Set<Int> { + val array = when (value) { + is JsonArray -> value + is JsonPrimitive -> if (value.isString && value.content.isNotBlank()) { + runCatching { PrairieJson.parseToJsonElement(value.content) }.getOrNull() as? JsonArray + } else { + null + } + else -> null + } ?: return emptySet() + return array.mapNotNullTo(linkedSetOf()) { entry -> + (entry as? JsonPrimitive) + ?.takeUnless { it.isString } + ?.doubleOrNull + ?.takeIf { it >= 1 && it <= Int.MAX_VALUE && it % 1.0 == 0.0 } + ?.toInt() + } + } + } +} + +fun List<UserLibrary>.withoutHidden(hiddenIds: Set<Int>): List<UserLibrary> = + if (hiddenIds.isEmpty()) this else filterNot { it.id in hiddenIds } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/HomeRealtimeCoordinator.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/HomeRealtimeCoordinator.kt index f041e12f50..1d5c7ee9d6 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/HomeRealtimeCoordinator.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/HomeRealtimeCoordinator.kt @@ -8,7 +8,9 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.launch +import org.prairieserver.prairie.network.AccessChangeSignals import org.prairieserver.prairie.network.HomeRealtimeClient import org.prairieserver.prairie.network.HomeRealtimeEvent import org.prairieserver.prairie.network.TokenManager @@ -28,12 +30,20 @@ import org.prairieserver.prairie.network.homeRefreshTrigger class HomeRealtimeCoordinator( private val client: HomeRealtimeClient, private val tokenManager: TokenManager, + /** + * Server-reported access changes. Either events socket may be the one that + * sees the change (each runs its own access check), so Home listens here + * rather than only to its own frames. The socket's 4001 close then makes + * [connect] reconnect with a freshly minted ticket. + */ + private val accessChanges: AccessChangeSignals? = null, ) { private val triggers = MutableSharedFlow<Unit>(extraBufferCapacity = 16) /** Debounced refetch signal — collect and call the Home refresh. */ @OptIn(FlowPreview::class) - val refreshSignals: Flow<Unit> = triggers.debounce(DEBOUNCE_MS) + val refreshSignals: Flow<Unit> = (accessChanges?.let { merge(triggers, it.changes) } ?: triggers) + .debounce(DEBOUNCE_MS) fun connect(scope: CoroutineScope): Job = scope.launch { var backoffMs = INITIAL_BACKOFF_MS diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/HouseholdManagementSession.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/HouseholdManagementSession.kt new file mode 100644 index 0000000000..87f50f1c76 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/HouseholdManagementSession.kt @@ -0,0 +1,177 @@ +package org.prairieserver.prairie.repository + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.prairieserver.prairie.model.profile.Profile +import org.prairieserver.prairie.model.profile.VerifyPinResponse +import org.prairieserver.prairie.model.profile.authorizedProfileToken +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.api.HouseholdManager +import org.prairieserver.prairie.network.api.isSameAccountAs + +/** + * The profile picker's manage mode: household management acting as the + * account's primary profile, which is the only profile the server lets create, + * edit, or delete the others. + * + * The primary's profile token is held in memory for the session only. It is + * never selected or persisted, and the PIN itself is never stored. + * + * A profile token is valid only for the account's access-policy revision it was + * issued under, and the server bumps that revision whenever an update touches a + * PIN, child flag, rating, advisory, library, or quality limit. So the first + * save of that kind makes the held token stale. [run] answers the resulting + * `profile_verification_required` by asking for the primary's PIN again + * ([reverify]), replacing the token, and retrying the call once. + */ +class HouseholdManagementSession internal constructor( + private val verifyPin: suspend (profileId: String, pin: String) -> ApiResult<VerifyPinResponse>, + private val captureScope: suspend () -> AuthScopeSnapshot?, +) { + /** The PIN prompt shown while a stale primary token is being replaced. */ + data class Reverify( + val profile: Profile, + val isVerifying: Boolean = false, + val error: String? = null, + /** Bumped on each rejected PIN so the entry clears. */ + val errorCount: Int = 0, + ) + + private class Held(val primary: Profile, val manager: HouseholdManager) + + private val held = MutableStateFlow<Held?>(null) + private val _reverify = MutableStateFlow<Reverify?>(null) + private val pinEntries = Channel<String?>(Channel.CONFLATED) + private val renewLock = Mutex() + + private val _isActive = MutableStateFlow(false) + + /** Whether a management session is running; it drops to false when the user cancels a re-prompt. */ + val isActive: StateFlow<Boolean> = _isActive.asStateFlow() + + /** Non-null while the primary's PIN is needed again; render the PIN prompt for it. */ + val reverify: StateFlow<Reverify?> = _reverify.asStateFlow() + + /** The manager management calls act as, or null outside a session. */ + val manager: HouseholdManager? get() = held.value?.manager + + /** + * Starts a session as [primary]. [profileToken] is the token `verify-pin` + * issued for it (null when it has no PIN); [scope] is the identity that + * verification ran under. + */ + fun begin(primary: Profile, profileToken: String?, scope: AuthScopeSnapshot?) { + setHeld(Held(primary, HouseholdManager(primary.id, profileToken, scope))) + } + + /** + * Ends the session, abandoning any open re-prompt. The null entry is sent + * unconditionally: a re-prompt that is about to open (its held check + * already passed) reads it and closes instead of waiting for input. + */ + fun end() { + setHeld(null) + pinEntries.trySend(null) + } + + /** A PIN typed into the [reverify] prompt. */ + fun submitPin(pin: String) { + if (_reverify.value?.isVerifying == false) pinEntries.trySend(pin) + } + + /** + * Cancel on the [reverify] prompt: ends the session without retrying. The + * session ends at once, so a verification already in flight cannot renew + * the manager and retry the call after the user cancelled. + */ + fun cancelReverify() { + if (_reverify.value != null) end() + } + + /** + * Runs one management [call] as the held manager. When the server says the + * primary's token is no longer valid, asks for the PIN again and retries + * the call once with the new token. Cancel there ends the session and + * returns [cancelledError]. + */ + suspend fun <T> run(call: suspend (HouseholdManager?) -> ApiResult<T>): ApiResult<T> { + val used = manager + val first = call(used) + if (used == null || !first.needsProfileVerification()) return first + val renewed = renew(used) ?: return cancelledError() + return call(renewed) + } + + private suspend fun renew(failed: HouseholdManager): HouseholdManager? = renewLock.withLock { + // Drain before the held check: an end() after this point leaves its + // null in the channel, so the prompt below closes on it. + while (pinEntries.tryReceive().isSuccess) Unit + val current = held.value ?: return@withLock null + // Another call already asked for the PIN while this one waited. + if (current.manager !== failed) return@withLock current.manager + _reverify.value = Reverify(current.primary) + try { + askUntilVerified(current) + } finally { + _reverify.value = null + } + } + + private suspend fun askUntilVerified(current: Held): HouseholdManager? { + while (true) { + val pin = pinEntries.receive() + if (pin == null) { + if (held.value === current) setHeld(null) + return null + } + _reverify.update { it?.copy(isVerifying = true, error = null) } + val scope = captureScope() + if (!current.isSameAccountAs(scope)) return endFor(current) + val result = verifyPin(current.primary.id, pin) + if (held.value !== current) return null + // The new token must come from the account the session began on. + if (!current.isSameAccountAs(captureScope())) return endFor(current) + val token = (result as? ApiResult.Success)?.data?.authorizedProfileToken() + if (token != null) { + val renewed = Held(current.primary, HouseholdManager(current.primary.id, token, scope)) + setHeld(renewed) + return renewed.manager + } + val message = when (result) { + is ApiResult.Success -> "Wrong PIN. Try again." + is ApiResult.Error -> result.message.ifBlank { "Incorrect PIN" } + is ApiResult.NetworkError -> "Network error. Please try again." + } + _reverify.update { it?.copy(isVerifying = false, error = message, errorCount = it.errorCount + 1) } + } + } + + private fun Held.isSameAccountAs(scope: AuthScopeSnapshot?): Boolean = + manager.scope?.isSameAccountAs(scope) ?: true + + private fun endFor(current: Held): HouseholdManager? { + if (held.value === current) setHeld(null) + return null + } + + private fun setHeld(value: Held?) { + held.value = value + _isActive.value = value != null + } + + companion object { + private val VerificationCodes = setOf("profile_verification_required", "profile_unverified") + + internal fun ApiResult<*>.needsProfileVerification(): Boolean = + this is ApiResult.Error && code == 403 && error in VerificationCodes + + internal fun cancelledError(): ApiResult.Error = + ApiResult.Error(403, "profile_verification_required", "Enter the primary profile's PIN to manage profiles.") + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/NotificationsRepository.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/NotificationsRepository.kt index 5ce9b5510c..8ad10185a7 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/NotificationsRepository.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/NotificationsRepository.kt @@ -22,6 +22,8 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.updateAndGet import kotlinx.coroutines.launch @@ -124,7 +126,9 @@ class NotificationsRepository( private val checkpoints: NotificationSyncStore? = null, private val identityTransitions: IdentityTransitionBarrier? = null, ) { - private var epoch = 0L + // Observable so a parked realtime loop can wait for the next identity. + private val epochState = MutableStateFlow(0L) + private val epoch: Long get() = epochState.value private var readCutoff: String? = null private var cutoffViewer: AuthScopeSnapshot? = null private val refreshMutex = Mutex() @@ -153,7 +157,7 @@ class NotificationsRepository( private val _preferences = MutableStateFlow<NotificationPreferences?>(null) private val _capability = MutableStateFlow<NotificationCapability?>(null) - /** True once [connectRealtime] gives up permanently due to an auth-class [NotificationRealtimeEvent.Closed]. */ + /** True while [connectRealtime] is parked after an auth-class [NotificationRealtimeEvent.Closed]. */ private val _realtimeFatal = MutableStateFlow(false) val unreadCount: StateFlow<Int> = _unreadCount.asStateFlow() @@ -163,9 +167,10 @@ class NotificationsRepository( val capability: StateFlow<NotificationCapability?> = _capability.asStateFlow() /** - * Becomes true if [connectRealtime] receives an auth-class [NotificationRealtimeEvent.Closed] - * (401/403/auth reason). When true the realtime loop has permanently stopped; callers should - * surface an appropriate error state rather than attempting to reconnect. + * True while [connectRealtime] is parked after an auth-class [NotificationRealtimeEvent.Closed] + * (401/403/auth reason). The loop stops retrying the same identity and resumes on the next + * [reset] — a profile re-verification, a profile switch, or an account or server change — + * which also clears this flag. */ val realtimeFatal: StateFlow<Boolean> = _realtimeFatal.asStateFlow() @@ -331,7 +336,8 @@ class NotificationsRepository( * [tryEmit] into the buffered flow never suspends. */ fun reset() { - epoch++ + epochState.update { it + 1 } + _realtimeFatal.value = false _error.value = null readCutoff = null cutoffViewer = null @@ -382,7 +388,16 @@ class NotificationsRepository( } catch (_: Throwable) { // fall through to backoff-reconnect } - if (authFailed) return@launch + if (authFailed) { + // Retrying the same identity would be refused again (a stale + // profile token stays stale). Park until the identity changes — + // the user re-enters the PIN, picks another profile, or the + // account changes — then reconnect with a fresh ticket. + epochState.first { it != connectionViewer.epoch } + _realtimeFatal.value = false + backoffMs = INITIAL_BACKOFF_MS + continue + } delay(backoffMs) backoffMs = (backoffMs * 2).coerceAtMost(MAX_BACKOFF_MS) } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/PersonalDataRepository.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/PersonalDataRepository.kt index 9a845ad334..e2ea305185 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/PersonalDataRepository.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/PersonalDataRepository.kt @@ -1,5 +1,9 @@ package org.prairieserver.prairie.repository +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.prairieserver.prairie.model.personal.SyncProgressItem @@ -31,27 +35,105 @@ open class PersonalDataRepository( private val catalogCache: CatalogCachePort = NoOpCatalogCachePort, private val identityTransitions: IdentityTransitionBarrier = DefaultIdentityTransitionBarrier(), membershipPort: org.prairieserver.prairie.repository.port.MembershipPort? = null, + /** The profile's hidden libraries, left out of every library list below. Null lists all. */ + private val hiddenLibraries: HiddenLibrariesStore? = null, ) { val memberships = MembershipActions(membershipPort, identityTransitions) // -- Libraries -- - /** Lists the libraries visible to the current user (offline: last cached list). */ + /** + * Changes when the profile hides or shows a library on another device + * (picked up on foreground and reconnect). Screens holding a library list + * re-load it then. + */ + val hiddenLibrariesRevision: StateFlow<Int> = hiddenLibraries?.revision ?: MutableStateFlow(0) + + /** + * Lists the libraries visible to the current user, minus the ones the + * profile hid (offline: last cached list). + */ suspend fun listUserLibraries(): ApiResult<List<UserLibrary>> { val requestIdentityGeneration = identityTransitions.generation.value - val result = personalDataApi.listUserLibraries() - if (result is ApiResult.Success) { - writeIfIdentityUnchanged(requestIdentityGeneration) { cacheWriteLease -> - catalogCache.cacheLibraries(result.data, cacheWriteLease) - } - return result - } + val result = fetchUserLibraries(requestIdentityGeneration) { personalDataApi.listUserLibraries() } + if (result is ApiResult.Success) return publishLibraries(result.data, requestIdentityGeneration) if (result.canServeCache()) { - catalogCache.getCachedLibraries()?.let { return ApiResult.Success(it) } + catalogCache.getCachedLibraries()?.let { return ApiResult.Success(withoutHidden(it)) } } return result } + /** + * Re-checks the library list after the app returns to the foreground and + * says whether the set of visible libraries differs from the cached list, + * which is the one the screens last loaded. Goes through + * [recheckUserLibraries], so a short response is confirmed before it is + * trusted and the cache keeps only the visible libraries. False when there + * is no cached list, the request fails, or the identity changed while it ran. + */ + suspend fun libraryListChangedSinceCached(): Boolean { + val cachedIds = catalogCache.getCachedLibraries()?.mapTo(HashSet()) { it.id } ?: return false + val result = recheckUserLibraries(cachedIds) + return result is ApiResult.Success && result.data.mapTo(HashSet()) { it.id } != cachedIds + } + + /** + * Background re-check of a library list already on screen ([knownIds]). + * A list missing any known library is read again before it is trusted, + * and only the accepted list is cached, so one transient short response + * can't replace the full list on screen or in the offline cache. A failed + * read returns the failure rather than the cache — even with nothing on + * screen, where the cache may hold a list whose access was since denied — + * so the caller keeps what it shows. + */ + suspend fun recheckUserLibraries(knownIds: Set<Int>): ApiResult<List<UserLibrary>> { + val requestIdentityGeneration = identityTransitions.generation.value + // Known ids are checked against the full list: a library the profile + // just hid is a deliberate removal, not a short response. + val result = fetchUserLibraries(requestIdentityGeneration) { + val first = personalDataApi.listUserLibraries() + if (first is ApiResult.Success && !first.data.map { it.id }.containsAll(knownIds)) { + personalDataApi.listUserLibraries() + } else { + first + } + } + if (result is ApiResult.Success) return publishLibraries(result.data, requestIdentityGeneration) + return result + } + + /** + * Runs [fetch] while the profile's hidden libraries load beside it, the + * first time only. A switch while either is in flight answers + * `identity_changed`, so the old profile's list never comes back. + */ + private suspend fun fetchUserLibraries( + requestIdentityGeneration: Long, + fetch: suspend () -> ApiResult<List<UserLibrary>>, + ): ApiResult<List<UserLibrary>> = coroutineScope { + val hiddenRead = launch { hiddenLibraries?.ensureLoaded() } + val result = fetch() + hiddenRead.join() + if (requestIdentityGeneration != identityTransitions.generation.value) identityChanged() else result + } + + /** Drops hidden libraries and caches what is shown, unless the profile changed meanwhile. */ + private suspend fun publishLibraries( + libraries: List<UserLibrary>, + requestIdentityGeneration: Long, + ): ApiResult<List<UserLibrary>> { + val visible = withoutHidden(libraries) + writeIfIdentityUnchanged(requestIdentityGeneration) { cacheWriteLease -> + catalogCache.cacheLibraries(visible, cacheWriteLease) + } + if (requestIdentityGeneration != identityTransitions.generation.value) return identityChanged() + return ApiResult.Success(visible) + } + + /** [libraries] minus the ones the profile hid, for a list a screen already holds. */ + fun withoutHidden(libraries: List<UserLibrary>): List<UserLibrary> = + libraries.withoutHidden(hiddenLibraries?.current().orEmpty()) + // -- Favorites -- suspend fun isFavorite(itemId: String): ApiResult<Boolean> = diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/PlaybackRepository.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/PlaybackRepository.kt index 720a7bfa2b..f8d3b39448 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/PlaybackRepository.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/PlaybackRepository.kt @@ -19,15 +19,12 @@ class PlaybackRepository( private val transcodeApi: PlaybackApi? = null, ) { suspend fun controlOwner(sessionId: String): Pair<AuthScopeSnapshot, String?>? = sequenced.controlOwner(sessionId) - val pendingPlayback = sequenced.pending fun isSequenced(sessionId: String): Boolean = sequenced.owns(sessionId) - suspend fun pendingPlaybackCount(): Int = sequenced.pendingForCurrentViewer() - suspend fun recoverPlayback(): ApiResult<Unit> = guarded { sequenced.recover() } /** [SequencedPlayback] answers null for a session it never journaled. */ private fun unknownSession() = ApiResult.Error(0, "playback_unavailable", "This playback session is not owned by the app.") private suspend fun <T> guarded(block: suspend () -> ApiResult<T>): ApiResult<T> = try { block() } catch (e: CancellationException) { throw e } - catch (e: Exception) { ApiResult.Error(0, "playback_storage", "Playback recovery storage is unavailable.") } + catch (e: Exception) { ApiResult.Error(0, "playback_storage", "Playback storage is unavailable.") } /** Starts a protocol-v3 playback attempt using the supplied client and route evidence. */ suspend fun startPlaybackV3(request: PlaybackStartRequestV3, expectedMetadataOwner: AuthScopeSnapshot? = null): ApiResult<PlaybackDecisionResponseV3> = diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/ProfileRepository.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/ProfileRepository.kt index 7ab8cf7f12..e308f6476d 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/ProfileRepository.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/ProfileRepository.kt @@ -10,7 +10,9 @@ import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier import org.prairieserver.prairie.network.IdentityTransitionBarrier import org.prairieserver.prairie.network.IdentityTransitionKind import org.prairieserver.prairie.network.ServerRegistry +import org.prairieserver.prairie.network.StaleProfileReport import org.prairieserver.prairie.network.TokenManager +import org.prairieserver.prairie.network.isSameHttpOrigin import org.prairieserver.prairie.network.api.ProfileApi import org.prairieserver.prairie.network.map @@ -39,14 +41,24 @@ open class ProfileRepository( open suspend fun listProfiles(): ApiResult<List<Profile>> = profileApi.listProfiles().map { it.profiles } - /** Creates a new profile. */ + /** + * The picker's manage mode: management calls act as the household's primary + * profile while it runs. Held in memory only; see [HouseholdManagementSession]. + */ + val householdManagement = HouseholdManagementSession( + verifyPin = { id, pin -> profileApi.verifyPin(id, pin) }, + captureScope = { tokenManager.snapshotCurrentScope() }, + ) + + /** Creates a new profile, as the household manager while manage mode is on. */ open suspend fun createProfile(request: CreateProfileRequest): ApiResult<Profile> = - profileApi.createProfile(request) + householdManagement.run { profileApi.createProfile(request, it) } - /** Updates an existing profile. */ + /** Updates an existing profile, as the household manager while manage mode is on. */ open suspend fun updateProfile(id: String, request: UpdateProfileRequest): ApiResult<Profile> = - profileApi.updateProfile(id, request) + householdManagement.run { profileApi.updateProfile(id, request, it) } + /** Updates the selected profile as itself, even while manage mode is on. */ suspend fun updateActiveProfile(request: UpdateProfileRequest): ApiResult<Profile> { val profileId = getActiveProfileId() ?: return ApiResult.Error( @@ -54,12 +66,12 @@ open class ProfileRepository( error = "bad_request", message = "No active profile selected", ) - return updateProfile(profileId, request) + return profileApi.updateProfile(profileId, request) } - /** Deletes a profile by ID. */ + /** Deletes a profile by ID, as the household manager while manage mode is on. */ suspend fun deleteProfile(id: String): ApiResult<Unit> = - profileApi.deleteProfile(id) + householdManagement.run { profileApi.deleteProfile(id, it) } /** * Verifies a profile's PIN. @@ -237,6 +249,59 @@ open class ProfileRepository( return profiles.firstOrNull { it.id == activeId } } + /** + * Clears the active profile when [report] proves its proof is stale. + * + * Acts only when the refused request presented exactly the profile id and + * token that are active now, on the active server: a report for a profile + * the user has since switched away from, re-verified, or already cleared is + * ignored, so repeated refusals cannot loop. A remote-playback overlay owns + * identity while it exists and is never touched. The account session is + * left alone; only the profile selection goes. + * + * @return true when the profile selection was cleared. + */ + suspend fun clearStaleProfile(report: StaleProfileReport): Boolean { + // Checked before entering the transition so an ignored report does not + // fire identity gates, then again inside it in case the identity moved. + // `changing` bumps the generation before its block runs (see + // [identityScopeStillHolds]), so the inner check allows exactly that one. + val generationBeforeTransition = identityTransitions.generation.value + if (!staleProfileStillActive(report, transitionsSinceCheck = 0)) return false + var cleared = false + identityTransitions.changing(IdentityTransitionKind.PROFILE_SWITCH) { + if (identityTransitions.generation.value != generationBeforeTransition + 1) return@changing + if (!staleProfileStillActive(report, transitionsSinceCheck = 1)) return@changing + val activeServerId = tokenManager.getCurrentServerId() + tokenManager.setProfileIdentity(null, null) + if (activeServerId != null) { + serverRegistry?.setProfileId(activeServerId, null) + } + notificationsRepository?.reset() + requestsRepository?.reset() + cleared = true + } + return cleared + } + + private suspend fun staleProfileStillActive(report: StaleProfileReport, transitionsSinceCheck: Long): Boolean { + if (tokenManager.hasTemporaryScope()) return false + val identity = tokenManager.getProfileIdentity() + if (identity.profileId != report.profileId || identity.profileToken != report.profileToken) return false + val current = tokenManager.snapshotCurrentScope() + if (!isSameHttpOrigin(current?.serverUrl ?: tokenManager.getServerUrl(), report.requestUrl)) return false + val pinned = report.pinnedScope ?: return true + // A pinned request belongs to the identity it captured; a scope-less + // manager cannot prove that is still the active one. The profile id and + // token already matched above, so the generation is what proves no + // sign-in, server switch, or profile pick happened in between. The + // credential epoch is left out on purpose: an access-token refresh + // says nothing about whether the profile token is stale. + return current != null && + current.serverId == pinned.serverId && + current.identityGeneration == pinned.identityGeneration + transitionsSinceCheck + } + /** Clears the active profile selection and its token. */ suspend fun clearProfile() { identityTransitions.changing(IdentityTransitionKind.PROFILE_SWITCH) { diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/ProfileVerificationRecovery.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/ProfileVerificationRecovery.kt new file mode 100644 index 0000000000..d7b0d018a6 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/ProfileVerificationRecovery.kt @@ -0,0 +1,180 @@ +package org.prairieserver.prairie.repository + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import org.prairieserver.prairie.network.AccessChangeSignals +import org.prairieserver.prairie.network.IdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionPhase +import org.prairieserver.prairie.network.StaleProfileReport +import org.prairieserver.prairie.network.TokenManager + +/** + * The user must pick a profile (and enter its PIN) again because the server + * stopped accepting the active profile's proof on server [serverId]. + * + * The prompt stays valid while that server is active, signed in, and has no + * profile selected. Signing out, switching servers, or picking a profile ends + * it. Other identity transitions (a remote-playback overlay starting or ending, + * removing another server) leave the profile cleared, so they must not end it. + * + * [sequence] tells two prompts for the same server apart. + */ +data class ProfileVerificationPrompt( + val serverId: String?, + val sequence: Long, +) + +/** What the navigation layer does with a pending [ProfileVerificationPrompt]. */ +enum class ProfilePromptAction { + /** Send the user to profile selection now. */ + Navigate, + + /** Keep the prompt until the user leaves the current destination. */ + Defer, + + /** + * The user is already on the profile picker or one of its screens. Consume + * the prompt without navigating, so a PIN being typed is not thrown away; + * the picker reloads itself from [ProfileVerificationRecovery.profileCleared]. + */ + AlreadyThere, + + /** The prompt no longer applies; consume it without navigating. */ + Drop, +} + +/** + * Turns stale-profile refusals into one profile-selection prompt. + * + * Any v2 request can come back 403 `profile_verification_required` after an + * administrator changes the account's access: the server stops accepting PIN + * profile tokens minted before the change. Without this every screen showed a + * failed load. The recovery clears the stale profile selection (never the + * account session) through [ProfileRepository.clearStaleProfile], which ignores + * refusals for any identity other than the active one, and then raises + * [pending] for the phone and TV navigation to act on. + * + * Background work (downloads, outbox replay, Watch Next) reaches this too when + * it ran on the active identity, because that identity is just as stale. It + * never navigates by itself: navigation reads [pending] and defers while the + * player is in front, so a background refusal cannot pull the user out of + * playback. + */ +class ProfileVerificationRecovery( + private val signals: AccessChangeSignals, + private val tokenManager: TokenManager, + private val profileRepository: ProfileRepository, + identityTransitions: IdentityTransitionBarrier, + scope: CoroutineScope, +) { + private val _pending = MutableStateFlow<ProfileVerificationPrompt?>(null) + + /** The prompt waiting for the navigation layer, or null. */ + val pending: StateFlow<ProfileVerificationPrompt?> = _pending.asStateFlow() + + /** + * [pending], emitted again after every completed identity transition. The + * navigation layer collects this so a prompt deferred behind a + * remote-playback overlay is decided again when the overlay ends, even if + * the visible destination does not change. + */ + val promptChecks: Flow<ProfileVerificationPrompt?> = combine( + pending, + identityTransitions.transitions + .filter { it.phase == IdentityTransitionPhase.DID_CHANGE } + .map { it.generation } + .onStart { emit(identityTransitions.generation.value) }, + ) { prompt, _ -> prompt } + + private val _profileCleared = MutableSharedFlow<Unit>( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + /** + * Fires each time a stale profile is cleared. The profile pickers reload on + * it: a list fetched with the stale token failed, and the next one is sent + * without it. + */ + val profileCleared: SharedFlow<Unit> = _profileCleared.asSharedFlow() + + private var sequence = 0L + + init { + scope.launch { + // Serial on purpose: the first report clears the profile, and every + // later one for the same identity then finds nothing to clear. + signals.staleProfileReports.collect { report -> + try { + handle(report) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + // A failing identity gate must not crash the app or end the + // collection; the next refusal tries again. + } + } + } + } + + internal suspend fun handle(report: StaleProfileReport) { + if (!profileRepository.clearStaleProfile(report)) return + _pending.value = ProfileVerificationPrompt( + serverId = tokenManager.snapshotCurrentScope()?.serverId ?: tokenManager.getCurrentServerId(), + sequence = ++sequence, + ) + _profileCleared.tryEmit(Unit) + } + + /** + * Whether [prompt] still applies: the same server is active and signed in, + * and no profile has been selected since. Call only while no + * remote-playback overlay is installed (the overlay reports its own + * identity, not the saved one). + */ + suspend fun isCurrent(prompt: ProfileVerificationPrompt): Boolean { + if (tokenManager.getAccessToken().isNullOrBlank()) return false + val scope = tokenManager.snapshotCurrentScope() + val serverId = scope?.serverId ?: tokenManager.getCurrentServerId() + val profileId = if (scope != null) scope.profileId else tokenManager.getProfileId() + return serverId == prompt.serverId && profileId.isNullOrBlank() + } + + /** + * Decides what to do with [prompt] while [currentRoute] is displayed. + * [deferRoutes] are destinations the prompt must not interrupt: playback, + * and the sign-in and server screens where no profile is in use. + * [pickerRoutes] are the profile picker and its create and edit screens. + */ + suspend fun actionFor( + prompt: ProfileVerificationPrompt, + currentRoute: String?, + deferRoutes: Set<String>, + pickerRoutes: Set<String> = emptySet(), + ): ProfilePromptAction = when { + // A remote-playback overlay owns identity until it ends; decide after. + tokenManager.hasTemporaryScope() -> ProfilePromptAction.Defer + !isCurrent(prompt) -> ProfilePromptAction.Drop + currentRoute == null || currentRoute in deferRoutes -> ProfilePromptAction.Defer + currentRoute in pickerRoutes -> ProfilePromptAction.AlreadyThere + else -> ProfilePromptAction.Navigate + } + + /** Marks [prompt] handled; a newer prompt raised meanwhile is kept. */ + fun consume(prompt: ProfileVerificationPrompt) { + _pending.compareAndSet(prompt, null) + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/RequestDetailCache.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/RequestDetailCache.kt new file mode 100644 index 0000000000..6aa84c3774 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/RequestDetailCache.kt @@ -0,0 +1,208 @@ +package org.prairieserver.prairie.repository + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.prairieserver.prairie.model.request.MediaRequest +import org.prairieserver.prairie.model.request.RequestAvailability +import org.prairieserver.prairie.model.request.RequestMediaDetail +import org.prairieserver.prairie.model.request.RequestMediaResult +import org.prairieserver.prairie.model.request.RequestMediaType +import org.prairieserver.prairie.model.request.RequestOutcome +import org.prairieserver.prairie.model.request.RequestReason +import org.prairieserver.prairie.model.request.RequestState +import org.prairieserver.prairie.model.request.RequestUserState +import org.prairieserver.prairie.model.request.requestInstantKey +import org.prairieserver.prairie.network.ApiResult + +/** + * Session memory for request detail pages, so opening a title the app has + * already seen paints its final layout on the first frame instead of building + * itself as reads land (the Apple clients' `RequestDetailCache`). + * + * It holds, keyed by media type + TMDB id: the title detail from the last read + * or prefetch; the signed-in user's own request records from every + * `/requests/mine` read; and admin records from the approval queue. A tap + * seeds it too: a card carries enough of the title for a provisional page. + * Every page still refreshes from the server; this only decides the first frame. + * + * Main-thread confined, like the view models that read it. Profile-scoped: + * [RequestsRepository.reset] clears it on sign-in, sign-out, and profile or + * server switches, and drops reads that answer after it. + */ +class RequestDetailCache( + /** Runs the shared prefetch queue; null disables prefetch (tests). */ + private val prefetchScope: CoroutineScope? = null, +) { + data class Key(val mediaType: String, val tmdbId: Int) + + private val details = LinkedHashMap<Key, RequestMediaDetail>() + private var ownRecords: Map<Key, MediaRequest> = emptyMap() + private var moderationRecords: Map<Key, MediaRequest> = emptyMap() + /** The exact request an admin opened from the approval queue; wins over any other for the title. */ + private val pinnedModeration = mutableMapOf<Key, MediaRequest>() + private val seeds = mutableMapOf<Key, RequestMediaResult>() + private val prefetchQueue = ArrayDeque<Pair<Key, suspend (Key) -> ApiResult<RequestMediaDetail>>>() + private var prefetchJob: Job? = null + /** Bumped by [clear]: a drain from the previous session stops at its next step. */ + private var generation = 0 + + fun detail(key: Key): RequestMediaDetail? = details[key] + fun ownRecord(key: Key): MediaRequest? = ownRecords[key] + fun moderationRecord(key: Key): MediaRequest? = moderationRecords[key] + fun pinnedModerationRecord(key: Key): MediaRequest? = pinnedModeration[key] + + /** Any other way into the title opens an ordinary page. */ + fun unpinModeration(key: Key) { + pinnedModeration.remove(key) + } + + /** Drops the pin once the request has been decided on. */ + fun unpinModeration(record: MediaRequest) { + val key = record.cacheKey() + if (pinnedModeration[key]?.id == record.id) pinnedModeration.remove(key) + } + + fun pinModeration(record: MediaRequest) { + pinnedModeration[record.cacheKey()] = record + } + + /** A page to show before the detail read answers: the cached detail, or one built from a card or record. */ + fun firstFrameDetail(key: Key): RequestMediaDetail? = + details[key] + ?: seeds[key]?.let(::provisionalDetail) + ?: (ownRecords[key] ?: moderationRecords[key])?.let(::provisionalDetail) + + fun store(detail: RequestMediaDetail) { + val key = Key(detail.mediaType, detail.tmdbId) + details.remove(key) + details[key] = detail + if (details.size > DetailLimit) details.remove(details.keys.first()) + } + + /** A complete `/requests/mine` read. Replaces what was known, so a cancelled request no longer seeds a page. */ + fun storeOwnRecords(records: List<MediaRequest>) { + ownRecords = records.groupBy { it.cacheKey() }.mapNotNull { (key, list) -> currentRecord(list)?.let { key to it } }.toMap() + } + + fun storeOwnRecord(record: MediaRequest) { + val key = record.cacheKey() + ownRecords = if (record.outcome == RequestOutcome.Cancelled) ownRecords - key else ownRecords + (key to record) + } + + /** + * Records in priority order: the first per title wins, so a pending request + * listed before a failed one is the one the page decides on. + */ + fun storeModerationRecords(records: List<MediaRequest>) { + val next = LinkedHashMap<Key, MediaRequest>() + records.forEach { next.getOrPut(it.cacheKey()) { it } } + moderationRecords = next + // A complete read no longer listing a pinned request means someone + // already decided on it; its pin must not bring the buttons back. + val queued = records.mapTo(HashSet()) { it.id } + pinnedModeration.entries.removeAll { it.value.id !in queued } + } + + fun seed(result: RequestMediaResult) { + seeds[Key(result.mediaType, result.tmdbId)] = result + } + + fun clear() { + generation++ + prefetchJob?.cancel() + prefetchJob = null + prefetchQueue.clear() + details.clear() + ownRecords = emptyMap() + moderationRecords = emptyMap() + pinnedModeration.clear() + seeds.clear() + } + + /** + * Warms the detail for the first rows of a list, one read at a time and only + * for titles not already cached, so opening any of them lands on the + * finished page. Lists that load together share one queue. + */ + fun prefetch(records: List<MediaRequest>, load: suspend (Key) -> ApiResult<RequestMediaDetail>) { + val scope = prefetchScope ?: return + val batch = records.asSequence() + .map { it.cacheKey() } + .filter { details[it] == null && (it.mediaType == RequestMediaType.Movie || it.mediaType == RequestMediaType.Series) } + .distinct() + .take(PrefetchCount) + .filter { key -> prefetchQueue.none { it.first == key } } + .toList() + if (batch.isEmpty()) return + batch.forEach { prefetchQueue.addLast(it to load) } + if (prefetchJob != null) return + val session = generation + prefetchJob = scope.launch { + while (session == generation && prefetchQueue.isNotEmpty()) { + val (key, loader) = prefetchQueue.removeFirst() + if (details[key] != null) continue + val result = loader(key) + if (session != generation) return@launch + if (result is ApiResult.Success) store(result.data) + } + if (session == generation) prefetchJob = null + } + } + + companion object { + /** Bounded so a long browsing session can't grow without limit. */ + private const val DetailLimit = 80 + /** Rows warmed per list read: roughly the first screenful and a bit. */ + const val PrefetchCount = 10 + + /** + * The one of a title's requests that speaks for it: the newest active + * one, otherwise the newest overall. Null when that newest one was + * cancelled, so an older decline or failure doesn't come back. + */ + fun currentRecord(records: List<MediaRequest>): MediaRequest? { + records.filter { it.outcome == RequestOutcome.Active } + .maxByOrNull { it.createdAt.requestInstantKey() } + ?.let { return it } + return records.maxByOrNull { it.createdAt.requestInstantKey() } + ?.takeIf { it.outcome != RequestOutcome.Cancelled } + } + + fun provisionalDetail(result: RequestMediaResult): RequestMediaDetail = RequestMediaDetail( + mediaType = result.mediaType, + tmdbId = result.tmdbId, + title = result.title, + overview = result.overview, + posterPath = result.posterPath, + backdropPath = result.backdropPath, + releaseDate = result.releaseDate, + year = result.year, + voteAverage = result.voteAverage, + availability = result.availability, + libraryContentId = result.libraryContentId, + request = result.request, + ) + + fun provisionalDetail(record: MediaRequest): RequestMediaDetail = RequestMediaDetail( + mediaType = record.mediaType, + tmdbId = record.tmdbId, + title = record.title, + overview = record.overview, + posterPath = record.posterPath, + backdropPath = record.backdropPath, + year = record.year, + availability = if (record.state == RequestUserState.Available) RequestAvailability.Available else RequestAvailability.Missing, + libraryContentId = record.libraryContentId, + request = RequestState( + status = record.status, + state = record.state, + requestable = false, + reason = RequestReason.AlreadyRequested, + requestId = record.id, + ), + ) + } +} + +fun MediaRequest.cacheKey(): RequestDetailCache.Key = RequestDetailCache.Key(mediaType, tmdbId) diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/RequestsRepository.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/RequestsRepository.kt index e6e7db3afd..88e18fca4b 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/RequestsRepository.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/RequestsRepository.kt @@ -1,5 +1,7 @@ package org.prairieserver.prairie.repository +import org.prairieserver.prairie.model.request.AdminRequestAction +import org.prairieserver.prairie.model.request.AdminRequestCapabilities import org.prairieserver.prairie.model.request.CreateMediaRequest import org.prairieserver.prairie.model.request.MediaRequest import org.prairieserver.prairie.model.request.RequestMediaDetail @@ -8,16 +10,62 @@ import org.prairieserver.prairie.model.request.RequestsDiscoverResponse import org.prairieserver.prairie.model.request.RequestsFeatureStatus import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.api.RequestsApi +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeMark +import kotlin.time.TimeSource import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update -class RequestsRepository(private val api: RequestsApi) { +/** + * One request mutation, broadcast so every mounted request surface can patch + * its own items in place. [sequence] makes a repeat of the same record a new event. + */ +data class RequestEvent(val record: MediaRequest, val sequence: Long) + +/** + * Profile-scoped request state: the user's own list, the mutation broadcasts, + * and [cache]. [reset] starts a new session; a read or mutation that began + * before it answers without writing anything back, so the previous profile's + * records never seed the next one's pages. + * + * Main-thread confined, like [RequestDetailCache]. + */ +class RequestsRepository( + private val api: RequestsApi, + val cache: RequestDetailCache = RequestDetailCache(), + private val timeSource: TimeSource = TimeSource.Monotonic, +) { private val _mine = MutableStateFlow<List<MediaRequest>>(emptyList()) val mine: StateFlow<List<MediaRequest>> = _mine.asStateFlow() + private var eventSequence = 0L + + /** Bumped by each of this client's own creates and cancels as they land. */ + private var ownMutations = 0L + + /** Bumped by [reset]: anything still running from the previous session drops its result. */ + @kotlin.concurrent.Volatile + private var generation = 0 + + /** When [mine] last held a complete, unfiltered read; null until one lands in this session. */ + private var mineReadAt: TimeMark? = null + + private val _lastUpdate = MutableStateFlow<RequestEvent?>(null) + /** The most recent create or cancel of the signed-in user's own request. */ + val lastUpdate: StateFlow<RequestEvent?> = _lastUpdate.asStateFlow() + + private val _lastModeration = MutableStateFlow<RequestEvent?>(null) + /** + * The most recent admin decision (approve, decline, retry) on anyone's + * request. Kept apart from [lastUpdate], whose consumers treat every record + * as the signed-in user's own. + */ + val lastModeration: StateFlow<RequestEvent?> = _lastModeration.asStateFlow() + suspend fun status(): ApiResult<RequestsFeatureStatus> = api.status() suspend fun discover(): ApiResult<RequestsDiscoverResponse> = api.discover() @@ -32,7 +80,7 @@ class RequestsRepository(private val api: RequestsApi) { ): ApiResult<RequestMediaPage> = api.search(query, mediaType, page) suspend fun detail(mediaType: String, tmdbId: Int): ApiResult<RequestMediaDetail> = - api.detail(mediaType, tmdbId) + read({ api.detail(mediaType, tmdbId) }) { cache.store(it) } suspend fun get(id: String): ApiResult<MediaRequest> = api.get(id) @@ -41,37 +89,123 @@ class RequestsRepository(private val api: RequestsApi) { outcome: String? = null, limit: Int? = null, offset: Int? = null, - ): ApiResult<Unit> = when (val result = api.mine(status, outcome, limit, offset)) { - is ApiResult.Success -> { - _mine.value = result.data.requests - ApiResult.Success(Unit) + ): ApiResult<Unit> { + val mutationsBefore = ownMutations + val result = read({ api.mine(status, outcome, limit, offset) }) { response -> + // A create or cancel that landed while this read was out isn't in + // it: keep the patched list, and let the next caller read again. + if (ownMutations != mutationsBefore) { + mineReadAt = null + return@read + } + _mine.value = response.requests + if (status == null && outcome == null) { + cache.storeOwnRecords(response.requests) + mineReadAt = timeSource.markNow() + } else { + mineReadAt = null + } + } + return when (result) { + is ApiResult.Success -> ApiResult.Success(Unit) + is ApiResult.Error -> result + is ApiResult.NetworkError -> result } - is ApiResult.Error -> ApiResult.Error(result.code, result.error, result.message) - is ApiResult.NetworkError -> ApiResult.NetworkError(result.exception) + } + + /** + * [mine] as of no more than [maxAge] ago, reading only when it's older. + * This client's own creates and cancels patch [mine] as they land, so a + * recent read stays current for them; anything that must see another + * device's change reads with a zero [maxAge]. + */ + suspend fun ensureMine(maxAge: Duration = MineFreshness): ApiResult<Unit> { + val readAt = mineReadAt + if (readAt != null && readAt.elapsedNow() < maxAge) return ApiResult.Success(Unit) + return refreshMine() } suspend fun create(request: CreateMediaRequest): ApiResult<MediaRequest> = - when (val result = api.create(request)) { - is ApiResult.Success -> { - upsertMine(result.data) - ApiResult.Success(result.data) - } - is ApiResult.Error -> ApiResult.Error(result.code, result.error, result.message) - is ApiResult.NetworkError -> ApiResult.NetworkError(result.exception) - } + mutate({ api.create(request) }, ::publish) suspend fun cancel(id: String): ApiResult<MediaRequest> = - when (val result = api.cancel(id)) { - is ApiResult.Success -> { - upsertMine(result.data) - ApiResult.Success(result.data) - } - is ApiResult.Error -> ApiResult.Error(result.code, result.error, result.message) - is ApiResult.NetworkError -> ApiResult.NetworkError(result.exception) + mutate({ api.cancel(id) }, ::publish) + + suspend fun adminCapabilities(): ApiResult<AdminRequestCapabilities> = api.adminCapabilities() + + suspend fun adminRequests( + status: String? = null, + outcome: String? = null, + mediaType: String? = null, + tmdbId: Int? = null, + ): ApiResult<List<MediaRequest>> = when (val result = read({ api.adminRequests(status, outcome, mediaType, tmdbId) })) { + is ApiResult.Success -> ApiResult.Success(result.data.requests) + is ApiResult.Error -> result + is ApiResult.NetworkError -> result + } + + /** How many requests match, up to one page, and whether more follow. */ + suspend fun adminRequestCount(status: String? = null, outcome: String? = null): ApiResult<AdminRequestCount> = + when (val result = read({ api.adminRequestsFirstPage(status, outcome) })) { + is ApiResult.Success -> ApiResult.Success(AdminRequestCount(result.data.requests.size, result.data.page.hasMore)) + is ApiResult.Error -> result + is ApiResult.NetworkError -> result } + suspend fun adminAction(id: String, action: AdminRequestAction, reason: String? = null): ApiResult<MediaRequest> = + mutate({ api.adminAction(id, action, reason) }, ::publishModeration) + + /** Warms the first rows' details so opening them lands on a finished page. */ + fun prefetch(records: List<MediaRequest>) { + cache.prefetch(records) { key -> api.detail(key.mediaType, key.tmdbId) } + } + fun reset() { + generation++ + mineReadAt = null _mine.value = emptyList() + _lastUpdate.value = null + _lastModeration.value = null + cache.clear() + } + + /** + * A read for the current session. When a [reset] lands while it runs, the + * answer belongs to the previous profile or server: nothing is stored and + * the caller gets a failure instead of the stale data. + */ + private suspend fun <T> read(call: suspend () -> ApiResult<T>, store: (T) -> Unit = {}): ApiResult<T> { + val session = generation + val result = call() + if (session != generation) return SessionChanged + if (result is ApiResult.Success) store(result.data) + return result + } + + /** + * A mutation for the current session. The server acted either way, so the + * caller gets the real result, but one that finishes after a [reset] isn't + * broadcast into the next session. + */ + private suspend fun <T> mutate(call: suspend () -> ApiResult<T>, publish: (T) -> Unit): ApiResult<T> { + val session = generation + val result = call() + if (session == generation && result is ApiResult.Success) publish(result.data) + return result + } + + private fun publish(request: MediaRequest) { + ownMutations++ + upsertMine(request) + cache.storeOwnRecord(request) + _lastUpdate.value = RequestEvent(request, ++eventSequence) + } + + private fun publishModeration(request: MediaRequest) { + cache.unpinModeration(request) + // An admin deciding on their own request: their own lists update too. + if (cache.ownRecord(request.cacheKey())?.id == request.id) publish(request) + _lastModeration.value = RequestEvent(request, ++eventSequence) } private fun upsertMine(request: MediaRequest) { @@ -80,4 +214,14 @@ class RequestsRepository(private val api: RequestsApi) { if (replaced.any { it.id == request.id }) replaced else replaced + request } } + + companion object { + /** How long a complete read of [mine] answers for a page that only needs one title's record. */ + val MineFreshness: Duration = 30.seconds + + private val SessionChanged = ApiResult.Error(0, "identity_changed", "The acting account or profile changed.") + } } + +/** A count of admin requests, read one page deep: [hasMore] means there are more than [count]. */ +data class AdminRequestCount(val count: Int, val hasMore: Boolean) diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/SectionRepository.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/SectionRepository.kt index 760fe65ce0..8ec1e0cb78 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/SectionRepository.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/SectionRepository.kt @@ -14,8 +14,15 @@ import org.prairieserver.prairie.network.map import org.prairieserver.prairie.repository.port.CatalogCachePort import org.prairieserver.prairie.repository.port.NoOpCatalogCachePort import org.prairieserver.prairie.repository.port.canServeCache +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.isActive +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext class SectionRepository( private val sectionApi: SectionApi, @@ -25,7 +32,42 @@ class SectionRepository( suspend fun captureHomeAuthority() = sectionApi.captureHomeAuthority() suspend fun isHomeAuthorityCurrent(owner: org.prairieserver.prairie.network.AuthScopeSnapshot) = sectionApi.isHomeAuthorityCurrent(owner) - suspend fun getHomeSections(owner: org.prairieserver.prairie.network.AuthScopeSnapshot) = sectionApi.getHomeSections(owner) + private val homeReadLock = Mutex() + private var homeRead: Pair<org.prairieserver.prairie.network.AuthScopeSnapshot, CompletableDeferred<ApiResult<SectionsResponse>>>? = null + + suspend fun getHomeSections(owner: org.prairieserver.prairie.network.AuthScopeSnapshot): ApiResult<SectionsResponse> { + val read = CompletableDeferred<ApiResult<SectionsResponse>>() + homeReadLock.withLock { homeRead = owner to read } + try { + return sectionApi.getHomeSections(owner).also { read.complete(it) } + } catch (e: Throwable) { + read.completeExceptionally(e) + throw e + } finally { + withContext(NonCancellable) { homeReadLock.withLock { if (homeRead?.second === read) homeRead = null } } + } + } + + /** + * Home's first load: joins a read for the same [owner] that is already + * running instead of starting another. At launch the startup warmup starts + * one, and with 100-item rows each read downloads and decodes over 1 MB. + * Refreshes use [getHomeSections], which never joins: they must see changes + * made after an earlier read started. + */ + suspend fun getHomeSectionsJoiningRead(owner: org.prairieserver.prairie.network.AuthScopeSnapshot): ApiResult<SectionsResponse> { + val running = homeReadLock.withLock { homeRead?.takeIf { it.first == owner }?.second } + if (running != null) { + try { + return running.await() + } catch (e: CancellationException) { + // The read we joined was cancelled with its caller; read for ourselves. + currentCoroutineContext().ensureActive() + } + } + return getHomeSections(owner) + } + suspend fun getHomeSectionItems(id: String, owner: org.prairieserver.prairie.network.AuthScopeSnapshot) = sectionApi.getHomeSectionItems(id, owner) suspend fun dismissHomeItem(surface: String, itemId: String, anchor: String, owner: org.prairieserver.prairie.network.AuthScopeSnapshot) = @@ -69,6 +111,10 @@ class SectionRepository( ): ApiResult<HomeSectionItemsResponse> = sectionApi.getLibrarySectionItems(libraryId, sectionId, owner) + suspend fun getLibrarySectionCatalogItems(libraryId: Int, sectionId: String, + owner: org.prairieserver.prairie.network.AuthScopeSnapshot, continuation: CatalogContinuationV2?) = + sectionApi.getLibrarySectionCatalogItems(libraryId, sectionId, owner, continuation) + /** Lists collections within a library as a flat list. Callers that need * the grouped layout should use [getLibraryCollectionsGrouped]. */ suspend fun getLibraryCollections(libraryId: Int): ApiResult<List<LibraryCollection>> = @@ -88,6 +134,9 @@ class SectionRepository( order: String? = null, queryGroups: List<CatalogQueryGroup> = emptyList(), match: String? = null, + mediaType: String? = null, + libraryId: Int? = null, + source: String = "library_collection", ): ApiResult<CatalogResponse> = sectionApi.getLibraryCollectionItems( collectionId = collectionId, @@ -97,5 +146,8 @@ class SectionRepository( order = order, queryGroups = queryGroups, match = match, + mediaType = mediaType, + libraryId = libraryId, + source = source, ) } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/SequencedPlayback.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/SequencedPlayback.kt index a8c39c1467..44d6f9798e 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/SequencedPlayback.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/SequencedPlayback.kt @@ -2,8 +2,6 @@ package org.prairieserver.prairie.repository import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.serialization.Serializable @@ -59,8 +57,18 @@ class SequencedPlayback( private companion object { /** Settled attempts retained as tombstones. Bounds the journal's size. */ const val SETTLED_TOMBSTONE_LIMIT = 8 + + /** Login marker for attempts admitted under a temporary remote-playback identity. */ + const val TEMPORARY_LOGIN_PREFIX = "temporary:" } + /** + * Admitted under a phone's temporary remote-playback identity. Those + * credentials die with the process, so a restart could never replay the + * attempt: it stays in memory, out of the durable journal and recovery. + */ + private val PlaybackJournalEntry.isTemporary: Boolean get() = loginId.startsWith(TEMPORARY_LOGIN_PREFIX) + private val mutex = Mutex() private var entries: List<PlaybackJournalEntry>? = null private val adopted = mutableSetOf<String>() @@ -70,10 +78,8 @@ class SequencedPlayback( @kotlin.concurrent.Volatile private var liveAttempts: Set<String> = emptySet() private val auxiliaryHeaders = mutableMapOf<String, ProxyAuxiliaryRequestHeaders>() private val scopes = mutableMapOf<String, AuthScopeSnapshot>() - private val _pending = MutableStateFlow<List<String>>(emptyList()) - val pending = _pending.asStateFlow() - private val _sessions = MutableStateFlow<Set<String>>(emptySet()) - fun owns(sessionId: String): Boolean = sessionId in _sessions.value + @kotlin.concurrent.Volatile private var sessions: Set<String> = emptySet() + fun owns(sessionId: String): Boolean = sessionId in sessions private suspend fun load(): List<PlaybackJournalEntry> { if (entries == null) { entries = store.read(); publish() } @@ -84,18 +90,31 @@ class SequencedPlayback( private fun publish() { liveAttempts = entries.orEmpty().filter { !it.terminal && it.stop == null }.map { it.attemptId }.toSet() - _sessions.value = entries.orEmpty().mapNotNull { it.sessionId }.toSet() - _pending.value = entries.orEmpty().filter { it.needsRecovery() } - .map { it.attemptId } + sessions = entries.orEmpty().mapNotNull { it.sessionId }.toSet() } private suspend fun save(entry: PlaybackJournalEntry) { - val next = compactSettled(load().filterNot { it.attemptId == entry.attemptId } + entry) - store.write(next) - entries = next + val (temporary, durable) = (load().filterNot { it.attemptId == entry.attemptId } + entry).partition { it.isTemporary } + // Compacted apart, so titles a phone launched never crowd durable tombstones out. + val kept = compactSettled(durable) + if (!entry.isTemporary) store.write(kept) + // Only the identity that admitted a temporary attempt may act for it, so an ended + // identity's attempts can never settle; drop them rather than keep them for the + // life of the process, along with the scope and subtitle headers they captured. + val liveTemporary = tokens.snapshotCurrentScope()?.credentialGenerationId?.let { TEMPORARY_LOGIN_PREFIX + it } + val (current, ended) = temporary.partition { it.loginId == liveTemporary } + entries = kept + compactSettled(current) + ended.forEach { forget(it.attemptId) } if (entry.terminal || entry.stop != null) auxiliaryHeaders.remove(entry.attemptId) publish() } + private fun forget(attemptId: String) { + scopes.remove(attemptId) + adopted.remove(attemptId) + auxiliaryHeaders.remove(attemptId) + auxiliaryGenerations = auxiliaryGenerations - attemptId + } + /** * A settled attempt keeps a tombstone, not its history. * @@ -129,8 +148,15 @@ class SequencedPlayback( private fun failure(code: String, message: String) = ApiResult.Error(0, code, message) private fun authorityChanged() = failure("identity_changed", "Playback authority changed.") private suspend fun scope(entry: PlaybackJournalEntry): AuthScopeSnapshot? { - val live = authorities.snapshotDurableLoginAuthority() ?: return null val captured = scopes[entry.attemptId] ?: return null // Restart requires explicit recovery. + if (entry.isTemporary) { + // Only the temporary identity that admitted the attempt may act for it. + return tokens.snapshotCurrentScope()?.takeIf { + captured.isSameIdentityAs(it) && it.credentialGenerationId == captured.credentialGenerationId && + it.serverId == entry.serverId && it.serverUrl == entry.origin && it.profileId == entry.profileId + } + } + val live = authorities.snapshotDurableLoginAuthority() ?: return null return live.scope.takeIf { live.loginId == entry.loginId && it.serverId == entry.serverId && it.serverUrl == entry.origin && it.profileId == entry.profileId && @@ -161,7 +187,7 @@ class SequencedPlayback( withStableIdentity(null, expectedMetadataOwner, request.profileId) { guard -> val current = tokens.snapshotCurrentScope() ?: return@withStableIdentity failure("identity_unavailable", "Playback needs an authenticated profile.") - // Probe availability before admission; retained intents still fence new attempts. + // Probe availability before admission. val capabilityResult = api.capabilities(current) guard() val capability = when (capabilityResult) { @@ -172,31 +198,18 @@ class SequencedPlayback( if (!current.isSameIdentityAs(tokens.snapshotCurrentScope())) throw IdentityChanged() val live = authorities.snapshotDurableLoginAuthority() guard() - suspend fun unresolved() = load().any { it.needsRecovery() && it.loginId == live?.loginId && - it.serverId == current.serverId && it.profileId == current.profileId } - guard() - if (unresolved()) { - // Settle the earlier attempt first (replay an uncertain start, then stop it) so a - // crashed or rejected session never needs a manual recovery step before playing again. - val settled = try { recoverLocked() } - catch (e: CancellationException) { throw e } - catch (e: IdentityChanged) { throw e } - catch (_: Exception) { failure("playback_storage", "Playback recovery storage is unavailable.") } - guard() - if (unresolved()) return@withStableIdentity when (settled) { - is ApiResult.Success -> failure("playback_pending", "A previous playback request needs recovery before starting again.") - is ApiResult.Error -> settled - is ApiResult.NetworkError -> settled - } - } if (capability == null) return@withStableIdentity failure("server_update_required", "Update the server to use v2 playback.") if (SEQUENCED_PROGRESS_FEATURE !in capability.features) return@withStableIdentity failure("playback_unavailable", "V2 playback is unavailable on this server.") if (!capability.allowed || capability.state != PlaybackCapabilityStateV2.AVAILABLE || capability.installationId.isNullOrBlank() || 3 !in capability.protocolVersions) return@withStableIdentity failure("playback_unavailable", "Sequenced playback is unavailable for this profile.") - if (live == null || !current.isSameIdentityAs(live.scope) || live.scope.credentialGenerationId != null || - current.profileId != request.profileId) - return@withStableIdentity failure("identity_unavailable", "Sequenced playback needs a saved login and matching profile.") + val loginId = when { + current.profileId != request.profileId -> null + // A phone launched this title on the TV under its own profile. + current.credentialGenerationId != null -> TEMPORARY_LOGIN_PREFIX + current.credentialGenerationId + live != null && current.isSameIdentityAs(live.scope) && live.scope.credentialGenerationId == null -> live.loginId + else -> null + } ?: return@withStableIdentity failure("identity_unavailable", "Sequenced playback needs a saved login and matching profile.") val account = when (val result = api.account(current)) { is ApiResult.Success -> result.data is ApiResult.Error -> return@withStableIdentity result @@ -204,13 +217,21 @@ class SequencedPlayback( } guard() if (!current.isSameIdentityAs(tokens.snapshotCurrentScope())) throw IdentityChanged() + // Stop what earlier attempts left open, but never make this one wait on them: the + // server expires a session nobody stops, so a stop that fails now is retried next time. + try { + if (current.credentialGenerationId != null) settleTemporary(current, loginId) + else live?.let { settleDurable(it, account.id, capability.installationId) } + } catch (e: CancellationException) { throw e } catch (e: IdentityChanged) { throw e } catch (_: Exception) {} + guard() if (load().any { it.attemptId == request.playbackAttemptId }) - return@withStableIdentity failure("attempt_exists", "This playback attempt is already recorded. Use recovery.") + return@withStableIdentity failure("attempt_exists", "This playback attempt is already recorded.") guard() - val entry = PlaybackJournalEntry(current.serverId, current.serverUrl, live.loginId, account.id, + val entry = PlaybackJournalEntry(current.serverId, current.serverUrl, loginId, account.id, request.profileId, capability.installationId, request.playbackAttemptId, request.v2Body(capability.installationId)) save(entry) - scopes[entry.attemptId] = current + // Unless save() just pruned it: its temporary identity ended in between. + if (load().any { it.attemptId == entry.attemptId }) scopes[entry.attemptId] = current // A saved attempt is uncertainty, even if its owner changes before send. guard() sendStart(entry, current, expectedMetadataOwner = expectedMetadataOwner) @@ -218,7 +239,7 @@ class SequencedPlayback( } private suspend fun sendStart(entry: PlaybackJournalEntry, captured: AuthScopeSnapshot, - adoptForPlayer: Boolean = true, expectedMetadataOwner: AuthScopeSnapshot? = null): ApiResult<PlaybackDecisionResponseV3> = + expectedMetadataOwner: AuthScopeSnapshot?): ApiResult<PlaybackDecisionResponseV3> = withStableIdentity(entry, expectedMetadataOwner, entry.profileId) { guard -> var sentHeaders: Map<String, String> = emptyMap() when (val result = api.start(captured, entry.start) { sentHeaders = it }) { @@ -244,8 +265,8 @@ class SequencedPlayback( // the decision's server_features lists only protocol-v3 plan features. val decision = decodePlaybackDecisionV2(result.data) guard() - val ready = if (adoptForPlayer) withAuxiliaryAuthority(decision, entry, captured, sentHeaders) else decision - if (adoptForPlayer) adopted += entry.attemptId + val ready = withAuxiliaryAuthority(decision, entry, captured, sentHeaders) + adopted += entry.attemptId publish() ApiResult.Success(ready) } catch (e: IdentityChanged) { throw e } catch (e: Exception) { ApiResult.NetworkError(e) } @@ -273,9 +294,12 @@ class SequencedPlayback( require(plan.sessionId == sessionId) val ephemeral = ProxyAuxiliaryRequestHeaders(plan.stream.url, sessionId, references, headers) { // Lock-free: a playback request in flight under the mutex must not stall subtitle loads. - val live = authorities.snapshotDurableLoginAuthority() - live?.loginId == entry.loginId && live.scope == captured && - auxiliaryGenerations[entry.attemptId] == generation && entry.attemptId in liveAttempts + val current = if (entry.isTemporary) { + tokens.snapshotCurrentScope() + } else { + authorities.snapshotDurableLoginAuthority()?.takeIf { it.loginId == entry.loginId }?.scope + } + current == captured && auxiliaryGenerations[entry.attemptId] == generation && entry.attemptId in liveAttempts } auxiliaryHeaders[entry.attemptId] = ephemeral return decision.copy(playbackPlan = plan.copy(stream = plan.stream.copy(auxiliaryRequestHeaders = ephemeral))) @@ -376,7 +400,8 @@ class SequencedPlayback( if (entry.progress != null) { val retry = sendProgress(entry, captured) if (retry !is ApiResult.Success) return@withLock retry - entry = load().first { it.attemptId == entry.attemptId } + // Gone when the retry's save() pruned it: its temporary identity ended in flight. + entry = load().find { it.attemptId == entry.attemptId } ?: return@withLock authorityChanged() } if (entry.sequence == Long.MAX_VALUE) return@withLock failure("sequence_exhausted", "Playback sample sequence exhausted.") val sample = PlaybackProgressV2(entry.installationId, entry.sequence + 1, position, paused) @@ -405,7 +430,7 @@ class SequencedPlayback( val entry = load().find { it.sessionId == sessionId } ?: return@withLock null stopEntry(entry) } - private suspend fun stopEntry(original: PlaybackJournalEntry): ApiResult<Unit> { + private suspend fun stopEntry(original: PlaybackJournalEntry, attempts: Int = 3): ApiResult<Unit> { if (original.terminal) return ApiResult.Success(Unit) var entry = original if (entry.stop == null) { @@ -415,7 +440,7 @@ class SequencedPlayback( } fun stopPending() = failure("identity_changed", "Playback authority changed; stop remains pending.") val captured = scope(entry) ?: return stopPending() - repeat(3) { attempt -> + repeat(attempts) { attempt -> if (scope(entry) == null) return stopPending() when (val result = api.stop(captured, requireNotNull(entry.sessionId), requireNotNull(entry.stop))) { is ApiResult.Success -> { @@ -429,53 +454,55 @@ class SequencedPlayback( } else if (result.code != 503) return result is ApiResult.NetworkError -> Unit } - if (attempt < 2) delay(250L * (attempt + 1)) + if (attempt < attempts - 1) delay(250L * (attempt + 1)) } - return failure("stop_pending", "Playback stop is pending. Retry from playback recovery.") - } - - suspend fun pendingForCurrentViewer(): Int = mutex.withLock { - val live = authorities.snapshotDurableLoginAuthority() ?: return@withLock 0 - load().count { it.needsRecovery() && - it.loginId == live.loginId && it.serverId == live.scope.serverId && - it.origin == live.scope.serverUrl && it.profileId == live.scope.profileId } + return failure("stop_pending", "Playback stop is pending. It is retried before the next playback starts.") } - /** Explicit recovery revalidates installation, canonical account, saved login and profile. Never autoplay. */ - suspend fun recover(): ApiResult<Unit> = mutex.withLock { recoverLocked() } - - private suspend fun recoverLocked(): ApiResult<Unit> { - val live = authorities.snapshotDurableLoginAuthority() - ?: return failure("identity_unavailable", "Sign in to recover playback.") - val capability = when (val result = api.capabilities(live.scope)) { - is ApiResult.Success -> result.data - is ApiResult.Error -> return result - is ApiResult.NetworkError -> return result - } - val account = when (val result = api.account(live.scope)) { - is ApiResult.Success -> result.data - is ApiResult.Error -> return result - is ApiResult.NetworkError -> return result - } - if (live != authorities.snapshotDurableLoginAuthority()) return failure("identity_changed", "The active viewer changed.") - for (entry in load().filter { it.needsRecovery() }) { - if (entry.loginId != live.loginId || entry.serverId != live.scope.serverId || entry.origin != live.scope.serverUrl || - entry.profileId != live.scope.profileId || entry.accountId != account.id || entry.installationId != capability.installationId) continue + /** + * Stops what this viewer's earlier attempts left open: a stop that never got through, a + * replan whose outcome was lost, or a session from before a restart. Attempts recorded + * under another address, account or installation of this server can never be acted on + * again, so they are dropped; the server expires their sessions. + * + * This runs on every start, so it is bounded: each stop gets one attempt, and the first + * that fails ends this pass. The rest wait for the next start rather than delay this one. + */ + private suspend fun settleDurable(live: DurableLoginAuthority, accountId: String, installationId: String) { + for (entry in load().filter { it.needsRecovery() && it.loginId == live.loginId && + it.serverId == live.scope.serverId && it.profileId == live.scope.profileId }) { + if (entry.origin != live.scope.serverUrl || entry.accountId != accountId || entry.installationId != installationId) { + save(entry.copy(terminal = true, progress = null)) + continue + } // Existing process attempts remain fenced after any identity transition. val oldScope = scopes[entry.attemptId] if (oldScope != null && !oldScope.isSameIdentityAs(live.scope)) continue scopes[entry.attemptId] = live.scope - if (entry.sessionId == null) { - when (val startResult = sendStart(entry, live.scope, adoptForPlayer = false)) { - is ApiResult.Success -> Unit - is ApiResult.Error -> return startResult - is ApiResult.NetworkError -> return startResult - } - } - val result = stopEntry(load().first { it.attemptId == entry.attemptId }) - if (result !is ApiResult.Success) return result + if (!settle(entry)) return + } + } + + /** [settleDurable] for a temporary remote-playback identity: only the attempts it admitted itself. */ + private suspend fun settleTemporary(current: AuthScopeSnapshot, loginId: String) { + for (entry in load().filter { it.needsRecovery() && it.loginId == loginId && + it.serverId == current.serverId && it.profileId == current.profileId }) { + if (scope(entry) != null && !settle(entry)) return + } + } + + /** + * Stops [entry]'s session. A start whose reply was lost has no session id to stop, and + * replaying it would open a session the server may never have created, so it is dropped + * and left for the server to expire. A stop that fails stays journaled for next time. + * Returns whether [entry] is settled. + */ + private suspend fun settle(entry: PlaybackJournalEntry): Boolean { + if (entry.sessionId == null) { + save(entry.copy(terminal = true, progress = null)) + return true } - return ApiResult.Success(Unit) + return stopEntry(entry, attempts = 1) is ApiResult.Success } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/ServerIdentityRepository.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/ServerIdentityRepository.kt new file mode 100644 index 0000000000..e787a2c4f7 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/ServerIdentityRepository.kt @@ -0,0 +1,71 @@ +package org.prairieserver.prairie.repository + +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import org.prairieserver.prairie.model.server.ServerEntry +import org.prairieserver.prairie.network.ServerRegistry +import org.prairieserver.prairie.network.api.ServerIdentityApi +import org.prairieserver.prairie.network.api.ServerIdentityProbe + +/** + * Deployment identities of saved servers. The id comes from + * `GET /api/v2/system/identity` at the saved URL and is recorded on the entry + * ([ServerEntry.verifiedServerId]) so later matches don't need the network. + * + * Used to match a TV's `srv` advertisement and a `prairie://device?server=` link + * to a saved server by identity rather than URL spelling: one deployment has + * several addresses, and two deployments can reuse an address over time. + */ +class ServerIdentityRepository( + private val registry: ServerRegistry, + private val api: ServerIdentityApi, +) { + /** + * The identity verified at [entry]'s URL. Probes when none is recorded or + * [refresh] is set; a probe that can't reach the server keeps the recorded + * value, and one that reaches a server without the identity contract + * clears it. + */ + suspend fun identityOf(entry: ServerEntry, refresh: Boolean = false): String? { + val recorded = entry.verifiedServerId?.takeIf { it.isNotBlank() } + if (recorded != null && !refresh) return recorded + return when (val probe = api.probeIdentity(entry.url)) { + is ServerIdentityProbe.Identity -> { + if (probe.serverId != recorded) registry.setVerifiedServerId(entry.id, probe.serverId) + probe.serverId + } + ServerIdentityProbe.UnsupportedServer -> { + if (recorded != null) registry.setVerifiedServerId(entry.id, null) + null + } + ServerIdentityProbe.Unreachable -> recorded + } + } + + /** + * The entries among [candidates] whose identity is [serverId]. Entries + * with a recorded identity are matched without the network; the others + * are probed (in parallel) only when no recorded identity matched. + */ + suspend fun entriesFor( + serverId: String, + candidates: List<ServerEntry> = registry.entries.value, + ): List<ServerEntry> { + val wanted = serverId.trim().takeIf { it.isNotEmpty() } ?: return emptyList() + val recorded = candidates.filter { it.verifiedServerId == wanted } + if (recorded.isNotEmpty()) return recorded + val unverified = candidates.filter { it.verifiedServerId.isNullOrBlank() && it.url.isNotBlank() } + if (unverified.isEmpty()) return emptyList() + return coroutineScope { + unverified + .map { entry -> async { entry to identityOf(entry) } } + .awaitAll() + .filter { (_, id) -> id == wanted } + .map { (entry, _) -> entry } + } + } + + /** Probe an address that isn't saved (a link's `url`, a pushed server). */ + suspend fun probe(serverUrl: String): ServerIdentityProbe = api.probeIdentity(serverUrl) +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/SettingsRepository.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/SettingsRepository.kt index ede78c9b3f..098e7dc2ef 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/SettingsRepository.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/SettingsRepository.kt @@ -40,13 +40,25 @@ class SettingsRepository( * preference that applies on every device until a device overrides it. * * Picker callers surface failure and let the user make a new decision. + * + * [authority] pins the request to one captured profile (as the settings + * flusher does); a caller making several requests for one change passes + * the same snapshot to each so none can land on a profile switched to + * in between. */ - suspend fun setProfileValue(key: String, value: JsonElement): ApiResult<StoredSettingValue> = - settingsApi.putValue(key, SettingScopeIdentity.profile(), value) + suspend fun setProfileValue( + key: String, + value: JsonElement, + authority: org.prairieserver.prairie.network.AuthScopeSnapshot? = null, + ): ApiResult<StoredSettingValue> = + settingsApi.putValue(key, SettingScopeIdentity.profile(), value, authority = authority) /** Clear the profile-scoped value so the setting inherits again. */ - suspend fun clearProfileValue(key: String): ApiResult<Unit> = - treatMissingAsCleared(settingsApi.deleteValue(key, SettingScopeIdentity.profile())) + suspend fun clearProfileValue( + key: String, + authority: org.prairieserver.prairie.network.AuthScopeSnapshot? = null, + ): ApiResult<Unit> = + treatMissingAsCleared(settingsApi.deleteValue(key, SettingScopeIdentity.profile(), authority = authority)) /** * Write one like-client value (`scope=profile_client`) — the preference @@ -65,8 +77,12 @@ class SettingsRepository( * on this device only. The device half of the identity rides the * `X-Prairie-Device-Id` header. */ - suspend fun setProfileDeviceValue(key: String, value: JsonElement): ApiResult<StoredSettingValue> = - settingsApi.putValue(key, SettingScopeIdentity.profileDevice(), value) + suspend fun setProfileDeviceValue( + key: String, + value: JsonElement, + authority: org.prairieserver.prairie.network.AuthScopeSnapshot? = null, + ): ApiResult<StoredSettingValue> = + settingsApi.putValue(key, SettingScopeIdentity.profileDevice(), value, authority = authority) suspend fun setMigrationDeviceValue(key: String, value: JsonElement, authority: org.prairieserver.prairie.network.AuthScopeSnapshot): ApiResult<StoredSettingValue> = settingsApi.putValue(key, SettingScopeIdentity.profileDevice(), value, authority.profileId, authority) diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/SubtitlesRepository.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/SubtitlesRepository.kt index bd15480f6e..ce7767e0db 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/SubtitlesRepository.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/SubtitlesRepository.kt @@ -17,6 +17,10 @@ import org.prairieserver.prairie.model.subtitles.SubtitleSearchResponse import org.prairieserver.prairie.model.subtitles.SubtitleTranslateRequest import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.api.SubtitlesApi +import org.prairieserver.prairie.network.apiv2.SubtitleSyncV2Api +import org.prairieserver.prairie.model.subtitles.SubtitleSyncCapability +import org.prairieserver.prairie.model.subtitles.SubtitleSyncState +import org.prairieserver.prairie.model.subtitles.SubtitleTiming import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay @@ -32,7 +36,20 @@ import kotlinx.coroutines.delay * - rethrows [CancellationException] so callers can cancel via structured * concurrency (player exit cancels the viewModelScope job) */ -class SubtitlesRepository(private val api: SubtitlesApi, private val tokens: org.prairieserver.prairie.network.TokenManager? = null) { +/** The subtitle sync operations the player needs, by media file and sync key. */ +interface SubtitleSyncSource { + suspend fun syncCapability(): ApiResult<SubtitleSyncCapability> + suspend fun listSync(mediaFileId: Int): ApiResult<List<SubtitleSyncState>> + suspend fun readSync(mediaFileId: Int, key: String): ApiResult<SubtitleSyncState> + suspend fun startSync(mediaFileId: Int, key: String): ApiResult<SubtitleSyncState> + suspend fun resetTiming(mediaFileId: Int, key: String): ApiResult<SubtitleSyncState> +} + +class SubtitlesRepository( + private val api: SubtitlesApi, + private val tokens: org.prairieserver.prairie.network.TokenManager? = null, + private val sync: SubtitleSyncV2Api? = null, +) : SubtitleSyncSource { /** Terminal result of [pollJob]. */ sealed class SubtitleJobOutcome { @@ -52,6 +69,22 @@ class SubtitlesRepository(private val api: SubtitlesApi, private val tokens: org suspend fun list(mediaFileId: Int): ApiResult<DownloadedSubtitlesResponse> = api.list(mediaFileId) + // ---- Subtitle sync (stored subtitles and sidecars, by sync key) ---- + + override suspend fun syncCapability(): ApiResult<SubtitleSyncCapability> = sync?.status() ?: syncUnavailable + + override suspend fun listSync(mediaFileId: Int): ApiResult<List<SubtitleSyncState>> = + sync?.list(mediaFileId) ?: syncUnavailable + + override suspend fun readSync(mediaFileId: Int, key: String): ApiResult<SubtitleSyncState> = + sync?.read(mediaFileId, key) ?: syncUnavailable + + override suspend fun startSync(mediaFileId: Int, key: String): ApiResult<SubtitleSyncState> = + sync?.start(mediaFileId, key) ?: syncUnavailable + + override suspend fun resetTiming(mediaFileId: Int, key: String): ApiResult<SubtitleSyncState> = + sync?.setTiming(mediaFileId, key, SubtitleTiming()) ?: syncUnavailable + suspend fun aiStatus(): ApiResult<SubtitleAiStatus> = api.aiStatus() suspend fun aiQuota(): ApiResult<SubtitleAiQuota> = api.aiQuota() @@ -125,3 +158,5 @@ class SubtitlesRepository(private val api: SubtitlesApi, private val tokens: org } } } + +private val syncUnavailable = ApiResult.Error(0, "sync_unavailable", "Subtitle sync is not available.") diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepository.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepository.kt index 55ba3488b7..71dc233e95 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepository.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepository.kt @@ -1,31 +1,54 @@ package org.prairieserver.prairie.repository -import org.prairieserver.prairie.RoomSyncEngine import org.prairieserver.prairie.model.watchtogether.AddSuggestionRequest import org.prairieserver.prairie.model.watchtogether.CreateRoomRequest import org.prairieserver.prairie.model.watchtogether.JoinRoomRequest +import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.model.watchtogether.MemberStateRequest +import org.prairieserver.prairie.model.watchtogether.MemberStateResponse +import org.prairieserver.prairie.model.watchtogether.PickerResponse import org.prairieserver.prairie.model.watchtogether.PromoteSuggestionRequest +import org.prairieserver.prairie.model.watchtogether.RoomPhase import org.prairieserver.prairie.model.watchtogether.RoomResponse +import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.SelectionModeRequest import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest +import org.prairieserver.prairie.model.watchtogether.SourceFallbackReason +import org.prairieserver.prairie.model.watchtogether.SourceFallbackRequest import org.prairieserver.prairie.model.watchtogether.Suggestion -import org.prairieserver.prairie.model.watchtogether.SuggestionsResponse +import org.prairieserver.prairie.model.watchtogether.SuggestionReceipt import org.prairieserver.prairie.model.watchtogether.TransportCommand import org.prairieserver.prairie.model.watchtogether.UpdatePolicyRequest +import org.prairieserver.prairie.model.watchtogether.GuestControlPolicy import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.AuthScopeSnapshot import org.prairieserver.prairie.network.RoomRealtimeEvent +import org.prairieserver.prairie.network.RoomTicketExpiringException +import org.prairieserver.prairie.network.RoomTicketRefusedException import org.prairieserver.prairie.network.WatchTogetherRealtimeClient import org.prairieserver.prairie.network.api.WatchTogetherApi +import org.prairieserver.prairie.util.formatEpochMillisRfc3339 import org.prairieserver.prairie.util.parseRfc3339ToEpochMillis +import org.prairieserver.prairie.util.wallClockMillis +import org.prairieserver.prairie.watchtogether.RoomClockEstimate +import org.prairieserver.prairie.watchtogether.RoomClockEstimator import org.prairieserver.prairie.watchtogether.RoomDeliveryEcho import org.prairieserver.prairie.watchtogether.RoomDeliveryLatch -import org.prairieserver.prairie.watchtogether.WatchTogetherEntryGateway +import org.prairieserver.prairie.watchtogether.RoomPlaybackRoom +import org.prairieserver.prairie.watchtogether.RoomProof import org.prairieserver.prairie.watchtogether.RoomSessionRepository import org.prairieserver.prairie.watchtogether.RoomTransportIntent +import org.prairieserver.prairie.watchtogether.rankSuggestions +import org.prairieserver.prairie.watchtogether.roomProofExpiryMs import org.prairieserver.prairie.watchtogether.roomTransportAuthorized +import kotlin.concurrent.Volatile import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -33,41 +56,35 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlin.random.Random import kotlin.time.TimeSource /** * A transport command paired with its `execute_at` already parsed to a * server-epoch millisecond value, so the player binding never has to touch the * RFC3339Nano wire string. [executeAtMs] is null when the wire timestamp is - * malformed (the binding should then apply immediately / fall back). + * malformed. [receivedAtMs] is the monotonic receipt time. */ data class ScheduledTransportCommand( val command: TransportCommand, val executeAtMs: Long?, val connection: WatchTogetherConnectionState, + val receivedAtMs: Long = 0L, ) /** - * A pong frame with its three server-clock RFC3339Nano timestamps parsed to - * epoch millis. The fourth NTP sample value, `clientReceivedMs`, is NOT here: - * it must be stamped by the player binding at the instant it receives the pong - * (the shared module has no wall clock — `Date.now()`/`System.currentTimeMillis` - * are platform APIs), and supplied when the binding calls - * `RoomSyncEngine.recordPongSample(clientSentMs, serverReceivedMs, serverSentMs, clientReceivedMs)`. - * Any field is null when its wire timestamp was malformed. + * [disconnectedAtMs] is the monotonic time the room socket stopped being + * writable, so the UI can wait before warning about a routine reconnect. */ -data class PongSample( - val clientSentMs: Long?, - val serverReceivedMs: Long?, - val serverSentMs: Long?, -) - data class WatchTogetherConnectionState( val generation: Long = 0L, val epoch: Long = 0L, val writable: Boolean = false, + val disconnectedAtMs: Long? = null, ) /** Immutable, atomically-published authority for one room + physical socket. */ @@ -79,101 +96,172 @@ class RoomTransportAuthorization internal constructor( val snapshot: RoomSnapshot, ) +/** Why this device's Watch Party engagement ended. */ +object WatchPartyEndReason { + /** The host ended the party, or the host-disconnect grace ran out. The server does not say which. */ + const val HostLeft = "host_left" + const val NotFound = "not_found" + const val Ended = "ended" + /** This profile joined the party on another device. The room keeps going. */ + const val Replaced = "connection_replaced" + const val ConnectionLost = "connection_lost" + const val Unauthorized = "unauthorized" +} + +/** The last engagement that ended on this device, kept so the UI can explain it and offer Rejoin. */ +data class WatchPartyEnded( + val roomId: String, + val code: String, + val reason: String, + val wasHost: Boolean, +) + +/** A room or vote mutation in flight. One runs at a time so repeated taps cannot reorder work. */ +enum class WatchPartyPendingAction { + Create, Join, Stage, Start, Stop, End, Mode, Policy, Select, Promote, Suggest, RemoveSuggestion, Vote, Fallback, +} + +/** Client timing choices; the web client's values unless evidence says otherwise. */ +data class WatchPartyTiming( + val pingIntervalMs: Long = 15_000L, + val initialPings: Int = 3, + val initialPingSpacingMs: Long = 1_000L, + /** Renew the room proof this long before it expires: a socket lifetime plus a ticket. */ + val proofRenewalMarginMs: Long = 6 * 60_000L, + val proofUnknownRenewalMs: Long = 60 * 60_000L, + val proofRetryMs: Long = 30_000L, + val stableConnectionMs: Long = 30_000L, + /** Keep reconnecting at least this long after the last healthy socket; covers the host grace. */ + val reconnectBudgetMs: Long = 150_000L, + val maxReconnectFailures: Int = 6, + val backoffMs: List<Long> = listOf(500L, 1_000L, 2_000L, 5_000L), + val expiringTicketRetryMs: Long = 1_500L, + /** Test seam: background pings and proof renewal off. */ + val backgroundWork: Boolean = true, +) + /** - * Singleton owner of one Watch Together room's state and websocket lifecycle. - * The per-room WS IS the feature (no REST fallback); the REST calls are for - * create/join + host management + suggestion mutations. + * The single owner of this device's Watch Party engagement: membership, room + * proof, the room socket, the server clock estimate, snapshot ordering, + * suggestions, and room actions. The per-room socket IS the feature (no REST + * fallback); REST carries create/join, host management, reads, and suggestion + * mutations. * - * Holds the **room JWT** internally after create/join so every room-scoped op - * passes it transparently. Folds snapshot/suggestions WS events into - * [roomSnapshot]/[suggestions] StateFlows, re-merging `voted_by_me` (forced - * false in broadcasts) from a locally-tracked vote set. Exposes the - * sync-relevant client→server send passthroughs and a [transportCommands] flow - * the player binding feeds to its [RoomSyncEngine] (the engine needs the - * player's local position/playing/clock, which live in the binding, not here). + * Identities are kept apart. A membership ([RoomBinding]) is the room, the + * captured authority, and a local generation; joining the same room again + * creates a new membership. The room proof renews from every successful room + * response without changing membership. Socket attempts are fenced by a + * connection owner and epoch. * - * [realtimeFactory] is injected so tests supply a fake event flow. + * Snapshot ordering: `generation` orders transport changes, not membership or + * readiness updates. An HTTP snapshot is accepted only when its generation is + * newer, or equal with no socket snapshot received since the request started, + * so a delayed response cannot erase a newer attachment or readiness update. + * A terminal engagement is never reopened by a late result. */ class WatchTogetherRepository( private val api: WatchTogetherApi, private val realtimeFactory: () -> WatchTogetherRealtimeClient? = { null }, private val monotonicNowMs: () -> Long = { MONOTONIC_ORIGIN.elapsedNow().inWholeMilliseconds }, private val authScopeProvider: suspend () -> AuthScopeSnapshot? = { null }, -) : RoomSessionRepository, WatchTogetherEntryGateway { + private val wallClockMs: () -> Long = ::wallClockMillis, + private val timing: WatchPartyTiming = WatchPartyTiming(), + private val random: Random = Random.Default, +) : RoomSessionRepository, RoomPlaybackRoom { /** Successful delivery state follows the process connection, not a UI controller. */ val roomDeliveryLatch = RoomDeliveryLatch() private data class RoomBinding( val roomId: String, - val roomToken: String, val authScope: AuthScopeSnapshot, val generation: Long, - ) + ) { + override fun toString(): String = "RoomBinding(roomId=<redacted>, generation=$generation)" + } private val stateMutex = Mutex() + private val actionMutex = Mutex() + private val suggestionReadMutex = Mutex() private var nextGeneration = 0L private var latestRoomRequest = 0L private var binding: RoomBinding? = null + private var proof: RoomProof? = null private var terminalGeneration: Long? = null private var realtimeGeneration: Long? = null private var realtimeConnectionId: Long? = null private var nextConnectionOwner = 0L private var activeConnectionOwner: Long? = null + private var socketSnapshotSeq = 0L + private var socketSuggestionsSeq = 0L + private var suggestionReadRequests = 0L + private var suggestionReadStartedAt = 0L + private var lastSuggestionRead: Pair<RoomBinding, ApiResult<List<Suggestion>>>? = null + private var rawSuggestions: List<Suggestion> = emptyList() + private val clockEstimator = RoomClockEstimator() + private val _roomSnapshot = MutableStateFlow<RoomSnapshot?>(null) private val _suggestions = MutableStateFlow<List<Suggestion>>(emptyList()) + private val _personalVotesKnown = MutableStateFlow(false) private val _roomDeliveryEcho = MutableStateFlow<RoomDeliveryEcho?>(null) + private val _connectionState = MutableStateFlow(WatchTogetherConnectionState()) + private val _latestCommand = MutableStateFlow<ScheduledTransportCommand?>(null) + private val _clock = MutableStateFlow(clockEstimator.estimate) + private val _roomClosedReason = MutableStateFlow<String?>(null) + + private val _closedByThisDevice = MutableSharedFlow<String>(extraBufferCapacity = 4) + + /** + * Rooms this device ended for everyone. Leaving clears the engagement without + * an end reason, so this is how the recent party learns the room is gone. + */ + val closedByThisDevice: SharedFlow<String> = _closedByThisDevice.asSharedFlow() + private val _ended = MutableStateFlow<WatchPartyEnded?>(null) + private val _pendingAction = MutableStateFlow<WatchPartyPendingAction?>(null) override val roomSnapshot: StateFlow<RoomSnapshot?> = _roomSnapshot.asStateFlow() - val suggestions: StateFlow<List<Suggestion>> = _suggestions.asStateFlow() - val roomDeliveryEcho: StateFlow<RoomDeliveryEcho?> = _roomDeliveryEcho.asStateFlow() - private val _connectionState = MutableStateFlow(WatchTogetherConnectionState()) - val connectionState: StateFlow<WatchTogetherConnectionState> = _connectionState.asStateFlow() - @kotlin.concurrent.Volatile - private var transportAuthorization: RoomTransportAuthorization? = null - /** One atomic read; never pair independently-read snapshot/generation values. */ - fun currentTransportAuthorization(): RoomTransportAuthorization? = transportAuthorization + /** Suggestions in vote order, with this profile's own votes merged in. */ + val suggestions: StateFlow<List<Suggestion>> = _suggestions.asStateFlow() /** - * Transport commands surfaced for the player binding to feed to its - * [RoomSyncEngine]. Buffered + drop-oldest so emission never suspends the - * collect loop. replay=0 — a late subscriber should not re-apply a stale - * command. + * False until an authenticated suggestion read has told this profile which + * suggestions it voted for. Socket broadcasts never carry personal votes. */ - private val _transportCommands = MutableSharedFlow<ScheduledTransportCommand>( - replay = 0, - extraBufferCapacity = 16, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) - val transportCommands: SharedFlow<ScheduledTransportCommand> = _transportCommands.asSharedFlow() + val personalVotesKnown: StateFlow<Boolean> = _personalVotesKnown.asStateFlow() + override val roomDeliveryEcho: StateFlow<RoomDeliveryEcho?> = _roomDeliveryEcho.asStateFlow() + override val connectionState: StateFlow<WatchTogetherConnectionState> = _connectionState.asStateFlow() /** - * Pong samples for the player binding's engine clock-sync, with the three - * server-clock timestamps parsed to epoch millis. The binding stamps - * `clientReceivedMs` itself (see [PongSample]). + * The newest accepted transport command. Commands supersede each other, so + * the binding needs only the latest; a slow collector can never lose it. */ - private val _pongs = MutableSharedFlow<PongSample>( - replay = 0, - extraBufferCapacity = 16, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) - val pongs: SharedFlow<PongSample> = _pongs.asSharedFlow() + override val latestTransportCommand: StateFlow<ScheduledTransportCommand?> = _latestCommand.asStateFlow() + + /** Server clock estimate, sampled on the room socket from the moment it opens. */ + override val clock: StateFlow<RoomClockEstimate> = _clock.asStateFlow() + + /** The action in flight, if any. */ + val pendingAction: StateFlow<WatchPartyPendingAction?> = _pendingAction.asStateFlow() + + /** The last engagement that ended, with its reason. Cleared by [reset] and by a new membership. */ + val ended: StateFlow<WatchPartyEnded?> = _ended.asStateFlow() + + @kotlin.concurrent.Volatile + private var transportAuthorization: RoomTransportAuthorization? = null + + /** One atomic read; never pair independently-read snapshot/generation values. */ + override fun currentTransportAuthorization(): RoomTransportAuthorization? = transportAuthorization /** - * Why the room ended — TERMINAL only. Set exclusively on the server - * `room_closed` / [RoomRealtimeEvent.Closed] reason path (host left / explicit - * close); the player observes this and exits. Cleared on [reset] and at the - * start of a fresh [connect]. Transient server `error` frames do NOT populate - * this (they would eject the user) — see [errors]. + * Why the engagement ended — terminal only (see [WatchPartyEndReason]). + * Transient server `error` frames never populate this; they flow on + * [errors]. */ - private val _roomClosedReason = MutableStateFlow<String?>(null) override val roomClosedReason: StateFlow<String?> = _roomClosedReason.asStateFlow() /** - * Transient, non-terminal server `error` frames (e.g. a rejected - * transport_request). The UI may surface these as a snackbar/toast WITHOUT - * exiting the room. Buffered + drop-oldest so emission never suspends the - * fold; replay=0 so a late subscriber doesn't re-show a stale error. + * Transient, non-terminal notices (a rejected transport request, a local + * delivery failure). Buffered + drop-oldest; a missed notice is harmless. */ private val _errors = MutableSharedFlow<String>( replay = 0, @@ -187,53 +275,35 @@ class WatchTogetherRepository( if (message.isNotBlank()) _errors.tryEmit(message) } - // Locally-tracked vote set: ids the local user has voted for. Used to - // re-merge voted_by_me into broadcast suggestion lists (which force false). + // Ids this profile has voted for, known only after an authenticated read. private val votedIds = mutableSetOf<String>() @kotlin.concurrent.Volatile private var realtime: WatchTogetherRealtimeClient? = null - // ---- REST: create / join (store the room token) --------------------------- + override suspend fun membershipGeneration(): Long? = activeBinding()?.generation - override suspend fun createRoom(request: CreateRoomRequest): ApiResult<RoomResponse> { - val scope = authScopeProvider() ?: return missingAuthScope() + // ---- REST: create / join ------------------------------------------------ + + /** + * Create a room with the caller-selected identity in [request]. After an + * uncertain outcome, retry with the same request: the server replays it. + */ + suspend fun createRoom(request: CreateRoomRequest): ApiResult<RoomResponse> = action(WatchPartyPendingAction.Create) { + val scope = authScopeProvider() ?: return@action missingAuthScope() val requestGeneration = beginRoomRequest() val r = api.createRoom(request, scope) - if (authScopeProvider() != scope) return obsoleteRoomRequest() - return if (r is ApiResult.Success) installRoomResponse(r.data, scope, requestGeneration) else r + if (authScopeProvider() != scope) return@action obsoleteRoomRequest() + if (r is ApiResult.Success) installRoomResponse(r.data, scope, requestGeneration) else r } - override suspend fun joinRoom(request: JoinRoomRequest): ApiResult<RoomResponse> { - val scope = authScopeProvider() ?: return missingAuthScope() + suspend fun joinRoom(request: JoinRoomRequest): ApiResult<RoomResponse> = action(WatchPartyPendingAction.Join) { + val scope = authScopeProvider() ?: return@action missingAuthScope() val requestGeneration = beginRoomRequest() val r = api.joinRoom(request, scope) - if (authScopeProvider() != scope) return obsoleteRoomRequest() - if (r !is ApiResult.Success) return r - val installed = installRoomResponse(r.data, scope, requestGeneration) - if (installed !is ApiResult.Success) return installed - - // The realtime handshake's initial event is a room snapshot; it does - // not replay suggestions that existed before this member connected. - // Hydrate them while the join lease is current so a re-entered lobby - // does not render an empty vote list until somebody mutates it. - val lease = stateMutex.withLock { - binding?.takeIf { current -> - latestRoomRequest == requestGeneration && - current.roomId == r.data.room.roomId && - isCurrentLocked(current) - } - } ?: return obsoleteRoomRequest() - publishSuggestionsResponse( - lease, - api.listSuggestions(lease.roomId, lease.roomToken, lease.authScope), - expectedRoomRequest = requestGeneration, - ) - val stillCurrent = stateMutex.withLock { - latestRoomRequest == requestGeneration && isCurrentLocked(lease) - } - if (!stillCurrent) return obsoleteRoomRequest() - return installed + if (authScopeProvider() != scope) return@action obsoleteRoomRequest() + if (r !is ApiResult.Success) return@action r + installRoomResponse(r.data, scope, requestGeneration) } private suspend fun beginRoomRequest(): Long = stateMutex.withLock { ++latestRoomRequest } @@ -250,17 +320,25 @@ class WatchTogetherRepository( if (requestGeneration != latestRoomRequest) return obsoleteRoomRequest() val installed = RoomBinding( roomId = data.room.roomId, - roomToken = data.roomAccessToken, authScope = scope, generation = ++nextGeneration, ) binding = installed + proof = RoomProof(data.roomAccessToken, roomProofExpiryMs(data.roomAccessToken)) terminalGeneration = null + // Offsets measured against another room, possibly on another + // server, must not schedule this one's commands. + clockEstimator.reset() + _clock.value = clockEstimator.estimate votedIds.clear() + rawSuggestions = emptyList() _suggestions.value = emptyList() + _personalVotesKnown.value = false _roomClosedReason.value = null + _ended.value = null _roomSnapshot.value = data.room _roomDeliveryEcho.value = null + _latestCommand.value = null _connectionState.value = WatchTogetherConnectionState(generation = installed.generation) realtimeConnectionId = null refreshTransportAuthorizationLocked() @@ -270,74 +348,324 @@ class WatchTogetherRepository( // ---- REST: host management ------------------------------------------------ - override suspend fun setSelection(request: SetSelectionRequest): ApiResult<RoomResponse> { - val lease = activeBinding() ?: return missingRoom() - val r = api.setSelection(lease.roomId, lease.roomToken, request, lease.authScope) - return publishRoomResponse(lease, r) + suspend fun stageSelection(request: SetSelectionRequest): ApiResult<RoomResponse> = + roomAction(WatchPartyPendingAction.Stage) { lease, _ -> api.stageSelection(lease.roomId, request, lease.authScope) } + + suspend fun startPlayback(): ApiResult<RoomResponse> = + roomAction(WatchPartyPendingAction.Start) { lease, _ -> api.startPlayback(lease.roomId, lease.authScope) } + + suspend fun stopPlayback(): ApiResult<RoomResponse> = + roomAction(WatchPartyPendingAction.Stop) { lease, _ -> api.stopPlayback(lease.roomId, lease.authScope) } + + suspend fun setSelectionMode(mode: RoomSelectionMode): ApiResult<RoomResponse> = + roomAction(WatchPartyPendingAction.Mode) { lease, _ -> + api.setSelectionMode(lease.roomId, SelectionModeRequest(mode.wire), lease.authScope) + } + + /** Direct selection: starts playback. Never use it to stage. */ + suspend fun setSelection(request: SetSelectionRequest): ApiResult<RoomResponse> = + roomAction(WatchPartyPendingAction.Select) { lease, _ -> api.setSelection(lease.roomId, request, lease.authScope) } + + suspend fun updatePolicy(policy: GuestControlPolicy): ApiResult<RoomResponse> = + roomAction(WatchPartyPendingAction.Policy) { lease, _ -> + api.updatePolicy(lease.roomId, UpdatePolicyRequest(policy.wire), lease.authScope) + } + + /** End the party for everyone (host). */ + override suspend fun closeRoom(): ApiResult<Unit> = action(WatchPartyPendingAction.End) { + val lease = activeBinding() ?: return@action missingRoom() + val result = api.closeRoom(lease.roomId, lease.authScope) + when { + !isCurrent(lease) -> obsoleteRoomRequest() + result is ApiResult.Error && result.code == 409 -> { + // Already ended: converge on the ended state. + endIfCurrent(lease, WatchPartyEndReason.Ended) + _closedByThisDevice.tryEmit(lease.roomId) + ApiResult.Success(Unit) + } + else -> result.also { if (it is ApiResult.Success) _closedByThisDevice.tryEmit(lease.roomId) } + } } - suspend fun updatePolicy(request: UpdatePolicyRequest): ApiResult<RoomResponse> { - val lease = activeBinding() ?: return missingRoom() - val r = api.updatePolicy(lease.roomId, lease.roomToken, request, lease.authScope) - return publishRoomResponse(lease, r) + suspend fun promoteSuggestion(suggestionId: String): ApiResult<RoomResponse> = + roomAction(WatchPartyPendingAction.Promote) { lease, token -> + api.promoteSuggestion(lease.roomId, token, PromoteSuggestionRequest(suggestionId), lease.authScope) + } + + /** + * Ask the server to move the whole room to another source after an + * approved playback refusal. Fenced by [selectionRevision] and + * [failedFileId]; a stale request returns the current snapshot. + */ + suspend fun requestSourceFallback( + selectionRevision: Long, + failedFileId: String, + reason: SourceFallbackReason, + ): ApiResult<RoomResponse> = roomAction(WatchPartyPendingAction.Fallback) { lease, token -> + api.sourceFallback( + lease.roomId, + token, + SourceFallbackRequest(selectionRevision, failedFileId, reason.wire), + lease.authScope, + ) } - override suspend fun closeRoom(): ApiResult<Unit> { + /** Read the room, adopting its snapshot (when newer) and renewed proof. */ + suspend fun refreshRoom(): ApiResult<RoomResponse> { val lease = activeBinding() ?: return missingRoom() - val result = api.closeRoom(lease.roomId, lease.roomToken, lease.authScope) - return if (isCurrent(lease)) result else obsoleteRoomRequest() + return reconcileRoom(lease) } - // ---- REST: suggestions ---------------------------------------------------- + // ---- REST: shared browsing ----------------------------------------------- - suspend fun refreshSuggestions(): ApiResult<SuggestionsResponse> { + suspend fun memberState(contentIds: List<String>): ApiResult<MemberStateResponse> { val lease = activeBinding() ?: return missingRoom() - val r = api.listSuggestions(lease.roomId, lease.roomToken, lease.authScope) - return publishSuggestionsResponse(lease, r) + val token = proofToken(lease) ?: return missingRoom() + val result = api.memberState(lease.roomId, token, MemberStateRequest(contentIds), lease.authScope) + return if (isCurrent(lease)) result else obsoleteRoomRequest() } - suspend fun addSuggestion(request: AddSuggestionRequest): ApiResult<SuggestionsResponse> { + suspend fun picker(): ApiResult<PickerResponse> { val lease = activeBinding() ?: return missingRoom() - val r = api.addSuggestion(lease.roomId, lease.roomToken, request, lease.authScope) - return publishSuggestionsResponse(lease, r) + val token = proofToken(lease) ?: return missingRoom() + val result = api.picker(lease.roomId, token, lease.authScope) + return if (isCurrent(lease)) result else obsoleteRoomRequest() } - suspend fun deleteSuggestion(suggestionId: String): ApiResult<SuggestionsResponse> { + // ---- REST: suggestions ---------------------------------------------------- + + /** + * Read every suggestion page under one captured membership and publish the + * merged list with authoritative personal votes. Concurrent requests + * coalesce: a caller whose request predates a read that already started + * gets that read's result. + */ + suspend fun refreshSuggestions(): ApiResult<List<Suggestion>> { val lease = activeBinding() ?: return missingRoom() - val r = api.deleteSuggestion(lease.roomId, lease.roomToken, suggestionId, lease.authScope) - return publishSuggestionsResponse(lease, r) + val ticket = stateMutex.withLock { ++suggestionReadRequests } + return suggestionReadMutex.withLock { + val shared = lastSuggestionRead + if (shared?.first == lease && stateMutex.withLock { suggestionReadStartedAt >= ticket }) { + // A read for this membership started after this request and + // already ran: share its outcome, a failure included. + return@withLock when { + !isCurrent(lease) -> obsoleteRoomRequest() + shared.second is ApiResult.Success -> ApiResult.Success(_suggestions.value) + else -> shared.second + } + } + val socketSeqAtStart = stateMutex.withLock { + suggestionReadStartedAt = suggestionReadRequests + socketSuggestionsSeq + } + readSuggestionPages(lease, socketSeqAtStart).also { lastSuggestionRead = lease to it } + } } - suspend fun vote(suggestionId: String): ApiResult<SuggestionsResponse> { - val lease = activeBinding() ?: return missingRoom() - val r = api.vote(lease.roomId, lease.roomToken, suggestionId, lease.authScope) - if (r !is ApiResult.Success) return r + private suspend fun readSuggestionPages(lease: RoomBinding, socketSeqAtStart: Long): ApiResult<List<Suggestion>> { + val pages = mutableListOf<Suggestion>() + var cursor: String? = null + while (true) { + val token = proofToken(lease) ?: return missingRoom() + when (val page = api.listSuggestions(lease.roomId, token, lease.authScope, cursor)) { + is ApiResult.Success -> { + pages += page.data.suggestions + cursor = page.data.page?.nextCursor?.takeIf { page.data.page.hasMore } + if (cursor == null) break + } + is ApiResult.Error -> return page + is ApiResult.NetworkError -> return page + } + } + val merged = pages.distinctBy { it.id } val published = stateMutex.withLock { if (!isCurrentLocked(lease)) return@withLock false - votedIds.add(suggestionId) - applySuggestions(r.data.suggestions, fromBroadcast = true) + votedIds.clear() + votedIds.addAll(merged.filter { it.votedByMe }.map { it.id }) + _personalVotesKnown.value = true + // A socket broadcast that arrived during the read carries newer + // tallies; keep its rows and take only personal votes from HTTP. + if (socketSuggestionsSeq == socketSeqAtStart) rawSuggestions = merged + publishSuggestionsLocked() true } - return if (published) r else obsoleteRoomRequest() + return if (published) ApiResult.Success(_suggestions.value) else obsoleteRoomRequest() } - suspend fun unvote(suggestionId: String): ApiResult<SuggestionsResponse> { - val lease = activeBinding() ?: return missingRoom() - val r = api.unvote(lease.roomId, lease.roomToken, suggestionId, lease.authScope) - if (r !is ApiResult.Success) return r + /** + * Add a suggestion with the caller-selected id in [request]. After an + * uncertain outcome, retry with the same request. A failed list refresh + * after the receipt does not fail the suggestion. + */ + suspend fun addSuggestion(request: AddSuggestionRequest): ApiResult<SuggestionReceipt> = + suggestionAction(WatchPartyPendingAction.Suggest) { lease, token -> + api.addSuggestion(lease.roomId, token, request, lease.authScope) + } + + /** Remove a suggestion (host, or its suggester). A 404 means it is already gone. */ + suspend fun deleteSuggestion(suggestionId: String): ApiResult<Unit> { + val result = suggestionAction(WatchPartyPendingAction.RemoveSuggestion) { lease, token -> + api.deleteSuggestion(lease.roomId, token, suggestionId, lease.authScope) + } + return if (result is ApiResult.Error && result.code == 404 && result.error != "no_active_room") { + ApiResult.Success(Unit) + } else { + result + } + } + + suspend fun vote(suggestionId: String): ApiResult<Unit> = setVote(suggestionId, voted = true) + + suspend fun unvote(suggestionId: String): ApiResult<Unit> = setVote(suggestionId, voted = false) + + private suspend fun setVote(suggestionId: String, voted: Boolean): ApiResult<Unit> = + suggestionAction(WatchPartyPendingAction.Vote) { lease, token -> + val result = if (voted) { + api.vote(lease.roomId, token, suggestionId, lease.authScope) + } else { + api.unvote(lease.roomId, token, suggestionId, lease.authScope) + } + if (result is ApiResult.Success) { + stateMutex.withLock { + if (isCurrentLocked(lease)) { + if (voted) votedIds.add(suggestionId) else votedIds.remove(suggestionId) + publishSuggestionsLocked() + } + } + } + result + } + + private fun publishSuggestionsLocked() { + _suggestions.value = rankSuggestions(rawSuggestions).map { s -> + if ((s.id in votedIds) != s.votedByMe) s.copy(votedByMe = s.id in votedIds) else s + } + } + + // ---- Actions --------------------------------------------------------------- + + private suspend fun <T> action(kind: WatchPartyPendingAction, block: suspend () -> ApiResult<T>): ApiResult<T> = + actionMutex.withLock { + _pendingAction.value = kind + try { + block() + } finally { + _pendingAction.value = null + } + } + + /** + * One room mutation returning a snapshot. Success publishes it (and the + * renewed proof). An uncertain outcome (network failure, malformed + * response) or an ambiguous refusal (403, 404, 409) reconciles by reading + * the room; the read, not the refusal, decides whether the room ended. + * Nothing is replayed. + */ + private suspend fun roomAction( + kind: WatchPartyPendingAction, + call: suspend (RoomBinding, String) -> ApiResult<RoomResponse>, + ): ApiResult<RoomResponse> = action(kind) { + val lease = activeBinding() ?: return@action missingRoom() + val token = proofToken(lease) ?: return@action missingRoom() + val seq = stateMutex.withLock { socketSnapshotSeq } + when (val result = call(lease, token)) { + is ApiResult.Success -> publishRoomResponse(lease, result, seq) + is ApiResult.Error -> { + if (result.code in RECONCILE_STATUSES || result.error == INVALID_RESPONSE) reconcileRoom(lease) + result + } + is ApiResult.NetworkError -> { + reconcileRoom(lease) + result + } + } + } + + private suspend fun <T> suggestionAction( + kind: WatchPartyPendingAction, + call: suspend (RoomBinding, String) -> ApiResult<T>, + ): ApiResult<T> { + val (lease, result) = action(kind) { + val lease = activeBinding() ?: return@action missingRoom() + val token = proofToken(lease) ?: return@action missingRoom() + ApiResult.Success(lease to call(lease, token)) + }.let { outer -> + when (outer) { + is ApiResult.Success -> outer.data + is ApiResult.Error -> return outer + is ApiResult.NetworkError -> return outer + } + } + if (!isCurrent(lease)) return obsoleteRoomRequest() + // Reconcile the list whatever the outcome; the mutation result stands + // even if this read fails. + refreshSuggestions() + if (result is ApiResult.Error && result.code == 409) reconcileRoom(lease) + return result + } + + // ---- Publication ------------------------------------------------------------ + + private suspend fun proofToken(lease: RoomBinding): String? = stateMutex.withLock { + if (isCurrentLocked(lease)) proof?.token else null + } + + private suspend fun publishRoomResponse( + lease: RoomBinding, + result: ApiResult.Success<RoomResponse>, + socketSeqAtRequest: Long, + ): ApiResult<RoomResponse> { + val response = result.data + if (response.room.roomId != lease.roomId || response.roomAccessToken.isBlank()) return invalidRoomResponse() + var ended = false val published = stateMutex.withLock { if (!isCurrentLocked(lease)) return@withLock false - votedIds.remove(suggestionId) - applySuggestions(r.data.suggestions, fromBroadcast = true) + proof = RoomProof(response.roomAccessToken, roomProofExpiryMs(response.roomAccessToken)) + if (response.room.phase == RoomPhase.Ended) { + endLocked(lease, WatchPartyEndReason.Ended) + ended = true + return@withLock true + } + val current = _roomSnapshot.value + val newer = current == null || + response.room.generation > current.generation || + (response.room.generation == current.generation && socketSnapshotSeq == socketSeqAtRequest) + if (newer) { + _roomSnapshot.value = response.room + val echo = _roomDeliveryEcho.value + if (echo != null && echo.playbackSessionId != response.room.attachedSessionId) { + _roomDeliveryEcho.value = null + } + refreshTransportAuthorizationLocked() + } true } - return if (published) r else obsoleteRoomRequest() + return when { + !published -> obsoleteRoomRequest() + ended -> ApiResult.Error(409, WatchPartyEndReason.Ended, "The Watch Party has ended.") + else -> result + } } - suspend fun promoteSuggestion(request: PromoteSuggestionRequest): ApiResult<RoomResponse> { - val lease = activeBinding() ?: return missingRoom() - val r = api.promoteSuggestion(lease.roomId, lease.roomToken, request, lease.authScope) - return publishRoomResponse(lease, r) + /** + * Read the room to settle an uncertain or ambiguous outcome. A 403, 404, + * or 409 here means this device can no longer take part. + */ + private suspend fun reconcileRoom(lease: RoomBinding): ApiResult<RoomResponse> { + val token = proofToken(lease) ?: return missingRoom() + val seq = stateMutex.withLock { socketSnapshotSeq } + return when (val r = api.getRoom(lease.roomId, token, lease.authScope)) { + is ApiResult.Success -> publishRoomResponse(lease, r, seq) + is ApiResult.Error -> { + when (r.code) { + 404 -> endIfCurrent(lease, WatchPartyEndReason.NotFound) + 409 -> endIfCurrent(lease, WatchPartyEndReason.Ended) + 403 -> endIfCurrent(lease, WatchPartyEndReason.Unauthorized) + } + r + } + is ApiResult.NetworkError -> r + } } private suspend fun activeBinding(): RoomBinding? = stateMutex.withLock { @@ -370,72 +698,42 @@ class WatchTogetherRepository( } } - private suspend fun publishRoomResponse( - lease: RoomBinding, - result: ApiResult<RoomResponse>, - ): ApiResult<RoomResponse> { - if (result !is ApiResult.Success) return result - if (result.data.room.roomId != lease.roomId) return invalidRoomResponse() - val published = stateMutex.withLock { - if (!isCurrentLocked(lease)) return@withLock false - _roomSnapshot.value = result.data.room - _roomDeliveryEcho.value = null - refreshTransportAuthorizationLocked() - true - } - return if (published) result else obsoleteRoomRequest() + private suspend fun endIfCurrent(lease: RoomBinding, reason: String) { + stateMutex.withLock { if (isCurrentLocked(lease)) endLocked(lease, reason) } } - private suspend fun publishSuggestionsResponse( - lease: RoomBinding, - result: ApiResult<SuggestionsResponse>, - expectedRoomRequest: Long? = null, - expectedConnectionOwner: Long? = null, - ): ApiResult<SuggestionsResponse> { - if (result !is ApiResult.Success) return result - val published = stateMutex.withLock { - if ( - !isCurrentLocked(lease) || - (expectedRoomRequest != null && latestRoomRequest != expectedRoomRequest) || - (expectedConnectionOwner != null && activeConnectionOwner != expectedConnectionOwner) - ) { - return@withLock false - } - applySuggestions(result.data.suggestions, fromBroadcast = false) - true - } - return if (published) result else obsoleteRoomRequest() + /** Terminal for this membership: nothing may reopen it. */ + private fun endLocked(lease: RoomBinding, reason: String) { + val last = _roomSnapshot.value + terminalGeneration = lease.generation + _ended.value = WatchPartyEnded( + roomId = lease.roomId, + code = last?.code.orEmpty(), + reason = reason, + wasHost = last?.selfRole == MemberRole.Host, + ) + _roomClosedReason.value = reason + _roomSnapshot.value = null + _roomDeliveryEcho.value = null + _latestCommand.value = null + _connectionState.value = _connectionState.value.copy(writable = false) + realtimeConnectionId = null + refreshTransportAuthorizationLocked() } private fun invalidRoomResponse(): ApiResult.Error = ApiResult.Error(502, "invalid_room_response", "The room response was missing or mismatched.") private fun missingRoom(): ApiResult.Error = - ApiResult.Error(409, "no_active_room", "No active Watch Together room.") + ApiResult.Error(409, "no_active_room", "No active Watch Party.") private fun missingAuthScope(): ApiResult.Error = - ApiResult.Error(401, "missing_auth_scope", "No authenticated Watch Together scope.") + ApiResult.Error(401, "missing_auth_scope", "No authenticated Watch Party scope.") private fun obsoleteRoomRequest(): ApiResult.Error = - ApiResult.Error(409, "obsolete_room_request", "The Watch Together identity changed.") + ApiResult.Error(409, "obsolete_room_request", "The Watch Party identity changed.") - /** - * Publish suggestions, re-merging `voted_by_me` from the local [votedIds] - * set. Authoritative REST lists replace [votedIds]; broadcasts and - * optimistic vote mutation responses preserve the local set because their - * per-recipient vote flags are not authoritative. - */ - private fun applySuggestions(list: List<Suggestion>, fromBroadcast: Boolean) { - if (!fromBroadcast) { - votedIds.clear() - votedIds.addAll(list.filter { it.votedByMe }.map { it.id }) - } - _suggestions.value = list.map { s -> - if (s.id in votedIds) s.copy(votedByMe = true) else s - } - } - - // ---- WS: client→server send passthroughs ---------------------------------- + // ---- WS: client→server sends ------------------------------------------------ private suspend fun currentWritableRealtime(): WatchTogetherRealtimeClient? = stateMutex.withLock { @@ -449,7 +747,7 @@ class WatchTogetherRepository( } } - suspend fun attachSession(sessionId: String): Boolean = + override suspend fun attachSession(sessionId: String): Boolean = currentWritableRealtime()?.attachSession(sessionId) ?: false suspend fun transportRequest( @@ -464,7 +762,7 @@ class WatchTogetherRepository( * lock that guards room replacement, so a delayed UI coroutine cannot send * an old room's command through a replacement room or changed policy. */ - suspend fun transportRequestForAuthorization( + override suspend fun transportRequestForAuthorization( authorization: RoomTransportAuthorization, intent: RoomTransportIntent, action: String, @@ -501,34 +799,47 @@ class WatchTogetherRepository( ) } - suspend fun stateReport( + override suspend fun stateReport( sessionId: String, positionSeconds: Double, isPaused: Boolean, - ): Boolean = currentWritableRealtime()?.stateReport(sessionId, positionSeconds, isPaused) ?: false + commandId: String?, + isReady: Boolean, + ): Boolean = currentWritableRealtime()?.stateReport(sessionId, positionSeconds, isPaused, commandId, isReady) ?: false - suspend fun ready( + override suspend fun ready( sessionId: String, positionSeconds: Double, isPaused: Boolean, - ): Boolean = currentWritableRealtime()?.ready(sessionId, positionSeconds, isPaused) ?: false + commandId: String?, + ): Boolean = currentWritableRealtime()?.ready(sessionId, positionSeconds, isPaused, commandId) ?: false - suspend fun buffering( + override suspend fun buffering( sessionId: String, positionSeconds: Double, isPaused: Boolean, ): Boolean = currentWritableRealtime()?.buffering(sessionId, positionSeconds, isPaused) ?: false - suspend fun ping(clientSentAt: String): Boolean = - currentWritableRealtime()?.ping(clientSentAt) ?: false + /** Advisory lobby Ready over the room socket. */ + suspend fun setLobbyReady(ready: Boolean): Boolean = + currentWritableRealtime()?.lobbyReady(ready) ?: false + + // ---- WS lifecycle ------------------------------------------------------------ - // ---- WS lifecycle: connect + reconnect-with-backoff ------------------------ + private sealed interface AttemptEnd { + data class Closed(val reason: String?) : AttemptEnd + data object Replaced : AttemptEnd + data class Transport(val cause: Throwable?) : AttemptEnd + } /** - * Collect the room socket with capped-backoff reconnect, folding each event - * into the state flows. Suspends until the caller's scope is cancelled OR a - * server `room_closed` arrives (which stops reconnecting). Backoff steps are - * [BACKOFF_MS]; a healthy event resets the index. + * Own the room socket for the current membership until the engagement ends + * or the caller's scope is cancelled. A socket that stayed up reconnects at + * once (the server rotates every socket after five minutes); a socket that + * fails quickly backs off. Reconnecting continues for at least + * [WatchPartyTiming.reconnectBudgetMs] after the last healthy socket, which + * covers the host's two-minute grace. Terminal frames and terminal ticket + * refusals end the engagement; `connection_replaced` never reclaims it. */ override suspend fun connect(roomId: String) { val lease = activeBinding()?.takeIf { it.roomId == roomId } ?: return @@ -543,100 +854,220 @@ class WatchTogetherRepository( _connectionState.value = _connectionState.value.copy( generation = lease.generation, writable = false, + disconnectedAtMs = monotonicNowMs(), ) - _roomClosedReason.value = null refreshTransportAuthorizationLocked() newOwner } + try { + coroutineScope { + val renewal = if (timing.backgroundWork) launch { renewProofLoop(lease, owner) } else null + socketLoop(lease, owner, client, this) + renewal?.cancel() + } + } finally { + withContext(NonCancellable) { clearRealtimeIfCurrent(lease, client, owner) } + } + } + + private suspend fun socketLoop( + lease: RoomBinding, + owner: Long, + client: WatchTogetherRealtimeClient, + scope: CoroutineScope, + ) { var backoffIndex = 0 var failures = 0 - while (true) { - if (!isCurrent(lease, owner)) break - var closedByServer = false + var lastHealthyMs = monotonicNowMs() + var proofRenewedForRefusal = false + while (isCurrent(lease, owner)) { + val token = proofToken(lease) ?: break var openedAtMs: Long? = null var sawSnapshot = false + var end: AttemptEnd = AttemptEnd.Transport(null) + var pings: Job? = null try { - client.connect(lease.roomId, lease.roomToken, lease.authScope).collect { event -> + client.connect(lease.roomId, token, lease.authScope).collect { event -> if (!isCurrent(lease, owner)) throw ObsoleteBinding - if (event is RoomRealtimeEvent.Closed) { - // Any server-initiated close (with or without a reason) is terminal. - // The event flow (a hot SharedFlow) never completes on its - // own, so we stop collecting by throwing a private sentinel. - closedByServer = true - stateMutex.withLock { - if (isCurrentOwnerLocked(lease, owner)) { - terminalGeneration = lease.generation - _roomClosedReason.value = event.reason ?: "room_closed" - _roomSnapshot.value = null - _roomDeliveryEcho.value = null - refreshTransportAuthorizationLocked() + when (event) { + RoomRealtimeEvent.Opened -> { + openedAtMs = monotonicNowMs() + markOpened(lease, owner, client.currentConnectionId()) + pings?.cancel() + if (timing.backgroundWork) pings = scope.launch { pingLoop(lease, owner, client) } + // Suggestions that predate this socket are not replayed on + // it. Hydrate off the receive path. + scope.launch { refreshSuggestions() } + } + is RoomRealtimeEvent.Closed -> { + end = AttemptEnd.Closed(event.reason) + throw AttemptStopped + } + is RoomRealtimeEvent.ConnectionReplaced -> { + end = AttemptEnd.Replaced + throw AttemptStopped + } + is RoomRealtimeEvent.TransportTerminated -> { + end = AttemptEnd.Transport(event.cause) + throw AttemptStopped + } + is RoomRealtimeEvent.Malformed -> scope.launch { reconcileRoom(lease) } + else -> { + if (event is RoomRealtimeEvent.SnapshotEvent && event.room.roomId == lease.roomId) { + sawSnapshot = true } + fold(lease, owner, event) } - throw ServerClosed - } else if (event is RoomRealtimeEvent.TransportTerminated) { - markNotWritable(lease, owner) - throw TransportEnded - } else if (event is RoomRealtimeEvent.Opened) { - openedAtMs = monotonicNowMs() - markOpened(lease, owner, client.currentConnectionId()) - publishSuggestionsResponse( - lease = lease, - result = api.listSuggestions( - lease.roomId, - lease.roomToken, - lease.authScope, - ), - expectedConnectionOwner = owner, - ) - } else if ( - event is RoomRealtimeEvent.SnapshotEvent && - event.room.roomId == lease.roomId - ) { - sawSnapshot = true } - fold(lease, owner, event) } - markNotWritable(lease, owner) - if (isStableAttempt(openedAtMs, sawSnapshot)) { - failures = 0 - backoffIndex = 0 - } - failures++ } catch (e: CancellationException) { - clearRealtimeIfCurrent(lease, client, owner) + pings?.cancel() throw e } catch (_: ObsoleteBinding) { + pings?.cancel() break - } catch (_: ServerClosed) { - // terminal — handled below via closedByServer - } catch (_: Throwable) { - // Any throw (including from a flapping server) counts as a failure, - // regardless of whether a healthy event arrived in the same attempt. - markNotWritable(lease, owner) - if (isStableAttempt(openedAtMs, sawSnapshot)) { - failures = 0 - backoffIndex = 0 + } catch (_: AttemptStopped) { + // `end` records why. + } catch (failure: Throwable) { + end = AttemptEnd.Transport(failure) + } + pings?.cancel() + markNotWritable(lease, owner) + lastSocketEnd = describeSocketEnd(end, openedAtMs?.let { monotonicNowMs() - it }) + when (val ended = end) { + is AttemptEnd.Closed -> { + endIfOwner(lease, owner, ended.reason?.takeIf { it.isNotBlank() } ?: WatchPartyEndReason.HostLeft) + break + } + AttemptEnd.Replaced -> { + endIfOwner(lease, owner, WatchPartyEndReason.Replaced) + break + } + is AttemptEnd.Transport -> when (val cause = ended.cause) { + is RoomTicketRefusedException -> { + if (cause.status == 403 && !proofRenewedForRefusal) { + // The proof may have lapsed; one renewal decides. + proofRenewedForRefusal = true + if (reconcileRoom(lease) is ApiResult.Success) continue + } + if (cause.terminal) { + endIfOwner(lease, owner, ticketEndReason(cause.status)) + break + } + } + is RoomTicketExpiringException -> { + // The proof or the access token is about to lapse. Renew + // the proof when it may be the one; an expiring access + // token lapses within the retry and the next mint's 401 + // refreshes it. Each attempt counts against the budget, + // so a server that keeps answering this way ends the + // engagement instead of retrying forever. + val expiry = stateMutex.withLock { proof?.expiresAtEpochMs } + if (expiry == null || expiry - serverWallNowMs() < timing.proofRenewalMarginMs) { + reconcileRoom(lease) + } + failures++ + if (reconnectBudgetSpent(failures, lastHealthyMs)) { + endIfOwner(lease, owner, WatchPartyEndReason.ConnectionLost) + break + } + delay(timing.expiringTicketRetryMs) + continue + } + else -> Unit } - failures++ } - if (closedByServer) break if (!isCurrent(lease, owner)) break - if (failures >= MAX_RECONNECT_ATTEMPTS) { - stateMutex.withLock { - if (isCurrentOwnerLocked(lease, owner)) { - terminalGeneration = lease.generation - _roomClosedReason.value = "connection_lost" - _roomSnapshot.value = null - _roomDeliveryEcho.value = null - refreshTransportAuthorizationLocked() - } + val now = monotonicNowMs() + val opened = openedAtMs + if (opened != null && sawSnapshot) { + proofRenewedForRefusal = false + if (now - opened >= timing.stableConnectionMs) { + // Routine rotation: reconnect at once. + lastHealthyMs = now + failures = 0 + backoffIndex = 0 + continue } + } + failures++ + if (reconnectBudgetSpent(failures, lastHealthyMs)) { + endIfOwner(lease, owner, WatchPartyEndReason.ConnectionLost) break } - delay(BACKOFF_MS[backoffIndex]) - backoffIndex = (backoffIndex + 1).coerceAtMost(BACKOFF_MS.lastIndex) + val step = timing.backoffMs[backoffIndex.coerceAtMost(timing.backoffMs.lastIndex)] + delay(step + random.nextLong(0L, step / 4 + 1)) + backoffIndex = (backoffIndex + 1).coerceAtMost(timing.backoffMs.lastIndex) + } + } + + private fun reconnectBudgetSpent(failures: Int, lastHealthyMs: Long): Boolean = + failures >= timing.maxReconnectFailures && monotonicNowMs() - lastHealthyMs >= timing.reconnectBudgetMs + + /** + * The server's wall clock as this device best knows it. Proof expiry is a + * server timestamp, so a device clock that runs fast or slow must not + * decide when to renew. + */ + private fun serverWallNowMs(): Long = wallClockMs() + (_clock.value.offsetMs ?: 0L) + + /** + * Why the last room socket ended and how long it had been open, for the + * debug harness. Carries exception class names and messages only, never + * tickets or tokens. + */ + @Volatile + var lastSocketEnd: String? = null + private set + + private fun describeSocketEnd(end: AttemptEnd, openMs: Long?): String { + val what = when (end) { + is AttemptEnd.Closed -> "closed:${end.reason}" + AttemptEnd.Replaced -> "replaced" + is AttemptEnd.Transport -> end.cause?.let { cause -> + "transport:${cause::class.simpleName}:${cause.message?.take(160)}" + } ?: "transport:eof" + } + return "$what after ${openMs ?: -1}ms" + } + + private fun ticketEndReason(status: Int): String = when (status) { + 404 -> WatchPartyEndReason.NotFound + 409 -> WatchPartyEndReason.Ended + else -> WatchPartyEndReason.Unauthorized + } + + private suspend fun pingLoop(lease: RoomBinding, owner: Long, client: WatchTogetherRealtimeClient) { + var sent = 0 + while (isCurrent(lease, owner)) { + client.ping(formatEpochMillisRfc3339(wallClockMs())) + sent++ + delay(if (sent < timing.initialPings) timing.initialPingSpacingMs else timing.pingIntervalMs) + } + } + + private suspend fun renewProofLoop(lease: RoomBinding, owner: Long) { + var renewedLastPass = false + while (isCurrent(lease, owner)) { + val expiry = stateMutex.withLock { proof?.expiresAtEpochMs } + val waitMs = if (expiry == null) { + timing.proofUnknownRenewalMs + } else { + expiry - serverWallNowMs() - timing.proofRenewalMarginMs + } + // A proof that is already due right after a successful renewal + // (a short-lived proof, or a clock this device cannot correct) + // would otherwise be read again at once, in a tight loop. + val pause = if (renewedLastPass) waitMs.coerceAtLeast(timing.proofRetryMs) else waitMs + if (pause > 0) delay(pause) + if (!isCurrent(lease, owner)) break + val renewed = reconcileRoom(lease) + renewedLastPass = renewed is ApiResult.Success + if (!renewedLastPass) { + if (!isCurrent(lease, owner)) break + delay(timing.proofRetryMs) + } } - clearRealtimeIfCurrent(lease, client, owner) } private suspend fun isCurrent(lease: RoomBinding): Boolean = @@ -648,6 +1079,10 @@ class WatchTogetherRepository( private fun isCurrentOwnerLocked(lease: RoomBinding, owner: Long): Boolean = isCurrentLocked(lease) && activeConnectionOwner == owner + private suspend fun endIfOwner(lease: RoomBinding, owner: Long, reason: String) { + stateMutex.withLock { if (isCurrentOwnerLocked(lease, owner)) endLocked(lease, reason) } + } + private suspend fun clearRealtimeIfCurrent( lease: RoomBinding, client: WatchTogetherRealtimeClient, @@ -682,6 +1117,7 @@ class WatchTogetherRepository( generation = lease.generation, epoch = if (previous.generation == lease.generation) previous.epoch + 1 else 1, writable = true, + disconnectedAtMs = null, ) refreshTransportAuthorizationLocked() } @@ -691,79 +1127,117 @@ class WatchTogetherRepository( private suspend fun markNotWritable(lease: RoomBinding, owner: Long) { stateMutex.withLock { if (binding == lease && activeConnectionOwner == owner) { - _connectionState.value = _connectionState.value.copy(writable = false) + val previous = _connectionState.value + _connectionState.value = previous.copy( + writable = false, + disconnectedAtMs = previous.disconnectedAtMs ?: monotonicNowMs(), + ) realtimeConnectionId = null refreshTransportAuthorizationLocked() } } } - private fun isStableAttempt(openedAtMs: Long?, sawSnapshot: Boolean): Boolean = - sawSnapshot && openedAtMs != null && monotonicNowMs() - openedAtMs >= STABLE_CONNECTION_MS - - /** Sentinel to unwind the [connect] collect loop on a server `room_closed`. */ - private object ServerClosed : Throwable() - private object TransportEnded : Throwable() + /** Sentinels that unwind one socket attempt's collect loop. */ + private object AttemptStopped : Throwable() private object ObsoleteBinding : Throwable() - /** Pure-ish fold of one realtime event into the state flows + side streams. */ + /** Fold one realtime event into the state flows. */ private suspend fun fold(lease: RoomBinding, owner: Long, event: RoomRealtimeEvent) { stateMutex.withLock { if (!isCurrentOwnerLocked(lease, owner)) return when (event) { - RoomRealtimeEvent.Opened -> Unit - is RoomRealtimeEvent.SnapshotEvent -> - if (event.room.roomId == lease.roomId) { - _roomSnapshot.value = event.room - _roomDeliveryEcho.value = event.room.attachedSessionId - ?.takeIf { it.isNotBlank() } - ?.let { sessionId -> - val connection = _connectionState.value - RoomDeliveryEcho( - connectionGeneration = connection.generation, - connectionEpoch = connection.epoch, - playbackSessionId = sessionId, - ) - } - refreshTransportAuthorizationLocked() + is RoomRealtimeEvent.SnapshotEvent -> { + val room = event.room + if (room.roomId != lease.roomId) return + if (room.phase == RoomPhase.Ended) { + endLocked(lease, WatchPartyEndReason.Ended) + return } - is RoomRealtimeEvent.SuggestionsEvent -> applySuggestions(event.suggestions, fromBroadcast = true) - is RoomRealtimeEvent.TransportCommandEvent -> _transportCommands.tryEmit( - ScheduledTransportCommand( + val current = _roomSnapshot.value + if (current != null && room.generation < current.generation) return + socketSnapshotSeq++ + _roomSnapshot.value = room + _roomDeliveryEcho.value = room.attachedSessionId + ?.takeIf { it.isNotBlank() } + ?.let { sessionId -> + val connection = _connectionState.value + RoomDeliveryEcho( + connectionGeneration = connection.generation, + connectionEpoch = connection.epoch, + playbackSessionId = sessionId, + ) + } + refreshTransportAuthorizationLocked() + } + is RoomRealtimeEvent.SuggestionsEvent -> { + socketSuggestionsSeq++ + rawSuggestions = event.suggestions.filter { it.roomId.isBlank() || it.roomId == lease.roomId } + publishSuggestionsLocked() + } + is RoomRealtimeEvent.TransportCommandEvent -> { + _latestCommand.value = ScheduledTransportCommand( command = event.command, executeAtMs = parseRfc3339ToEpochMillis(event.command.executeAt), connection = _connectionState.value, - ), - ) - is RoomRealtimeEvent.Pong -> _pongs.tryEmit( - PongSample( - clientSentMs = parseRfc3339ToEpochMillis(event.clientSentAt), - serverReceivedMs = parseRfc3339ToEpochMillis(event.serverReceivedAt), - serverSentMs = parseRfc3339ToEpochMillis(event.serverSentAt), - ), - ) - is RoomRealtimeEvent.Closed -> { /* lifecycle handled in connect() */ } - is RoomRealtimeEvent.TransportTerminated -> Unit + receivedAtMs = monotonicNowMs(), + ) + } + is RoomRealtimeEvent.Pong -> { + val sent = parseRfc3339ToEpochMillis(event.clientSentAt) + val serverReceived = parseRfc3339ToEpochMillis(event.serverReceivedAt) + val serverSent = parseRfc3339ToEpochMillis(event.serverSentAt) + val received = event.clientReceivedMs + if (sent != null && serverReceived != null && serverSent != null && received != null) { + // The receipt was stamped when the frame was decoded; it + // may be folded later. Pair it with the monotonic time + // of that same moment, or the wait reads as a wall-clock + // jump and wipes the estimate. + val waitedMs = (wallClockMs() - received).coerceAtLeast(0L) + clockEstimator.record(sent, serverReceived, serverSent, received, monotonicNowMs() - waitedMs) + _clock.value = clockEstimator.estimate + } + } is RoomRealtimeEvent.Error -> - // Transient, NON-terminal: a server `error` frame (e.g. a rejected - // transport_request) must NOT feed roomClosedReason. + // Transient, NON-terminal: a rejected request must not end the party. _errors.tryEmit(event.message.ifBlank { event.code }) + RoomRealtimeEvent.Opened, + is RoomRealtimeEvent.Closed, + is RoomRealtimeEvent.ConnectionReplaced, + is RoomRealtimeEvent.TransportTerminated, + is RoomRealtimeEvent.Malformed -> Unit } } } - /** Clear all room state on leave. The connect() loop ends via scope cancellation. */ + /** + * Check the local wall clock against the monotonic clock. A jump resets + * the server-time estimate so no timing work uses a stale offset. + */ + override suspend fun checkClockContinuity(): RoomClockEstimate = stateMutex.withLock { + if (clockEstimator.checkContinuity(wallClockMs(), monotonicNowMs())) _clock.value = clockEstimator.estimate + _clock.value + } + + /** Clear all room state on leave or identity change. The connect() loop ends via scope cancellation. */ override suspend fun reset() { stateMutex.withLock { nextGeneration++ latestRoomRequest++ binding = null + proof = null terminalGeneration = null _roomSnapshot.value = null _roomDeliveryEcho.value = null + _latestCommand.value = null + rawSuggestions = emptyList() _suggestions.value = emptyList() + _personalVotesKnown.value = false _roomClosedReason.value = null + _ended.value = null votedIds.clear() + clockEstimator.reset() + _clock.value = clockEstimator.estimate realtime = null realtimeGeneration = null realtimeConnectionId = null @@ -774,16 +1248,10 @@ class WatchTogetherRepository( } companion object { - /** Reconnect backoff steps (ms) — spec: not after room_closed. */ - val BACKOFF_MS = longArrayOf(500L, 1_000L, 2_000L, 5_000L) - - /** - * Maximum number of consecutive connection failures (e.g. throws during - * [connect] collection, factory/handshake errors) before the reconnect - * loop gives up. Reset to zero on any healthy server event. - */ - const val MAX_RECONNECT_ATTEMPTS = 6 - const val STABLE_CONNECTION_MS = 30_000L + private const val INVALID_RESPONSE = "invalid_response" + + /** Refusals that may mean the room ended or this membership lapsed; the room read decides. */ + private val RECONCILE_STATUSES = setOf(403, 404, 409) private val MONOTONIC_ORIGIN = TimeSource.Monotonic.markNow() } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/port/CatalogCachePort.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/port/CatalogCachePort.kt index 13093ecd47..c0cf818d00 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/port/CatalogCachePort.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/port/CatalogCachePort.kt @@ -14,6 +14,9 @@ import org.prairieserver.prairie.network.ApiResult */ data class CatalogCacheWriteLease(val identityGeneration: Long) +/** The durable cache owner, without credentials or a device session. */ +data class CatalogCacheScope(val serverId: String, val profileId: String) + /** * Offline read cache for catalog browse (Track B). Backs repository-level * cache-with-fallback in [org.prairieserver.prairie.repository.PersonalDataRepository] @@ -27,6 +30,9 @@ data class CatalogCacheWriteLease(val identityGeneration: Long) * network-only. Scope `(serverId, profileId)` is resolved inside the impl. */ interface CatalogCachePort { + /** Drop localized details, seasons, and episodes for the active profile. */ + suspend fun invalidateDetailMetadata(scope: CatalogCacheScope? = null) {} + /** V2 library-section cache entries require the exact initiating authority. */ suspend fun cacheLibrarySectionsV2(libraryId: Int, sections: List<ResolvedSection>, owner: org.prairieserver.prairie.network.AuthScopeSnapshot) {} suspend fun getCachedLibrarySectionsV2(libraryId: Int, owner: org.prairieserver.prairie.network.AuthScopeSnapshot): List<ResolvedSection>? = null diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/port/UserItemStatePort.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/port/UserItemStatePort.kt index 2dad758fe6..94b4e79d22 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/port/UserItemStatePort.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/port/UserItemStatePort.kt @@ -105,6 +105,19 @@ interface UserItemStatePort { /** Batched local resume snapshots for card/detail overlays. */ suspend fun localPlaybackProgressForContent(contentIds: List<String>): Map<String, LocalPlaybackProgress> = emptyMap() + /** + * Clears local resume samples recorded before [admittedAtMs] for [contentIds]. + * A season or series watched write names only its parent, so the episodes it + * reached keep their local progress unless the caller clears it here. + * [identityGeneration] is the write's; nothing is cleared once the active + * server, account, or profile has changed since. + */ + suspend fun clearLocalPlaybackProgressBefore( + contentIds: List<String>, + admittedAtMs: Long, + identityGeneration: Long, + ) {} + /** * Durably record per-file track selections. These are local-only hints for * future playback starts, stored as stable fingerprints rather than raw UI @@ -198,6 +211,9 @@ interface UserItemStatePort { * a cached card. Batched (one query). Empty map by default / unknown ids absent. */ suspend fun localContentStates(contentIds: List<String>): Map<String, LocalContentState> = emptyMap() + + /** Local writes that must take precedence over a server read started at [sinceMs]. */ + suspend fun contentIdsWithPendingOrNewerUserState(contentIds: List<String>, sinceMs: Long): Set<String> = emptySet() } /** Local optimistic content-level state; null fields = "no local opinion" (defer to server). */ diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/util/Rfc3339.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/util/Rfc3339.kt index d1217ac9a6..6e9b684df9 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/util/Rfc3339.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/util/Rfc3339.kt @@ -93,3 +93,34 @@ private fun daysFromCivil(year: Int, month: Int, day: Int): Long { val doe = yoe * 365 + yoe / 4 - yoe / 100 + doy return era * 146097L + doe - 719468L } + +/** + * Formats epoch milliseconds as a UTC RFC3339 timestamp with millisecond + * precision (`2026-06-12T09:30:00.123Z`), the inverse of + * [parseRfc3339ToEpochMillis] for the Watch Party clock-sync pings. + */ +fun formatEpochMillisRfc3339(epochMillis: Long): String { + val epochSecond = epochMillis.floorDiv(1_000L) + val millis = epochMillis.mod(1_000L).toInt() + val epochDay = epochSecond.floorDiv(86_400L) + val secondOfDay = epochSecond.mod(86_400L).toInt() + val (year, month, day) = civilFromDays(epochDay) + fun pad(value: Int, width: Int) = value.toString().padStart(width, '0') + return "${pad(year, 4)}-${pad(month, 2)}-${pad(day, 2)}T" + + "${pad(secondOfDay / 3_600, 2)}:${pad(secondOfDay / 60 % 60, 2)}:${pad(secondOfDay % 60, 2)}." + + "${pad(millis, 3)}Z" +} + +/** Proleptic-Gregorian civil date for days since 1970-01-01 (Hinnant). */ +private fun civilFromDays(epochDay: Long): Triple<Int, Int, Int> { + val z = epochDay + 719468L + val era = z.floorDiv(146097L) + val doe = z - era * 146097L + val yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365 + val doy = doe - (365 * yoe + yoe / 4 - yoe / 100) + val mp = (5 * doy + 2) / 153 + val day = (doy - (153 * mp + 2) / 5 + 1).toInt() + val month = (if (mp < 10) mp + 3 else mp - 9).toInt() + val year = (yoe + era * 400 + if (month <= 2) 1 else 0).toInt() + return Triple(year, month, day) +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/util/WallClock.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/util/WallClock.kt new file mode 100644 index 0000000000..bf181eb9c5 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/util/WallClock.kt @@ -0,0 +1,8 @@ +package org.prairieserver.prairie.util + +/** + * Current wall-clock time in epoch milliseconds. Room clock sampling compares + * it with server timestamps, which are wall-clock instants; local scheduling + * uses a monotonic source instead. + */ +expect fun wallClockMillis(): Long diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/CollectionsViewModel.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/CollectionsViewModel.kt index 0ed45471e4..fb8fabf0f0 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/CollectionsViewModel.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/CollectionsViewModel.kt @@ -6,6 +6,8 @@ import org.prairieserver.prairie.model.personal.Collection import org.prairieserver.prairie.model.personal.CollectionGroup import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.repository.CollectionRepository +import kotlinx.coroutines.Job +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -69,25 +71,32 @@ internal fun buildCollectionSections( class CollectionsViewModel( private val collectionRepository: CollectionRepository, ) : ViewModel() { + /** Access changes this ViewModel has applied, kept while its screen is away. */ + val accessChanges = org.prairieserver.prairie.network.AccessChangeCursor() private val _uiState = MutableStateFlow(CollectionsUiState()) val uiState: StateFlow<CollectionsUiState> = _uiState.asStateFlow() + // The newest load is the only one that publishes: a refresh after an + // access change replaces a load still waiting on the older policy. + // Declared before init, which starts the first load. + private var loadJob: Job? = null + init { loadCollections() } - fun loadCollections() { - viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, error = null) } - applyResult(collectionRepository.listCollections(), refreshing = false) - } - } + fun loadCollections() = load(refreshing = false) + + fun refresh() = load(refreshing = true) - fun refresh() { - viewModelScope.launch { - _uiState.update { it.copy(isRefreshing = true) } - applyResult(collectionRepository.listCollections(), refreshing = true) + private fun load(refreshing: Boolean) { + loadJob?.cancel() + loadJob = viewModelScope.launch { + _uiState.update { if (refreshing) it.copy(isRefreshing = true) else it.copy(isLoading = true, error = null) } + val result = collectionRepository.listCollections() + ensureActive() + applyResult(result, refreshing) } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/DevicePairingViewModel.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/DevicePairingViewModel.kt index 59b7049648..49b4228ff2 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/DevicePairingViewModel.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/DevicePairingViewModel.kt @@ -2,24 +2,158 @@ package org.prairieserver.prairie.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import org.prairieserver.prairie.model.auth.DeviceCodeFormat import org.prairieserver.prairie.model.auth.DeviceLoginLookupResponse import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.PrairieAuthUnavailableException +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.IdentityTransitionBarrier +import org.prairieserver.prairie.network.ServerRegistry +import org.prairieserver.prairie.network.TokenManager +import org.prairieserver.prairie.network.signedInEntries import org.prairieserver.prairie.repository.DeviceLoginRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +/** A saved, signed-in server a TV's code can be approved on. */ +data class DeviceApprovalServer( + val id: String, + val url: String, + val displayName: String, + val isActive: Boolean = false, +) + +/** + * The servers "Sign in a TV" can approve on, and the credentials each call + * runs with. Codes are unique only per server, so a typed code is looked up + * on one chosen server, never on every saved server. + */ +interface DeviceApprovalServers { + /** Signed-in servers, the active one first. */ + suspend fun list(): List<DeviceApprovalServer> + + /** The profileless account scope approvals on [server] run in. */ + fun scope(server: DeviceApprovalServer): AuthScopeSnapshot + + /** + * The account the approval would sign the TV in as, when known. Read + * before each lookup through [server]'s own scope, renewing its access + * token first when it has expired or is about to: the approver refreshes + * before looking up and approving (TV sign-in spec). Throws + * [PrairieAuthUnavailableException.PROVIDER_UNAVAILABLE] when that renewal + * was refused because the sign-in provider couldn't re-check the session. + */ + suspend fun accountName(server: DeviceApprovalServer): String? = null +} + +/** Production [DeviceApprovalServers] over the saved-server registry. */ +class RegistryDeviceApprovalServers( + private val registry: ServerRegistry, + private val tokenManager: TokenManager, + private val identityTransitions: IdentityTransitionBarrier, + /** The account signed in on a server, read through that server's own scope. */ + private val accountNameOf: suspend (AuthScopeSnapshot) -> String? = { null }, +) : DeviceApprovalServers { + override suspend fun list(): List<DeviceApprovalServer> { + val activeId = registry.activeServerId.value + return registry.signedInEntries(tokenManager) + .map { DeviceApprovalServer(it.id, it.url, it.displayName, isActive = it.id == activeId) } + } + + override fun scope(server: DeviceApprovalServer) = + AuthScopeSnapshot.profileless(server.id, server.url, identityTransitions.generation.value) + + override suspend fun accountName(server: DeviceApprovalServer): String? = accountNameOf(scope(server)) +} + +/** Why "Sign in a TV" can't go on; each screen maps these to its own strings. */ +sealed interface DevicePairingError { + data object EnterCode : DevicePairingError + data object SignInFirst : DevicePairingError + data object NotFound : DevicePairingError + + /** The code isn't on [serverName]; other saved servers are offered. */ + data class NotFoundOn(val serverName: String) : DevicePairingError + data object Expired : DevicePairingError + data object AlreadySignedIn : DevicePairingError + data object Declined : DevicePairingError + data object Canceled : DevicePairingError + data object Network : DevicePairingError + + /** + * The sign-in provider couldn't re-check this phone's session (503 + * `provider_unavailable` on its refresh). The session stays; try again. + */ + data object ProviderUnavailable : DevicePairingError + + /** The server's own explanation, or null for a generic failure. */ + data class Server(val message: String?) : DevicePairingError +} + +/** How the TV's request ended after this phone approved it. */ +enum class DeviceFollowOutcome { + /** The TV collected its sign-in. */ + SignedIn, + + /** The TV withdrew the request (it left its sign-in screen or signed in another way). */ + Canceled, + + /** The request expired before the TV collected it. */ + Expired, + + /** The request was declined elsewhere. */ + Denied, + + /** The TV hadn't collected it when following stopped. */ + TimedOut, +} + data class DevicePairingUiState( val token: String? = null, + /** The typed code without separators; shown grouped 4+4. */ val code: String = "", val lookup: DeviceLoginLookupResponse? = null, val isLoading: Boolean = false, val isSubmitting: Boolean = false, val completedStatus: String? = null, - val error: String? = null, + val error: DevicePairingError? = null, + /** Servers the code can be approved on; empty when only the active server is used. */ + val servers: List<DeviceApprovalServer> = emptyList(), + val selectedServerId: String? = null, + /** Account the approval signs the TV in as, when known. */ + val accountName: String? = null, + /** The selected server's account is being read. */ + val checkingAccount: Boolean = false, + /** + * Why the selected server's account couldn't be read, when the reason is + * known: the sign-in provider couldn't re-check the session + * ([DevicePairingError.ProviderUnavailable]). Null otherwise. + */ + val accountError: DevicePairingError? = null, + /** The chosen server answered "no such code": offer the others. */ + val notFound: Boolean = false, + /** After approving: how the TV's request ended, or null while it hasn't yet. */ + val followOutcome: DeviceFollowOutcome? = null, + /** + * A link named the server (`prairie://device?server=`): the code is looked + * up and approved there, through that server's own credentials, and + * nowhere else. No chooser, no "Try <server>". + */ + val serverLocked: Boolean = false, ) { + /** + * A `prairie://device?token=` link names no server, so its lookup and + * decision go to the active server: no chooser, no "Try <server>". + */ + val canChooseServer: Boolean + get() = token == null && !serverLocked && servers.size > 1 + /** * Whether approving or denying is a real, informed decision right now. * @@ -27,33 +161,100 @@ data class DevicePairingUiState( * deep link arrives with a token already set and starts its lookup * automatically, so "there is something to submit" is true from * construction — before the server has said which device is asking, from - * where, or with which match code. Those details are the entire content of - * the decision, and they only exist once [lookup] resolves. Gating on the + * where, or with which code. Those details are the entire content of the + * decision, and they only exist once [lookup] resolves. Gating on the * identifier let a viewer approve a sign-in they could not see, and kept * approving available after a failed lookup had cleared [lookup] and * reported the request invalid or expired. */ val canDecide: Boolean get() = lookup != null && !isSubmitting + + /** + * Whether the card can say which account approving signs the TV in as. + * Required on a saved server other than the active one: the approver + * sees exactly which account they are approving for (TV sign-in spec). + * The active server's account is the one this phone is using. + */ + val accountConfirmed: Boolean + get() = accountName != null || selectedServer?.isActive != false + + /** Reading the account on a non-active server failed: say so and offer a retry. */ + val accountUnconfirmed: Boolean + get() = !accountConfirmed && !checkingAccount + + /** Approving also needs the account known; declining doesn't. */ + val canApprove: Boolean + get() = canDecide && accountConfirmed + + val selectedServer: DeviceApprovalServer? + get() = servers.firstOrNull { it.id == selectedServerId } + + /** Other saved servers to try after [notFound]. */ + val otherServers: List<DeviceApprovalServer> + get() = servers.filter { it.id != selectedServerId } } +/** + * Approves (or declines) a TV's sign-in from the phone: by `prairie://device` + * link or by typing the TV's code. With [servers], the code is looked up and + * approved on the chosen server through its own account scope (the active + * server is preselected); without, on the active server. + * + * [lockServer]: a link named [initialServerId], so approving happens on that + * saved server's own account scope without switching the phone's active + * server (prairie-apple parity). When this phone isn't signed in there the + * screen asks to sign in rather than trying another server. + */ class DevicePairingViewModel( private val repository: DeviceLoginRepository, initialToken: String?, initialCode: String?, + private val servers: DeviceApprovalServers? = null, + private val initialServerId: String? = null, + lockServer: Boolean = false, ) : ViewModel() { private val _uiState = MutableStateFlow( DevicePairingUiState( token = initialToken?.takeIf { it.isNotBlank() }, - code = initialCode.orEmpty(), + code = DeviceCodeFormat.normalize(initialCode.orEmpty()), + // Only a code can be looked up on a named server's own scope; a + // token link always goes to the active server. + serverLocked = lockServer && servers != null && initialServerId != null && initialToken.isNullOrBlank(), ), ) val uiState: StateFlow<DevicePairingUiState> = _uiState.asStateFlow() + private var followJob: Job? = null + init { - if (_uiState.value.token != null || _uiState.value.code.isNotBlank()) { - lookup() + val hasRequest = _uiState.value.token != null || _uiState.value.code.isNotBlank() + if (servers == null) { + if (hasRequest) lookup() + } else { + viewModelScope.launch { + val list = servers.list() + val locked = _uiState.value.serverLocked + // A token link is looked up on the active server, so that's the + // one the card names. A locked link never falls back to another + // server: its code means nothing there. + val named = list.firstOrNull { it.id == initialServerId && _uiState.value.token == null } + val selected = when { + // Never another server: the token goes to the active one. + _uiState.value.token != null -> list.firstOrNull { it.isActive } + locked -> named + else -> named ?: list.firstOrNull { it.isActive } ?: list.firstOrNull() + } + _uiState.update { it.copy(servers = list, selectedServerId = selected?.id) } + when { + locked && selected == null -> + _uiState.update { it.copy(error = DevicePairingError.SignInFirst) } + // The lookup reads the account first. + hasRequest -> lookup() + else -> loadAccountName() + } + } } } @@ -65,12 +266,36 @@ class DevicePairingViewModel( lookupGeneration++ _uiState.update { it.copy( - code = value.trim().uppercase(), + code = DeviceCodeFormat.normalize(value).take(DeviceCodeFormat.LENGTH), lookup = null, completedStatus = null, error = null, + notFound = false, + ) + } + } + + /** Choose the server to look the code up on; looks it up again when one was typed. */ + fun selectServer(serverId: String) { + if (!_uiState.value.canChooseServer) return + if (_uiState.value.servers.none { it.id == serverId }) return + lookupGeneration++ + _uiState.update { + it.copy( + selectedServerId = serverId, + accountName = null, + accountError = null, + lookup = null, + error = null, + notFound = false, + isLoading = false, ) } + if (_uiState.value.code.isNotBlank() || _uiState.value.token != null) { + lookup() + } else { + viewModelScope.launch { loadAccountName() } + } } /** @@ -92,19 +317,33 @@ class DevicePairingViewModel( val token = current.token?.takeIf { it.isNotBlank() } val code = current.code.takeIf { it.isNotBlank() } if (token == null && code == null) { - _uiState.update { it.copy(error = "Enter the code shown on your TV.") } + _uiState.update { it.copy(error = DevicePairingError.EnterCode) } + return + } + + if (current.serverLocked && current.selectedServer == null) { + _uiState.update { it.copy(error = DevicePairingError.SignInFirst) } return } val generation = ++lookupGeneration loadingOwner = generation + // The server chosen when the lookup started; choosing another retires it. + val server = current.selectedServer viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, error = null, completedStatus = null) } - val lookupResult = repository.lookup(token = token, code = code) - // Retired while in flight: a newer lookup or, more importantly, a - // decision has superseded this answer. Clearing isLoading is still - // this request's job, but nothing else it has to say is current. - if (generation != lookupGeneration) { + _uiState.update { it.copy(isLoading = true, error = null, completedStatus = null, notFound = false) } + // Read the account first: it renews the chosen server's token when + // it is expiring, and the card names who approving signs in. + if (servers != null) loadAccountName() + // Retired while the account was read (another server chosen, a + // decision made): send nothing, since a lookup marks the request + // opened on whichever server receives it. + val lookupResult = if (generation == lookupGeneration) requestLookup(token, code, server) else null + // Retired before or while in flight: a newer lookup or, more + // importantly, a decision has superseded this answer. Clearing + // isLoading is still this request's job, but nothing else it has + // to say is current. + if (lookupResult == null || generation != lookupGeneration) { // Clear the loading flag only while this lookup still owns it. // A newer lookup has raised it again for itself, and clearing // it here would report that one as finished while it runs. @@ -113,10 +352,15 @@ class DevicePairingViewModel( } return@launch } - when (val result = lookupResult) { + when (val result: ApiResult<DeviceLoginLookupResponse> = lookupResult) { is ApiResult.Success -> { + val problem = statusProblem(result.data.status) _uiState.update { - it.copy(isLoading = false, lookup = result.data, error = null) + if (problem == null) { + it.copy(isLoading = false, lookup = result.data, error = null) + } else { + it.copy(isLoading = false, lookup = null, error = problem) + } } } is ApiResult.Error -> { @@ -124,13 +368,14 @@ class DevicePairingViewModel( it.copy( isLoading = false, lookup = null, - error = pairingError(result.code, result.message), + error = pairingError(result, it), + notFound = result.code == 404 && it.canChooseServer, ) } } is ApiResult.NetworkError -> { _uiState.update { - it.copy(isLoading = false, error = "Network error. Check your connection.") + it.copy(isLoading = false, error = networkError(result)) } } } @@ -141,6 +386,12 @@ class DevicePairingViewModel( decide(approve = true) } + /** "Try again" after the selected server's account couldn't be read. */ + fun retryAccount() { + if (_uiState.value.checkingAccount) return + viewModelScope.launch { loadAccountName() } + } + fun deny() { decide(approve = false) } @@ -150,9 +401,15 @@ class DevicePairingViewModel( val token = current.token?.takeIf { it.isNotBlank() } val code = current.code.takeIf { it.isNotBlank() } if (token == null && code == null) { - _uiState.update { it.copy(error = "Enter the code shown on your TV.") } + _uiState.update { it.copy(error = DevicePairingError.EnterCode) } + return + } + if (current.serverLocked && current.selectedServer == null) { + _uiState.update { it.copy(error = DevicePairingError.SignInFirst) } return } + // Never sign the TV in to an account the card couldn't name. + if (approve && !current.accountConfirmed) return // Retires any lookup already running, before it can report back over // the decision this is about to make. The retired lookup will not clear @@ -160,13 +417,10 @@ class DevicePairingViewModel( // it here — otherwise the screen shows a spinner nothing will finish. lookupGeneration++ _uiState.update { it.copy(isLoading = false) } + val server = current.selectedServer viewModelScope.launch { - _uiState.update { it.copy(isSubmitting = true, error = null, completedStatus = null) } - val result = if (approve) { - repository.approve(token = token, code = code) - } else { - repository.deny(token = token, code = code) - } + _uiState.update { it.copy(isSubmitting = true, error = null, completedStatus = null, followOutcome = null) } + val result = requestDecision(approve, token, code, server) when (result) { is ApiResult.Success -> { _uiState.update { @@ -176,29 +430,145 @@ class DevicePairingViewModel( error = null, ) } + if (approve) followTv(token, code, server) } is ApiResult.Error -> { _uiState.update { it.copy( isSubmitting = false, - error = pairingError(result.code, result.message), + error = pairingError(result, it), ) } } is ApiResult.NetworkError -> { _uiState.update { - it.copy(isSubmitting = false, error = "Network error. Check your connection.") + it.copy(isSubmitting = false, error = networkError(result)) } } } } } - private fun pairingError(code: Int, message: String): String = when (code) { - 401 -> "Sign in before approving this device." - 404 -> "This device sign-in request was not found." - 409 -> message.ifBlank { "This device sign-in request is no longer active." } - 410 -> "This device sign-in request has expired." - else -> message.ifBlank { "Device pairing failed." } + /** + * After approving, follow the request until the TV collects its sign-in + * (or it ends another way), so the result can move from "Your TV is + * signing in" to what happened. Best effort and bounded; it never changes + * the decision's outcome. + */ + private fun followTv(token: String?, code: String?, server: DeviceApprovalServer?) { + followJob?.cancel() + followJob = viewModelScope.launch { + repeat(FOLLOW_ATTEMPTS) { + delay(FOLLOW_INTERVAL_MS) + val status = (requestLookup(token, code, server) as? ApiResult.Success)?.data?.status ?: return@repeat + val outcome = when (status) { + "consumed" -> DeviceFollowOutcome.SignedIn + "canceled" -> DeviceFollowOutcome.Canceled + "expired" -> DeviceFollowOutcome.Expired + "denied" -> DeviceFollowOutcome.Denied + else -> null + } + if (outcome != null) { + _uiState.update { it.copy(followOutcome = outcome) } + return@launch + } + } + _uiState.update { it.copy(followOutcome = DeviceFollowOutcome.TimedOut) } + } + } + + private suspend fun requestLookup( + token: String?, + code: String?, + server: DeviceApprovalServer?, + ): ApiResult<DeviceLoginLookupResponse> { + return if (token == null && code != null && server != null && servers != null) { + repository.lookup(servers.scope(server), code) + } else { + repository.lookup(token = token, code = code) + } + } + + private suspend fun requestDecision( + approve: Boolean, + token: String?, + code: String?, + server: DeviceApprovalServer?, + ): ApiResult<org.prairieserver.prairie.model.auth.DeviceLoginDecisionResponse> { + return if (token == null && code != null && server != null && servers != null) { + val scope = servers.scope(server) + if (approve) repository.approve(scope, code) else repository.deny(scope, code) + } else if (approve) { + repository.approve(token = token, code = code) + } else { + repository.deny(token = token, code = code) + } + } + + private suspend fun loadAccountName() { + val server = _uiState.value.selectedServer ?: return + _uiState.update { it.copy(checkingAccount = true) } + var providerUnavailable = false + val name = try { + servers?.accountName(server) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (e: Exception) { + providerUnavailable = PrairieAuthUnavailableException.isProviderUnavailable(e) + null + } + _uiState.update { + if (it.selectedServerId != server.id) return@update it.copy(checkingAccount = false) + it.copy( + checkingAccount = false, + accountName = name, + accountError = DevicePairingError.ProviderUnavailable.takeIf { providerUnavailable }, + ) + } + } + + /** A lookup that answered, but for a request that can't be decided any more. */ + private fun statusProblem(status: String): DevicePairingError? = when (status) { + "pending" -> null + "approved", "consumed" -> DevicePairingError.AlreadySignedIn + "denied" -> DevicePairingError.Declined + "canceled" -> DevicePairingError.Canceled + "expired" -> DevicePairingError.Expired + // A status this app doesn't know: don't offer a decision on it. + else -> DevicePairingError.Server(null) + } + + /** An exchange that never got an answer; a provider outage keeps the session and says so. */ + private fun networkError(result: ApiResult.NetworkError): DevicePairingError = + if (PrairieAuthUnavailableException.isProviderUnavailable(result.exception)) { + DevicePairingError.ProviderUnavailable + } else { + DevicePairingError.Network + } + + private fun pairingError(error: ApiResult.Error, state: DevicePairingUiState): DevicePairingError = + if (error.code == 503 && error.error == PrairieAuthUnavailableException.PROVIDER_UNAVAILABLE_PROBLEM) { + DevicePairingError.ProviderUnavailable + } else { + pairingError(error.code, error.message, state) + } + + private fun pairingError(code: Int, message: String, state: DevicePairingUiState): DevicePairingError = when (code) { + 401 -> DevicePairingError.SignInFirst + 404 -> state.selectedServer?.takeIf { state.canChooseServer }?.let { DevicePairingError.NotFoundOn(it.displayName) } + ?: DevicePairingError.NotFound + 409 -> DevicePairingError.AlreadySignedIn + 410 -> DevicePairingError.Expired + else -> DevicePairingError.Server(message.takeIf { it.isNotBlank() }) + } + + override fun onCleared() { + followJob?.cancel() + super.onCleared() + } + + private companion object { + const val FOLLOW_ATTEMPTS = 40 + const val FOLLOW_INTERVAL_MS = 3_000L } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModel.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModel.kt index d627cee9a8..4cf73d4bbb 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModel.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModel.kt @@ -21,6 +21,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.isActive @@ -66,6 +68,7 @@ class HomeViewModel( private val homeRealtime: org.prairieserver.prairie.repository.HomeRealtimeCoordinator? = null, private val identityTransitions: IdentityTransitionBarrier = DefaultIdentityTransitionBarrier(), private val diagnostics: HomeDiagnosticsObserver = HomeDiagnosticsObserver.None, + private val catalogLanguage: org.prairieserver.prairie.domain.settings.CatalogLanguageRevision? = null, ) : ViewModel() { private var displayedOwner: AuthScopeSnapshot? = null @@ -94,6 +97,20 @@ class HomeViewModel( init { loadSections() + catalogLanguage?.let { language -> + viewModelScope.launch { + // A metadata-language change re-localizes every card: re-read + // quietly. Unlike refreshFromRealtime this never skips, since a + // fetch already in flight still asked for the old language. + language.revision.drop(1).collectLatest { + val generation = fetchSections(HomeLoadTrigger.REALTIME) + if (generation == fetchGeneration) { + _uiState.update { it.copy(isRefreshing = false) } + runPendingRealtimeRefresh() + } + } + } + } homeRealtime?.let { coordinator -> viewModelScope.launch { coordinator.refreshSignals.collect { refreshFromRealtime() } @@ -103,6 +120,14 @@ class HomeViewModel( private var realtimeRefreshInFlight = false + /** + * A realtime signal arrived while a fetch was running. That fetch may have + * been answered before the event behind the signal (an access change made + * after the request went out), so one more realtime refresh runs when it + * finishes. + */ + private var realtimeRefreshPending = false + /** * Bumped by every fetch, checked before any of them publishes. * @@ -117,12 +142,13 @@ class HomeViewModel( private var fetchGeneration = 0 /** - * Debounced realtime refetch: quiet (no spinner) and single-flight — - * an in-flight realtime or manual refresh already delivers the fresh - * sections, so overlapping signals are dropped rather than raced. + * Debounced realtime refetch: quiet (no spinner) and single-flight. + * Signals that arrive while a realtime or manual refresh is running are + * not raced against it; they collapse into one follow-up refresh after it. */ fun refreshFromRealtime() { if (realtimeRefreshInFlight || _uiState.value.isRefreshing) { + realtimeRefreshPending = true diagnostics.completed( HomeLoadObservation( trigger = HomeLoadTrigger.REALTIME, @@ -143,9 +169,16 @@ class HomeViewModel( } finally { realtimeRefreshInFlight = false } + runPendingRealtimeRefresh() } } + private fun runPendingRealtimeRefresh() { + if (!realtimeRefreshPending) return + realtimeRefreshPending = false + refreshFromRealtime() + } + fun loadSections() { viewModelScope.launch { // Stale-while-revalidate: serve the cached home instantly (offline- @@ -213,6 +246,7 @@ class HomeViewModel( // it fires a redundant request. if (generation == fetchGeneration) { _uiState.update { it.copy(isRefreshing = false) } + runPendingRealtimeRefresh() } } } @@ -273,7 +307,15 @@ class HomeViewModel( ), ) } - when (val result = sectionRepository.getHomeSections(owner)) { + // The first load may join the startup warmup's read; refreshes always read anew. + // Only with no acknowledged membership change: a joined read may predate one, and + // must not vouch for it through the witnesses captured above. + val read = if (trigger == HomeLoadTrigger.INITIAL && membershipWitnesses.isEmpty()) { + sectionRepository.getHomeSectionsJoiningRead(owner) + } else { + sectionRepository.getHomeSections(owner) + } + when (val result = read) { is ApiResult.Success -> { val sections = result.data.sections // `/home/sections` already returns each section with its items @@ -297,23 +339,11 @@ class HomeViewModel( // Don't persist a partially-resolved home over a good cached one. val fullyResolved = hydration.fullyResolved - // Cache the RAW server sections (snapshot), but display with the - // local optimistic overlay applied. - if ( - fullyResolved && - // A superseded fetch must not write its sections to the - // cache either: the next cold start would serve them. - generation == fetchGeneration && - requestIdentityGeneration == identityTransitions.generation.value - ) { - homeCache.cacheHomeV2(resolved, owner) { generation == fetchGeneration && displayedOwner == owner } - } val overlaid = overlayLocalState(resolved) - // Checked AGAIN, after the cache write and the overlay. Both - // suspend, and a newer fetch can complete and publish during - // either — so a check taken before them proves only that this - // reply was current when it arrived, not that it still is when - // it finally writes. + // Checked AGAIN, after the overlay. It suspends, and a newer + // fetch can complete and publish meanwhile — so a check taken + // before it proves only that this reply was current when it + // arrived, not that it still is when it finally writes. if (!mayPublish(owner, generation)) { report(HomeLoadOutcome.SUPERSEDED, resolved) return generation @@ -340,6 +370,19 @@ class HomeViewModel( it.copy(isLoading = false, error = null) } } + // Cache the RAW server sections (snapshot); the screen shows them + // with the local overlay. Written after publishing: with 100-item + // rows the snapshot is about 1 MB, and encoding it would hold the + // rows back from the screen. + if ( + fullyResolved && + // A superseded fetch must not write its sections to the + // cache either: the next cold start would serve them. + generation == fetchGeneration && + requestIdentityGeneration == identityTransitions.generation.value + ) { + homeCache.cacheHomeV2(resolved, owner) { generation == fetchGeneration && displayedOwner == owner } + } } is ApiResult.Error -> { // A superseded fetch's failure is not this home's failure. @@ -460,8 +503,9 @@ private fun List<ResolvedSection>.mapItem( else section.copy(items = section.items.map { if (it.contentId == itemId) transform(it) else it }) } +/** Watched and unwatched both reset resume progress, as the server does. */ private fun SectionItem.withPlayed(played: Boolean): SectionItem = - copy(userState = (userState ?: MediaItemUserState()).copy(played = played)) + copy(userState = (userState ?: MediaItemUserState()).copy(played = played), positionSeconds = 0.0) private fun SectionItem.withFavorite(favorite: Boolean): SectionItem = copy(userState = (userState ?: MediaItemUserState()).copy(isFavorite = favorite)) diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/RecommendationsViewModel.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/RecommendationsViewModel.kt index 951bba37d3..7ab2d19c79 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/RecommendationsViewModel.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/RecommendationsViewModel.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch data class RecommendationsUiState( @@ -27,6 +28,7 @@ data class RecommendationsUiState( class RecommendationsViewModel( private val recommendationRepository: RecommendationRepository, + catalogLanguage: org.prairieserver.prairie.domain.settings.CatalogLanguageRevision? = null, ) : ViewModel() { private val _uiState = MutableStateFlow(RecommendationsUiState()) @@ -35,7 +37,13 @@ class RecommendationsViewModel( private var loadGeneration = 0L private var loadJob: kotlinx.coroutines.Job? = null - init { loadRecommendations() } + init { + loadRecommendations() + catalogLanguage?.let { language -> + // Re-read the localized cards after a metadata-language change. + viewModelScope.launch { language.revision.drop(1).collect { refresh() } } + } + } fun loadRecommendations() = startLoad(refreshing = false) fun refresh() = startLoad(refreshing = true) diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/RequestApprovalsViewModel.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/RequestApprovalsViewModel.kt new file mode 100644 index 0000000000..87c16e1f5c --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/RequestApprovalsViewModel.kt @@ -0,0 +1,246 @@ +package org.prairieserver.prairie.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.TimeSource +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.prairieserver.prairie.model.request.AdminRequestAction +import org.prairieserver.prairie.model.request.MediaRequest +import org.prairieserver.prairie.model.request.RequestActionHold +import org.prairieserver.prairie.model.request.RequestActionCopy +import org.prairieserver.prairie.model.request.RequestDisplayState +import org.prairieserver.prairie.model.request.RequestMutationFailure +import org.prairieserver.prairie.model.request.RequestOutcome +import org.prairieserver.prairie.model.request.RequestRowActionPhase +import org.prairieserver.prairie.model.request.RequestStatus +import org.prairieserver.prairie.model.request.requestInstantKey +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.errorMessage +import org.prairieserver.prairie.repository.RequestsRepository + +data class RequestApprovalsUiState( + /** Everyone's requests waiting on a decision, oldest first. */ + val awaitingApproval: List<MediaRequest> = emptyList(), + /** Failed requests that can be retried, most recently updated first. */ + val failed: List<MediaRequest> = emptyList(), + val hasLoaded: Boolean = false, + val isRefreshing: Boolean = false, + val error: String? = null, + /** Inline message for an action whose result is unknown; cleared when it settles. */ + val actionErrorMessage: String? = null, + /** Per-row animation state for actions in flight or just finished. */ + val phases: Map<String, RequestRowActionPhase> = emptyMap(), + /** Rows whose decision was sent without a usable answer; they offer nothing until it settles. */ + val heldIds: Set<String> = emptySet(), + /** A failed action's reason, shown on its own row. */ + val rowErrors: Map<String, String> = emptyMap(), + /** Per-row count of outright failures; the row's button shakes when it changes. */ + val failureCounts: Map<String, Int> = emptyMap(), + /** Bumped on every accepted action, for the success haptic. */ + val completedActions: Int = 0, + /** Bumped on every outright failure, for the error haptic. */ + val failedActions: Int = 0, + /** The newest outright failure's message, for surfaces without per-row error lines (TV). */ + val lastActionError: String? = null, +) { + val isEmpty: Boolean get() = hasLoaded && awaitingApproval.isEmpty() && failed.isEmpty() + + /** Whether a row may offer its decision: not while its own action runs or is held. */ + fun canAct(request: MediaRequest): Boolean = request.id !in phases && request.id !in heldIds +} + +/** + * The admin approval queue: everyone's requests waiting on a decision, and + * failed ones that can be retried. Only reachable when + * [org.prairieserver.prairie.model.feature.RequestsFeatureStore.canModerate] is true. + */ +class RequestApprovalsViewModel( + private val repository: RequestsRepository, + private val holdLifetime: Duration = RequestActionHold.Lifetime, + private val timeSource: TimeSource = TimeSource.Monotonic, + /** Loads on creation; the TV hub loads only once moderation is confirmed. */ + loadOnInit: Boolean = true, +) : ViewModel() { + + private val _uiState = MutableStateFlow(RequestApprovalsUiState()) + val uiState: StateFlow<RequestApprovalsUiState> = _uiState.asStateFlow() + + private val holds = mutableMapOf<String, RequestActionHold>() + private var loadGeneration = 0 + /** Counts decisions; a read keeps out a request decided after it started. */ + private var decisionCount = 0L + private val decidedAt = mutableMapOf<String, Long>() + + init { + if (loadOnInit) load() + viewModelScope.launch { + // A decision made elsewhere (the detail page) takes the request out + // of whichever queue still lists it. + repository.lastModeration.drop(1).filterNotNull().collect { applyModeration(it.record) } + } + } + + fun load() { + viewModelScope.launch { fetch() } + } + + fun refresh() { + viewModelScope.launch { + _uiState.update { it.copy(isRefreshing = true) } + fetch() + _uiState.update { it.copy(isRefreshing = false) } + } + } + + /** Reads both queues; returns whether they read. A newer read, started meanwhile, wins. */ + suspend fun fetch(): Boolean { + val generation = ++loadGeneration + val startedAfter = decisionCount + _uiState.update { it.copy(error = null) } + val pending = viewModelScope.async { + repository.adminRequests(status = RequestStatus.Pending, outcome = RequestOutcome.Active) + } + val broken = repository.adminRequests(outcome = RequestOutcome.Failed) + val waiting = pending.await() + if (generation != loadGeneration) return false + if (waiting is ApiResult.Success && broken is ApiResult.Success) { + // The server may have answered before a decision made while it ran. + fun undecided(request: MediaRequest): Boolean = (decidedAt[request.id] ?: Long.MIN_VALUE) <= startedAfter + decidedAt.values.removeAll { it <= startedAfter } + val awaiting = waiting.data.filter(::undecided).sortedBy { it.createdAt.requestInstantKey() } + val failed = broken.data.filter(::undecided).sortedByDescending { it.updatedAt.requestInstantKey() } + val listed = awaiting + failed + repository.cache.storeModerationRecords(listed) + repository.prefetch(listed) + // Release the holds this read settles; an unchanged request keeps its hold. + holds.entries.removeAll { (id, hold) -> hold.isSettled(listed.firstOrNull { it.id == id }) } + _uiState.update { + it.copy( + awaitingApproval = awaiting, + failed = failed, + hasLoaded = true, + heldIds = holds.keys.toSet(), + // A fresh read is the new word on every row. + rowErrors = it.rowErrors.filterKeys { id -> id in phasesOrHeld(it) }, + ).clearingUnconfirmedMessageIfSettled() + } + return true + } + val failure = if (waiting is ApiResult.Success) broken else waiting + _uiState.update { + if (it.hasLoaded) it else it.copy(hasLoaded = true, error = failure.errorMessage("Failed to load requests")) + } + return false + } + + fun perform(action: AdminRequestAction, request: MediaRequest) { + if (!_uiState.value.canAct(request)) return + _uiState.update { + it.copy( + phases = it.phases + (request.id to RequestRowActionPhase.Working(action)), + rowErrors = it.rowErrors - request.id, + actionErrorMessage = null, + ) + } + viewModelScope.launch { + val result = repository.adminAction(request.id, action) + when { + result is ApiResult.Success -> { + recordDecision(request.id) + // Show the result on the row, then let the row leave. + _uiState.update { + it.copy( + phases = it.phases + (request.id to RequestRowActionPhase.Succeeded(action)), + completedActions = it.completedActions + 1, + ) + } + delay(ResultHold) + _uiState.update { + it.copy( + awaitingApproval = it.awaitingApproval.filterNot { r -> r.id == request.id }, + failed = it.failed.filterNot { r -> r.id == request.id }, + phases = it.phases - request.id, + ) + } + } + RequestMutationFailure.isUncertain(result) -> { + // Never resend: hold the row until a fresh read shows the result. + _uiState.update { it.copy(phases = it.phases - request.id) } + hold(request) + fetch() + if (holds.isNotEmpty()) { + _uiState.update { it.copy(actionErrorMessage = RequestActionCopy.UnconfirmedModeration) } + } + } + else -> { + val message = RequestActionCopy.failure(result, "Something went wrong") + _uiState.update { + it.copy( + phases = it.phases - request.id, + failureCounts = it.failureCounts + (request.id to ((it.failureCounts[request.id] ?: 0) + 1)), + failedActions = it.failedActions + 1, + rowErrors = it.rowErrors + (request.id to message), + lastActionError = message, + ) + } + } + } + } + } + + /** + * Holds the row, and ends the hold when its lifetime runs out even if no + * read settles it. Only this hold: a newer one for the same request keeps + * its own clock. + */ + private fun hold(request: MediaRequest) { + val hold = RequestActionHold(request, timeSource, holdLifetime) + holds[request.id] = hold + _uiState.update { it.copy(heldIds = holds.keys.toSet()) } + viewModelScope.launch { + delay(holdLifetime) + if (holds[request.id] !== hold) return@launch + holds.remove(request.id) + _uiState.update { it.copy(heldIds = holds.keys.toSet()).clearingUnconfirmedMessageIfSettled() } + fetch() + } + } + + /** Rows whose state the last action still owns; their error lines survive a read. */ + private fun phasesOrHeld(state: RequestApprovalsUiState): Set<String> = state.phases.keys + state.heldIds + + private fun RequestApprovalsUiState.clearingUnconfirmedMessageIfSettled(): RequestApprovalsUiState = + if (heldIds.isEmpty() && actionErrorMessage == RequestActionCopy.UnconfirmedModeration) copy(actionErrorMessage = null) else this + + private fun recordDecision(id: String) { + decidedAt[id] = ++decisionCount + } + + private fun applyModeration(record: MediaRequest) { + // This list's own action removes its row after the result shows. + if (record.id in _uiState.value.phases) return + val pendingNow = RequestDisplayState.of(record) == RequestDisplayState.Pending + if (!pendingNow) recordDecision(record.id) + _uiState.update { + it.copy( + awaitingApproval = it.awaitingApproval.filterNot { r -> r.id == record.id && !pendingNow }, + failed = it.failed.filterNot { r -> r.id == record.id }, + ) + } + } + + companion object { + /** How long a finished row shows its result before leaving the list. */ + val ResultHold: Duration = 900.milliseconds + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/RequestDetailViewModel.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/RequestDetailViewModel.kt new file mode 100644 index 0000000000..a9cf902839 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/RequestDetailViewModel.kt @@ -0,0 +1,550 @@ +package org.prairieserver.prairie.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlin.time.Duration +import kotlin.time.TimeSource +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.prairieserver.prairie.model.feature.RequestsFeatureStore +import org.prairieserver.prairie.model.request.AdminRequestAction +import org.prairieserver.prairie.model.request.CreateMediaRequest +import org.prairieserver.prairie.model.request.MediaRequest +import org.prairieserver.prairie.model.request.RequestActionHold +import org.prairieserver.prairie.model.request.RequestActionCopy +import org.prairieserver.prairie.model.request.RequestAttention +import org.prairieserver.prairie.model.request.RequestDisplayState +import org.prairieserver.prairie.model.request.RequestMediaDetail +import org.prairieserver.prairie.model.request.RequestMediaResult +import org.prairieserver.prairie.model.request.RequestMediaType +import org.prairieserver.prairie.model.request.RequestMutationFailure +import org.prairieserver.prairie.model.request.RequestOutcome +import org.prairieserver.prairie.model.request.RequestProgress +import org.prairieserver.prairie.model.request.RequestStatus +import org.prairieserver.prairie.model.request.RequestUnconfirmedToken +import org.prairieserver.prairie.model.request.toRequestState +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.errorMessage +import org.prairieserver.prairie.repository.RequestDetailCache +import org.prairieserver.prairie.repository.RequestsRepository + +/** + * The single primary action on a request detail page, computed from server + * state on every read so the button never disagrees with the server. The UI + * renders exactly one button whose label and action vary by case, so TV focus + * stays put as it morphs. + */ +sealed interface RequestPrimaryAction { + data object Loading : RequestPrimaryAction + /** The one-tap request. */ + data object Request : RequestPrimaryAction + /** Submit in flight: same button, relabeled. */ + data object Submitting : RequestPrimaryAction + /** A request exists (or the title is blocked); not interactive. */ + data class Status(val state: RequestDisplayState) : RequestPrimaryAction + /** Already in the library: the button opens the item. */ + data class OpenInLibrary(val contentId: String) : RequestPrimaryAction + + val isInteractive: Boolean get() = this == Request || this is OpenInLibrary +} + +data class RequestDetailUiState( + val detail: RequestMediaDetail? = null, + /** False while the page shows a cached or provisional first frame. */ + val hasFreshDetail: Boolean = false, + /** The signed-in user's own record for this title: stage timestamps, targets, and the id to cancel. */ + val record: MediaRequest? = null, + /** A pending or failed request for this title the signed-in admin can decide on. */ + val moderationRecord: MediaRequest? = null, + /** Opened from an approval queue: the page describes that exact request. */ + val openedForModeration: Boolean = false, + val isLoading: Boolean = false, + val error: String? = null, + val isSubmitting: Boolean = false, + /** A create was sent but its outcome is unknown; the action stays held until a read shows a request, or the hold lapses. */ + val isSubmissionUnconfirmed: Boolean = false, + val isCancelling: Boolean = false, + /** A cancel was sent without a usable answer; Cancel stays hidden until the request changes, or the hold lapses. */ + val isCancelUnconfirmed: Boolean = false, + val isModerating: Boolean = false, + /** A decision was sent without a usable answer; the buttons stay hidden until it settles. */ + val isModerationUnconfirmed: Boolean = false, + /** Inline message near the action for a failed create, cancel, or decision. */ + val actionErrorMessage: String? = null, + /** Bumped when the server accepts a new request, for the success haptic. */ + val submittedCount: Int = 0, + /** Bumped on every accepted admin decision, for the success haptic. */ + val moderatedCount: Int = 0, +) { + val primaryAction: RequestPrimaryAction + get() { + val detail = detail ?: return RequestPrimaryAction.Loading + // The same state as the title's card: a title in the library opens + // only when no active request says otherwise. + val state = RequestDisplayState.of(detail.availability, detail.request) + state?.libraryItemToOpen(detail.libraryContentId)?.let { return RequestPrimaryAction.OpenInLibrary(it) } + if (isSubmitting) return RequestPrimaryAction.Submitting + if (isSubmissionUnconfirmed) { + return RequestPrimaryAction.Status(RequestDisplayState.Unavailable(RequestUnconfirmedToken)) + } + val moderation = moderationRecord + if (openedForModeration && moderation != null) { + return RequestPrimaryAction.Status(RequestDisplayState.of(moderation)) + } + // A failed or declined request no longer blocks a new one: when the + // server calls the title requestable, the status shows on the card + // and progress, and the action stays Request (Again). + val endedButRequestable = state is RequestDisplayState.NeedsAttention && detail.request.requestable + if (state != null && !endedButRequestable) return RequestPrimaryAction.Status(state) + // The title annotation drops a request whose download finished but + // hasn't reached the library, and would offer a duplicate request; + // the user's own record still knows it's on the way. + activeRecordState?.let { return RequestPrimaryAction.Status(it) } + // A failed request no longer blocks a new one, so the server calls + // the title requestable. An admin who can retry it does that instead. + val ended = endedRequest + if (ended != null && isRetryableByAdmin) return RequestPrimaryAction.Status(RequestDisplayState.of(ended)) + return RequestPrimaryAction.Request + } + + /** + * The request for this title that ended without landing (failed or + * declined), when that's the latest word on it: the user's own, or the one + * an admin is deciding on. + */ + val endedRequest: MediaRequest? + get() { + val order = if (openedForModeration) listOf(moderationRecord, currentOwnRecord) else listOf(currentOwnRecord, moderationRecord) + return order.filterNotNull().firstOrNull { RequestDisplayState.of(it) is RequestDisplayState.NeedsAttention } + } + + /** + * Stage-track state for a request that exists: the user's record when they + * have one, an admin's view of someone else's, otherwise the title + * annotation. Null for a title nobody has requested. + */ + val progress: RequestProgress? + get() { + val own = currentOwnRecord + val moderation = moderationRecord + if (moderation != null && (openedForModeration || own == null)) return RequestProgress.of(moderation) + if (own != null && (activeRecordState != null || endedRequest?.id == own.id || detail?.request?.requestId == own.id)) { + return RequestProgress.of(own) + } + val detail = detail ?: return null + return RequestProgress.of(detail.availability, detail.request) + ?.takeUnless { it.display is RequestDisplayState.Unavailable } + } + + /** The request whose steps and timestamps the page shows. */ + val displayedRecord: MediaRequest? + get() = if (openedForModeration && moderationRecord != null) moderationRecord else record ?: moderationRecord + + /** Cancel is the requester's action, offered while the request is still pending. */ + val canCancel: Boolean + get() { + val record = record ?: return false + if (openedForModeration || isCancelling || isCancelUnconfirmed) return false + return RequestDisplayState.of(record).isCancelable + } + + /** The decisions the admin can take on [moderationRecord]. */ + val moderationActions: List<AdminRequestAction> + get() { + val moderation = moderationRecord ?: return emptyList() + if (isModerating || isModerationUnconfirmed) return emptyList() + return when (val state = RequestDisplayState.of(moderation)) { + RequestDisplayState.Pending -> listOf(AdminRequestAction.Approve, AdminRequestAction.Decline) + is RequestDisplayState.NeedsAttention -> + if (state.attention == RequestAttention.Failed) listOf(AdminRequestAction.Retry) else emptyList() + else -> emptyList() + } + } + + /** Recommendations this client can route (movies and series). */ + val recommendations: List<RequestMediaResult> + get() = detail?.recommendations.orEmpty().filter { + it.mediaType == RequestMediaType.Movie || it.mediaType == RequestMediaType.Series + } + + /** Where the title stands, in the slot a library title uses for its eyebrow. */ + val eyebrow: String? + get() { + if (moderationRecord != null && (openedForModeration || record == null)) return "Requested by someone on this server" + val display = progress?.display ?: return "Not in your library" + return if (display == RequestDisplayState.InLibrary) null else "Requested" + } + + /** The primary action's label. */ + val primaryActionTitle: String + get() = when (val action = primaryAction) { + RequestPrimaryAction.Loading -> "" + RequestPrimaryAction.Request -> if (endedRequest == null) "Request" else "Request Again" + RequestPrimaryAction.Submitting -> "Requesting…" + is RequestPrimaryAction.OpenInLibrary -> "Open in Library" + is RequestPrimaryAction.Status -> progress + ?.takeIf { action.state == it.display && action.state != RequestDisplayState.Pending } + ?.longLabel + ?: action.state.detailTitle + } + + /** The hint under a plain Request button. */ + val showsRequestHint: Boolean + get() = actionErrorMessage == null && primaryAction == RequestPrimaryAction.Request && endedRequest == null + + /** Whether the status card belongs on the page. */ + val showsStatus: Boolean + get() = progress?.let { it.display != RequestDisplayState.InLibrary } == true + + /** The admin can retry a failed request, independent of an action in flight. */ + private val isRetryableByAdmin: Boolean + get() = moderationRecord?.let { + (RequestDisplayState.of(it) as? RequestDisplayState.NeedsAttention)?.attention == RequestAttention.Failed + } == true + + /** + * The user's record while it's still the latest word on the title. A failed + * or declined one stops counting once the title is in the library or + * another request for it is under way. + */ + private val currentOwnRecord: MediaRequest? + get() { + val record = record ?: return null + val detail = detail + if (record.outcome == RequestOutcome.Active || detail == null) return record + if (RequestDisplayState.of(detail.availability, detail.request) == RequestDisplayState.InLibrary) return null + val current = detail.request.requestId + if (current != null && current != record.id) return null + return record + } + + private val activeRecordState: RequestDisplayState? + get() { + val record = record?.takeIf { it.outcome == RequestOutcome.Active } ?: return null + return RequestDisplayState.of(record).takeIf { + it == RequestDisplayState.Pending || it == RequestDisplayState.OnTheWay + } + } +} + +class RequestDetailViewModel( + private val repository: RequestsRepository, + private val mediaType: String, + private val tmdbId: Int, + private val featureStore: RequestsFeatureStore? = null, + private val holdLifetime: Duration = RequestActionHold.Lifetime, + private val timeSource: TimeSource = TimeSource.Monotonic, +) : ViewModel() { + + private val cache = repository.cache + private val key = RequestDetailCache.Key(mediaType, tmdbId) + + private val _uiState: MutableStateFlow<RequestDetailUiState> + val uiState: StateFlow<RequestDetailUiState> + + /** The one request this page decides on, once chosen, so a refresh never moves the buttons to another requester. */ + private var selectedModerationId: String? + private var moderationHold: RequestActionHold? = null + private var cancelHold: RequestActionHold? = null + /** The lapse timer of an unconfirmed create; null when none is held. */ + private var submissionHold: Job? = null + private var reloadJob: Job? = null + + init { + // First frame from what the app already knows: the finished page when + // this title was read before, the tapped card or record otherwise, and + // the status from the user's own records. load() refreshes all of it. + // Admin records seed the page only for a user who can still act on them. + val moderates = featureStore?.canModerate?.value == true + val pinned = cache.pinnedModerationRecord(key)?.takeIf { moderates } + val moderation = pinned ?: cache.moderationRecord(key)?.takeIf { moderates } + val detail = cache.firstFrameDetail(key) + _uiState = MutableStateFlow( + RequestDetailUiState( + detail = detail, + record = cache.ownRecord(key), + moderationRecord = moderation, + openedForModeration = pinned != null, + isLoading = detail == null, + ), + ) + uiState = _uiState.asStateFlow() + selectedModerationId = moderation?.id + // The pin is for this one opening; the next visit is an ordinary page. + pinned?.let(cache::unpinModeration) + load() + viewModelScope.launch { + // Another surface mutated this title (a cancel from My Requests while + // this page sits in the back stack). + repository.lastUpdate.drop(1).filterNotNull().collect { event -> + val record = event.record + val state = _uiState.value + if (record.mediaType != mediaType || record.tmdbId != tmdbId) return@collect + // This page's own action re-reads when it finishes. + if (state.isSubmitting || state.isCancelling || state.isModerating) return@collect + reloadJob?.cancel() + reloadJob = viewModelScope.launch { fetch() } + } + } + } + + fun load() { + viewModelScope.launch { fetch() } + } + + /** + * Reads the title, then the user's and the admin's records for it. [ownMaxAge] + * lets a recent read of the user's requests stand in; settling an uncertain + * action passes zero so it sees the server's latest. + */ + private suspend fun fetch(ownMaxAge: Duration = RequestsRepository.MineFreshness) { + _uiState.update { it.copy(isLoading = it.detail == null, error = null) } + when (val fresh = repository.detail(mediaType, tmdbId)) { + is ApiResult.Success -> { + _uiState.update { it.copy(detail = fresh.data, hasFreshDetail = true) } + // Supporting reads run side by side after the title; each keeps + // its previous value on failure, so a slow list never blocks the page. + val moderationLookup = viewModelScope.async { loadModerationRecord() } + val own = loadOwnRecords(ownMaxAge) + val moderation = moderationLookup.await() + _uiState.update { state -> + var next = state + if (own != null) { + next = next.copy(record = RequestDetailCache.currentRecord(own)) + // A timed-out cancel can still land after this read; + // only a changed or missing request settles it. + cancelHold?.let { hold -> + if (hold.isSettled(own.firstOrNull { it.id == hold.requestId })) { + cancelHold = null + next = next.releasingCancelHold() + } + } + } + if (moderation != null) { + next = next.copy(moderationRecord = moderation.record) + // An unchanged request doesn't show what a lost call did. + if (moderationHold?.isSettled(moderation.record) == true) { + moderationHold = null + next = next.releasingModerationHold() + } + } + // A create still being processed reads as no request at all: + // the hold ends only when the page no longer offers Request. + val released = next.releasingSubmissionHold() + if (next.isSubmissionUnconfirmed && released.primaryAction != RequestPrimaryAction.Request) { + submissionHold?.cancel() + submissionHold = null + next = released + } + next.copy(isLoading = false) + } + } + is ApiResult.Error, is ApiResult.NetworkError -> _uiState.update { + it.copy( + isLoading = false, + error = fresh.errorMessage("Failed to load request details").takeIf { _ -> it.detail == null }, + ) + } + } + } + + /** + * The user's requests for this title, from a read of their list no older + * than [maxAge]; null when the read fails, so the page keeps what it has. + * The list has no per-title filter, so a recent read stands in for a new one. + */ + private suspend fun loadOwnRecords(maxAge: Duration): List<MediaRequest>? { + if (repository.ensureMine(maxAge) !is ApiResult.Success) return null + return repository.mine.value.filter { it.mediaType == mediaType && it.tmdbId == tmdbId } + } + + private class ModerationLookup(val record: MediaRequest?) + + /** Null when the reads fail (the page keeps what it has); a null record means nothing awaits a decision. */ + private suspend fun loadModerationRecord(): ModerationLookup? { + if (featureStore?.canModerate?.value != true) return ModerationLookup(null) + // Filtered to this title on the server: one short page each. + val pendingRead = viewModelScope.async { + repository.adminRequests(status = RequestStatus.Pending, outcome = RequestOutcome.Active, mediaType = mediaType, tmdbId = tmdbId) + } + val failedRead = repository.adminRequests(outcome = RequestOutcome.Failed, mediaType = mediaType, tmdbId = tmdbId) + val pending = pendingRead.await() + if (pending !is ApiResult.Success || failedRead !is ApiResult.Success) return null + val matches = (pending.data + failedRead.data).filter { it.mediaType == mediaType && it.tmdbId == tmdbId } + // Several users can have failed requests for one title: stay on the + // exact request the page opened with. Once it's decided, offer nothing + // rather than another requester's request. + selectedModerationId?.let { selected -> return ModerationLookup(matches.firstOrNull { it.id == selected }) } + selectedModerationId = matches.firstOrNull()?.id + return ModerationLookup(matches.firstOrNull()) + } + + /** One tap, no confirmation dialog: the button is the confirmation. */ + fun submitRequest() { + val state = _uiState.value + val detail = state.detail ?: return + if (state.isSubmitting || state.primaryAction != RequestPrimaryAction.Request) return + _uiState.update { it.copy(isSubmitting = true, actionErrorMessage = null) } + viewModelScope.launch { + val result = repository.create(detail.toCreateMediaRequest()) + when { + result is ApiResult.Success -> { + // The server's answer stands even if the re-read below fails, + // so Request can't come back for a request that exists. + _uiState.update { it.applyingOwn(result.data).copy(submittedCount = it.submittedCount + 1) } + // Re-read so the page reflects the server's state, not a local + // guess. Still submitting meanwhile, so this page's own + // broadcast doesn't trigger a second read. + fetch() + _uiState.update { it.copy(isSubmitting = false) } + } + RequestMutationFailure.isUncertain(result) -> { + // Never resend: hold the action until a read shows the + // request, or the hold lapses. + holdSubmission() + _uiState.update { + it.copy(isSubmitting = false, isSubmissionUnconfirmed = true, actionErrorMessage = RequestActionCopy.UnconfirmedSubmit) + } + fetch(ownMaxAge = Duration.ZERO) + } + else -> _uiState.update { + it.copy(isSubmitting = false, actionErrorMessage = RequestActionCopy.failure(result, "Failed to submit request")) + } + } + } + } + + fun cancel() { + val state = _uiState.value + val record = state.record ?: return + if (!state.canCancel) return + _uiState.update { it.copy(isCancelling = true, actionErrorMessage = null) } + viewModelScope.launch { + val result = repository.cancel(record.id) + when { + result is ApiResult.Success -> { + _uiState.update { it.applyingOwn(result.data) } + fetch() + _uiState.update { it.copy(isCancelling = false) } + } + RequestMutationFailure.isUncertain(result) -> { + // Never resend: hold Cancel until the request changes, or the hold lapses. + holdCancel(record) + _uiState.update { + it.copy(isCancelling = false, isCancelUnconfirmed = true, actionErrorMessage = RequestActionCopy.UnconfirmedCancel) + } + fetch(ownMaxAge = Duration.ZERO) + } + else -> _uiState.update { + it.copy(isCancelling = false, actionErrorMessage = RequestActionCopy.failure(result, "Couldn't cancel the request")) + } + } + } + } + + fun moderate(action: AdminRequestAction) { + val state = _uiState.value + val moderation = state.moderationRecord ?: return + if (state.isModerating) return + _uiState.update { it.copy(isModerating = true, actionErrorMessage = null) } + viewModelScope.launch { + val result = repository.adminAction(moderation.id, action) + when { + result is ApiResult.Success -> { + // Still moderating through the re-read, so the decision + // buttons can't come back for the request just decided. + _uiState.update { it.copy(moderationRecord = result.data, moderatedCount = it.moderatedCount + 1) } + fetch() + _uiState.update { it.copy(isModerating = false) } + } + RequestMutationFailure.isUncertain(result) -> { + // Never resend: hide the decision until a read shows it landed. + holdModeration(moderation) + _uiState.update { + it.copy(isModerating = false, isModerationUnconfirmed = true, actionErrorMessage = RequestActionCopy.UnconfirmedModeration) + } + fetch(ownMaxAge = Duration.ZERO) + } + else -> _uiState.update { + it.copy(isModerating = false, actionErrorMessage = RequestActionCopy.failure(result, "Something went wrong")) + } + } + } + } + + /** Ends the hold when its lifetime runs out even if no read settles it; a newer hold keeps its own clock. */ + private fun holdModeration(request: MediaRequest) { + val hold = RequestActionHold(request, timeSource, holdLifetime) + moderationHold = hold + viewModelScope.launch { + delay(holdLifetime) + if (moderationHold !== hold) return@launch + moderationHold = null + _uiState.update { it.releasingModerationHold() } + fetch(ownMaxAge = Duration.ZERO) + } + } + + private fun holdCancel(request: MediaRequest) { + val hold = RequestActionHold(request, timeSource, holdLifetime) + cancelHold = hold + viewModelScope.launch { + delay(holdLifetime) + if (cancelHold !== hold) return@launch + cancelHold = null + _uiState.update { it.releasingCancelHold() } + fetch(ownMaxAge = Duration.ZERO) + } + } + + private fun holdSubmission() { + submissionHold?.cancel() + submissionHold = viewModelScope.launch { + delay(holdLifetime) + submissionHold = null + _uiState.update { it.releasingSubmissionHold() } + fetch(ownMaxAge = Duration.ZERO) + } + } + + /** The page as the server's answer to the user's own create or cancel leaves it. */ + private fun RequestDetailUiState.applyingOwn(record: MediaRequest): RequestDetailUiState = copy( + record = record, + detail = detail?.copy(request = record.toRequestState()), + ) + + private fun RequestDetailUiState.releasingModerationHold(): RequestDetailUiState = copy( + isModerationUnconfirmed = false, + actionErrorMessage = actionErrorMessage.takeUnless { it == RequestActionCopy.UnconfirmedModeration }, + ) + + private fun RequestDetailUiState.releasingCancelHold(): RequestDetailUiState = copy( + isCancelUnconfirmed = false, + actionErrorMessage = actionErrorMessage.takeUnless { it == RequestActionCopy.UnconfirmedCancel }, + ) + + private fun RequestDetailUiState.releasingSubmissionHold(): RequestDetailUiState = copy( + isSubmissionUnconfirmed = false, + actionErrorMessage = actionErrorMessage.takeUnless { it == RequestActionCopy.UnconfirmedSubmit }, + ) + + private fun RequestMediaDetail.toCreateMediaRequest(): CreateMediaRequest = CreateMediaRequest( + mediaType = mediaType, + tmdbId = tmdbId, + tvdbId = tvdbId, + imdbId = imdbId, + title = title, + year = year, + overview = overview, + posterPath = posterPath, + backdropPath = backdropPath, + ) +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/RequestsViewModels.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/RequestsViewModels.kt index abef171ee6..0978821252 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/RequestsViewModels.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/RequestsViewModels.kt @@ -2,109 +2,239 @@ package org.prairieserver.prairie.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import org.prairieserver.prairie.model.request.CreateMediaRequest +import org.prairieserver.prairie.model.feature.RequestsFeatureStore import org.prairieserver.prairie.model.request.MediaRequest +import org.prairieserver.prairie.model.request.MyRequestsBucket +import org.prairieserver.prairie.model.request.RequestActionCopy +import org.prairieserver.prairie.model.request.RequestActionHold import org.prairieserver.prairie.model.request.RequestDiscoverySection -import org.prairieserver.prairie.model.request.RequestMediaDetail +import org.prairieserver.prairie.model.request.RequestDisplayState import org.prairieserver.prairie.model.request.RequestMediaResult import org.prairieserver.prairie.model.request.RequestMediaType +import org.prairieserver.prairie.model.request.RequestMutationFailure +import org.prairieserver.prairie.model.request.RequestOutcome +import org.prairieserver.prairie.model.request.RequestStatus +import org.prairieserver.prairie.model.request.applyingRequestUpdate import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.errorMessage import org.prairieserver.prairie.repository.RequestsRepository +import kotlin.time.Duration +import kotlin.time.TimeSource import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch data class RequestsUiState( + /** The first load, with nothing to show yet. */ val isLoading: Boolean = true, val isRefreshing: Boolean = false, - val isEnabled: Boolean = false, + val hasLoaded: Boolean = false, val sections: List<RequestDiscoverySection> = emptyList(), + /** The signed-in user's requests, needs-attention first, newest first within each group. */ + val myRequests: List<MediaRequest> = emptyList(), + /** Requests waiting on this admin's decision; zero for everyone else. */ + val pendingApprovals: Int = 0, + /** More requests wait than [pendingApprovals]: the count reads one page. */ + val morePendingApprovals: Boolean = false, val error: String? = null, -) + val query: String = "", + val searchResults: List<RequestMediaResult> = emptyList(), + /** TMDB's total for the query, for the "N results" line. */ + val searchTotal: Int = 0, + val isSearching: Boolean = false, + val hasSearched: Boolean = false, +) { + /** True while the hub shows discover content (no active query). */ + val isShowingDiscover: Boolean get() = query.isBlank() + + /** Requests still moving, for the summary card. */ + val inProgressCount: Int + get() = myRequests.count { MyRequestsBucket.of(RequestDisplayState.of(it)) == MyRequestsBucket.InMotion } + + /** Requests that need the user, for the summary card. */ + val needsAttentionCount: Int + get() = myRequests.count { MyRequestsBucket.of(RequestDisplayState.of(it)) == MyRequestsBucket.NeedsAttention } + + val onTheWayCount: Int get() = myRequests.count { RequestDisplayState.of(it) == RequestDisplayState.OnTheWay } + val pendingCount: Int get() = myRequests.count { RequestDisplayState.of(it) == RequestDisplayState.Pending } +} +/** + * The Requests hub: TMDB search, the user's own requests strip, a status + * summary, and the discover carousels. Fetches fresh on every visit: request + * state changes server-side, and a stale "Pending" is worse than a placeholder. + */ class RequestsViewModel( private val repository: RequestsRepository, + private val featureStore: RequestsFeatureStore? = null, + /** False where the page reads the full approval queue itself (TV), so the hub doesn't read it twice. */ + private val countsPendingApprovals: Boolean = true, + /** Loads on creation; the TV page loads when it composes. */ + loadOnInit: Boolean = true, ) : ViewModel() { private val _uiState = MutableStateFlow(RequestsUiState()) val uiState: StateFlow<RequestsUiState> = _uiState.asStateFlow() + private var searchJob: Job? = null + private var loadGeneration = 0 + init { - load() + if (loadOnInit) load() + viewModelScope.launch { + repository.lastUpdate.drop(1).filterNotNull().collect { applyRequestUpdate(it.record) } + } + viewModelScope.launch { + repository.lastModeration.drop(1).filterNotNull().collect { loadPendingApprovals() } + } + featureStore?.let { store -> + // Moderation can be confirmed after the hub's first load. + viewModelScope.launch { store.canModerate.drop(1).collect { loadPendingApprovals() } } + } } fun load() { - viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, error = null) } - fetchRequestsHome() - } + viewModelScope.launch { fetch() } } fun refresh() { viewModelScope.launch { - _uiState.update { it.copy(isRefreshing = true, error = null) } - fetchRequestsHome() + _uiState.update { it.copy(isRefreshing = true) } + fetch() _uiState.update { it.copy(isRefreshing = false) } } } - private suspend fun fetchRequestsHome() { - when (val status = repository.status()) { - is ApiResult.Success -> { - if (!status.data.requestsEnabled) { - _uiState.update { - it.copy( - isLoading = false, - isEnabled = false, - sections = emptyList(), - error = "Requests are not enabled on this server.", - ) - } - return - } - fetchDiscover() + /** Loads discover and the user's requests; returns whether both read. */ + suspend fun fetch(): Boolean { + val generation = ++loadGeneration + _uiState.update { it.copy(isLoading = it.sections.isEmpty() && it.myRequests.isEmpty(), error = null) } + // The admin queue can take many pages; it fills its card when it lands. + val approvals = viewModelScope.launch { loadPendingApprovals() } + val discover = viewModelScope.async { repository.discover() } + val mine = repository.refreshMine() + val sections = discover.await() + if (generation != loadGeneration) return false + val succeeded = sections is ApiResult.Success && mine is ApiResult.Success + if (succeeded) { + val records = repository.mine.value + val ordered = MyRequestsBucket.bucket(records).flatMap { it.second } + _uiState.update { + it.copy( + isLoading = false, + hasLoaded = true, + sections = mergeDiscoverCarousels((sections as ApiResult.Success).data.sections), + myRequests = ordered, + error = null, + ) } - is ApiResult.Error, is ApiResult.NetworkError -> { - _uiState.update { - it.copy( - isLoading = false, - isEnabled = false, - error = status.errorMessage("Failed to load request status"), - ) - } + repository.prefetch(ordered) + } else { + val failure: ApiResult<*> = if (sections is ApiResult.Success) mine else sections + _uiState.update { + // Keep prior content on a transient failure; only surface the + // error when there is nothing to show instead. + it.copy( + isLoading = false, + hasLoaded = true, + error = failure.errorMessage("Failed to load requests") + .takeIf { _ -> it.sections.isEmpty() && it.myRequests.isEmpty() }, + ) } } + approvals.join() + return succeeded } - private suspend fun fetchDiscover() { - when (val discover = repository.discover()) { - is ApiResult.Success -> { - _uiState.update { - it.copy( - isLoading = false, - isEnabled = true, - sections = mergeDiscoverCarousels(discover.data.sections), - error = null, + /** The hub's approvals card is a nudge, not a list: a failed read hides it. */ + private suspend fun loadPendingApprovals() { + if (!countsPendingApprovals || featureStore?.canModerate?.value != true) { + _uiState.update { it.copy(pendingApprovals = 0, morePendingApprovals = false) } + return + } + // One page answers the card; it doesn't need the whole queue. + val result = repository.adminRequestCount(status = RequestStatus.Pending, outcome = RequestOutcome.Active) + val pending = (result as? ApiResult.Success)?.data + _uiState.update { it.copy(pendingApprovals = pending?.count ?: 0, morePendingApprovals = pending?.hasMore == true) } + } + + /** Debounced TMDB search (300 ms), as the Apple hub does. */ + fun onQueryChanged(value: String) { + searchJob?.cancel() + _uiState.update { it.copy(query = value) } + val trimmed = value.trim() + if (trimmed.isEmpty()) { + _uiState.update { it.copy(searchResults = emptyList(), searchTotal = 0, isSearching = false, hasSearched = false) } + return + } + searchJob = viewModelScope.launch { + delay(300) + search(trimmed) + } + } + + /** Runs the current query now (the keyboard's search action). */ + fun submitSearch() { + val trimmed = _uiState.value.query.trim() + if (trimmed.isEmpty()) return + searchJob?.cancel() + searchJob = viewModelScope.launch { search(trimmed) } + } + + private suspend fun search(query: String) { + _uiState.update { it.copy(isSearching = true) } + val result = repository.search(query = query) + // A replacement search owns the state now. + if (_uiState.value.query.trim() != query) return + _uiState.update { state -> + when (result) { + is ApiResult.Success -> { + val results = result.data.results.filter { it.mediaType.isRequestableVideo() } + state.copy( + isSearching = false, + hasSearched = true, + searchResults = results, + searchTotal = maxOf(result.data.totalResults, results.size), ) } + else -> state.copy(isSearching = false, hasSearched = true, searchResults = emptyList(), searchTotal = 0) } - is ApiResult.Error, is ApiResult.NetworkError -> { - _uiState.update { - it.copy( - isLoading = false, - isEnabled = true, - error = discover.errorMessage("Failed to load requests"), - ) - } + } + } + + /** Patches every visible surface in place after a mutation anywhere in the app. */ + private fun applyRequestUpdate(record: MediaRequest) { + _uiState.update { state -> + val index = state.myRequests.indexOfFirst { it.id == record.id } + val mine = when { + index >= 0 && record.outcome == RequestOutcome.Cancelled -> state.myRequests.filterNot { it.id == record.id } + index >= 0 -> state.myRequests.toMutableList().also { it[index] = record } + record.outcome == RequestOutcome.Active -> listOf(record) + state.myRequests + else -> state.myRequests } + state.copy( + searchResults = state.searchResults.applyingRequestUpdate(record), + sections = state.sections.map { it.copy(results = it.results.applyingRequestUpdate(record)) }, + myRequests = mine, + ) } } + + override fun onCleared() { + searchJob?.cancel() + super.onCleared() + } } +private fun String.isRequestableVideo(): Boolean = this == RequestMediaType.Movie || this == RequestMediaType.Series + data class RequestSearchUiState( val query: String = "", val submittedQuery: String = "", @@ -120,6 +250,7 @@ data class RequestSearchUiState( get() = submittedQuery.isNotBlank() && query.trim() == submittedQuery } +/** The "Available to request" section of the app's global search. */ class RequestSearchViewModel( private val repository: RequestsRepository, ) : ViewModel() { @@ -129,6 +260,14 @@ class RequestSearchViewModel( private var searchJob: Job? = null + init { + viewModelScope.launch { + repository.lastUpdate.drop(1).filterNotNull().collect { event -> + _uiState.update { it.copy(results = it.results.applyingRequestUpdate(event.record)) } + } + } + } + fun onQueryChanged(value: String) { _uiState.update { current -> val trimmed = value.trim() @@ -230,193 +369,169 @@ class RequestSearchViewModel( } } -data class RequestDetailUiState( - val isLoading: Boolean = true, - val isSubmitting: Boolean = false, - val detail: RequestMediaDetail? = null, - val error: String? = null, - val notice: String? = null, -) - -class RequestDetailViewModel( - private val repository: RequestsRepository, - private val mediaType: String, - private val tmdbId: Int, -) : ViewModel() { - - private val _uiState = MutableStateFlow(RequestDetailUiState()) - val uiState: StateFlow<RequestDetailUiState> = _uiState.asStateFlow() - - init { - load() - } - - fun load() { - viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, error = null, notice = null) } - when (val result = repository.detail(mediaType, tmdbId)) { - is ApiResult.Success -> { - _uiState.update { - it.copy( - isLoading = false, - detail = result.data, - error = null, - ) - } - } - is ApiResult.Error, is ApiResult.NetworkError -> { - _uiState.update { - it.copy( - isLoading = false, - detail = null, - error = result.errorMessage("Failed to load request details"), - ) - } - } - } - } - } - - fun submitRequest() { - val detail = _uiState.value.detail - if (detail == null) { - _uiState.update { it.copy(error = "Request details are not loaded.") } - return - } - - viewModelScope.launch { - _uiState.update { it.copy(isSubmitting = true, error = null, notice = null) } - when (val result = repository.create(detail.toCreateMediaRequest())) { - is ApiResult.Success -> { - val refreshedDetail = when (val detailResult = repository.detail(mediaType, tmdbId)) { - is ApiResult.Success -> detailResult.data - else -> detail.copy( - request = detail.request.copy( - status = result.data.status, - requestable = false, - requestId = result.data.id, - ), - ) - } - _uiState.update { - it.copy( - isSubmitting = false, - detail = refreshedDetail, - error = null, - notice = "Request submitted.", - ) - } - } - is ApiResult.Error, is ApiResult.NetworkError -> { - _uiState.update { - it.copy( - isSubmitting = false, - error = result.errorMessage("Failed to submit request"), - ) - } - } - } - } - } - - private fun RequestMediaDetail.toCreateMediaRequest(): CreateMediaRequest = CreateMediaRequest( - mediaType = mediaType, - tmdbId = tmdbId, - tvdbId = tvdbId, - imdbId = imdbId, - title = title, - year = year, - overview = overview, - posterPath = posterPath, - backdropPath = backdropPath, - ) -} - data class MyRequestsUiState( val isLoading: Boolean = true, val isRefreshing: Boolean = false, - val requests: List<MediaRequest> = emptyList(), - val actionInFlightId: String? = null, + val hasLoaded: Boolean = false, + /** Ordered, non-empty buckets, newest first within each. */ + val buckets: List<Pair<MyRequestsBucket, List<MediaRequest>>> = emptyList(), + /** The request being cancelled; its row dims and takes no taps. */ + val cancellingId: String? = null, + /** Requests whose cancel was sent without a usable answer; held until the request changes, or the hold lapses. */ + val unconfirmedCancelIds: Set<String> = emptySet(), + /** Inline message for a failed cancel; cleared on the next action. */ + val actionErrorMessage: String? = null, val error: String? = null, -) +) { + val requests: List<MediaRequest> get() = buckets.flatMap { it.second } + val isEmpty: Boolean get() = hasLoaded && buckets.isEmpty() + + /** Whether [record] may offer Cancel: pending, and no cancel of it held. */ + fun canCancel(record: MediaRequest): Boolean = + RequestDisplayState.of(record).isCancelable && record.id !in unconfirmedCancelIds && cancellingId == null +} class MyRequestsViewModel( private val repository: RequestsRepository, + /** Loads on creation; the TV page only uses this model to cancel. */ + loadOnInit: Boolean = true, + private val holdLifetime: Duration = RequestActionHold.Lifetime, + private val timeSource: TimeSource = TimeSource.Monotonic, ) : ViewModel() { private var loadGeneration = 0 + private val cancelHolds = mutableMapOf<String, RequestActionHold>() private val _uiState = MutableStateFlow(MyRequestsUiState()) val uiState: StateFlow<MyRequestsUiState> = _uiState.asStateFlow() + private var reloadJob: Job? = null init { - load() - } - - fun load() { - val generation = ++loadGeneration + if (loadOnInit) load() viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, error = null) } - refreshMine(generation) + // A mutation elsewhere (detail submit) while this list is mounted: + // the list is short, so a full refetch is the simplest correct answer. + repository.lastUpdate.drop(1).filterNotNull().collect { + if (!_uiState.value.hasLoaded || _uiState.value.cancellingId != null) return@collect + reloadJob?.cancel() + reloadJob = viewModelScope.launch { fetch() } + } } } - fun refresh() { - val generation = ++loadGeneration - viewModelScope.launch { - _uiState.update { it.copy(isRefreshing = true, error = null) } - refreshMine(generation) - if (generation == loadGeneration) { - _uiState.update { it.copy(isRefreshing = false) } - } - } + fun load() { + viewModelScope.launch { fetch() } } - fun cancel(id: String) { + fun refresh() { viewModelScope.launch { - _uiState.update { it.copy(actionInFlightId = id, error = null) } - when (val result = repository.cancel(id)) { - is ApiResult.Success -> { - _uiState.update { - it.copy( - requests = repository.mine.value, - actionInFlightId = null, - error = null, - ) - } - } - is ApiResult.Error, is ApiResult.NetworkError -> { - _uiState.update { - it.copy( - actionInFlightId = null, - error = result.errorMessage("Failed to cancel request"), - ) - } - } - } + _uiState.update { it.copy(isRefreshing = true) } + fetch() + _uiState.update { it.copy(isRefreshing = false) } } } - private suspend fun refreshMine(generation: Int) { + /** Reads the list; returns whether the read succeeded. */ + suspend fun fetch(): Boolean { + val generation = ++loadGeneration + _uiState.update { it.copy(isLoading = it.buckets.isEmpty(), error = null) } val result = repository.refreshMine() - if (generation != loadGeneration) return - when (result) { + if (generation != loadGeneration) return false + return when (result) { is ApiResult.Success -> { + val buckets = MyRequestsBucket.bucket(repository.mine.value) _uiState.update { it.copy( isLoading = false, - requests = repository.mine.value, + hasLoaded = true, + buckets = buckets, error = null, ) } + settleCancelHolds() + repository.prefetch(buckets.flatMap { it.second }) + true } is ApiResult.Error, is ApiResult.NetworkError -> { _uiState.update { it.copy( isLoading = false, - error = result.errorMessage("Failed to load your requests"), + error = result.errorMessage("Failed to load your requests").takeIf { _ -> it.buckets.isEmpty() }, ) } + false + } + } + } + + /** + * Cancels one of the user's requests. [refresh] re-reads the list that shows + * the row when it isn't this model's (the TV hub) and returns whether the + * read succeeded; it runs only to settle an uncertain cancel. + */ + fun cancel(record: MediaRequest, refresh: (suspend () -> Boolean)? = null) { + val state = _uiState.value + if (state.cancellingId != null || record.id in state.unconfirmedCancelIds) return + _uiState.update { it.copy(cancellingId = record.id, actionErrorMessage = null) } + viewModelScope.launch { + val result = repository.cancel(record.id) + when { + result is ApiResult.Success -> { + _uiState.update { it.copy(cancellingId = null) } + if (refresh == null) fetch() + } + RequestMutationFailure.isUncertain(result) -> { + // Never resend: hold Cancel until the request changes, or the hold lapses. + val reread: suspend () -> Unit = { + if (refresh == null) { + fetch() + } else if (refresh()) { + settleCancelHolds() + } + } + holdCancel(record, reread) + _uiState.update { it.copy(cancellingId = null, actionErrorMessage = RequestActionCopy.UnconfirmedCancel) } + reread() + } + else -> _uiState.update { + it.copy(cancellingId = null, actionErrorMessage = RequestActionCopy.failure(result, "Couldn't cancel the request")) + } } } } + + /** + * Holds Cancel on [record], and ends the hold when its lifetime runs out + * even if no read settles it. A newer hold for the same request keeps its own clock. + */ + private fun holdCancel(record: MediaRequest, reread: suspend () -> Unit) { + val hold = RequestActionHold(record, timeSource, holdLifetime) + cancelHolds[record.id] = hold + _uiState.update { it.copy(unconfirmedCancelIds = cancelHolds.keys.toSet()) } + viewModelScope.launch { + delay(holdLifetime) + if (cancelHolds[record.id] !== hold) return@launch + cancelHolds.remove(record.id) + publishCancelHolds() + reread() + } + } + + /** Releases the holds the last read of the user's requests settles; an unchanged request keeps its hold. */ + private fun settleCancelHolds() { + if (cancelHolds.isEmpty()) return + val records = repository.mine.value + cancelHolds.entries.removeAll { (id, hold) -> hold.isSettled(records.firstOrNull { it.id == id }) } + publishCancelHolds() + } + + private fun publishCancelHolds() { + _uiState.update { + it.copy( + unconfirmedCancelIds = cancelHolds.keys.toSet(), + actionErrorMessage = it.actionErrorMessage.takeUnless { m -> + cancelHolds.isEmpty() && m == RequestActionCopy.UnconfirmedCancel + }, + ) + } + } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/WatchPartySpoilers.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/WatchPartySpoilers.kt new file mode 100644 index 0000000000..187f9f8292 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/WatchPartySpoilers.kt @@ -0,0 +1,65 @@ +package org.prairieserver.prairie.viewmodel + +import org.prairieserver.prairie.model.catalog.BrowseItem +import org.prairieserver.prairie.model.catalog.ItemDetail +import org.prairieserver.prairie.model.section.SectionItem +import org.prairieserver.prairie.model.settings.EpisodeSpoilerPrefs +import org.prairieserver.prairie.model.settings.EpisodeSpoilers +import org.prairieserver.prairie.model.watchtogether.Suggestion + +/** + * Spoiler protection for a title Watch Party shows: whether it is an episode + * this profile hasn't started, and where each artwork slot came from. The + * picker, confirmation page, lobby, and suggestions apply the same rule as + * every other surface ([EpisodeSpoilers]). + */ +data class WatchPartySpoilers( + /** An episode this profile hasn't started; false for anything else. */ + val unwatchedEpisode: Boolean, + val posterIsEpisodeStill: Boolean? = null, + val backdropIsEpisodeStill: Boolean? = null, +) { + fun hidesPoster(prefs: EpisodeSpoilerPrefs): Boolean = prefs.hidesImage(unwatchedEpisode, posterIsEpisodeStill) + + /** The backdrop slot shows the poster when there is no backdrop, so it takes that provenance. */ + fun hidesBackdrop(prefs: EpisodeSpoilerPrefs, backdropUrl: String?): Boolean = prefs.hidesImage( + unwatchedEpisode, + if (backdropUrl.isNullOrBlank()) posterIsEpisodeStill else backdropIsEpisodeStill, + ) + + fun hidesOverview(prefs: EpisodeSpoilerPrefs): Boolean = prefs.hidesOverview(unwatchedEpisode) + + companion object { + val NONE = WatchPartySpoilers(unwatchedEpisode = false) + + /** + * Nothing is known about the title beyond its type: an episode counts + * as unstarted, and its artwork as its still. + */ + fun forType(contentType: String) = WatchPartySpoilers(unwatchedEpisode = isEpisode(contentType)) + + fun of(item: SectionItem) = WatchPartySpoilers( + unwatchedEpisode = isEpisode(item.type) && EpisodeSpoilers.isUnwatched(item), + posterIsEpisodeStill = item.posterIsEpisodeStill, + backdropIsEpisodeStill = item.backdropIsEpisodeStill, + ) + + fun of(item: BrowseItem) = WatchPartySpoilers( + unwatchedEpisode = isEpisode(item.type) && + EpisodeSpoilers.isUnwatched(item.userState?.played, isInProgress = null, item.positionSeconds), + posterIsEpisodeStill = item.posterIsEpisodeStill, + backdropIsEpisodeStill = item.backdropIsEpisodeStill, + ) + + fun of(detail: ItemDetail) = WatchPartySpoilers( + unwatchedEpisode = isEpisode(detail.type) && EpisodeSpoilers.isUnwatched(detail.userData), + posterIsEpisodeStill = detail.posterIsEpisodeStill, + backdropIsEpisodeStill = detail.backdropIsEpisodeStill, + ) + + /** A suggestion carries no watch state or provenance. */ + fun of(suggestion: Suggestion) = forType(suggestion.contentType) + + private fun isEpisode(type: String) = type.trim().equals("episode", ignoreCase = true) + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/WatchPartyViewModels.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/WatchPartyViewModels.kt new file mode 100644 index 0000000000..132eeffef6 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/WatchPartyViewModels.kt @@ -0,0 +1,602 @@ +package org.prairieserver.prairie.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import org.prairieserver.prairie.model.catalog.BrowseItem +import org.prairieserver.prairie.model.watchtogether.AddSuggestionRequest +import org.prairieserver.prairie.model.watchtogether.CreateRoomRequest +import org.prairieserver.prairie.model.watchtogether.GuestControlPolicy +import org.prairieserver.prairie.model.watchtogether.ItemMemberState +import org.prairieserver.prairie.model.watchtogether.JoinRoomRequest +import org.prairieserver.prairie.model.watchtogether.PickerResponse +import org.prairieserver.prairie.model.watchtogether.RoomResponse +import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest +import org.prairieserver.prairie.model.watchtogether.Suggestion +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.repository.WatchPartyEnded +import org.prairieserver.prairie.repository.WatchPartyPendingAction +import org.prairieserver.prairie.repository.WatchTogetherConnectionState +import org.prairieserver.prairie.repository.WatchTogetherRepository +import org.prairieserver.prairie.watchtogether.LobbyReadiness +import org.prairieserver.prairie.watchtogether.RecentWatchParties +import org.prairieserver.prairie.watchtogether.RecentWatchParty +import org.prairieserver.prairie.watchtogether.RoomSession +import org.prairieserver.prairie.watchtogether.WatchPartyAvailability +import org.prairieserver.prairie.watchtogether.WatchPartyAvailabilityRepository +import org.prairieserver.prairie.watchtogether.WatchPartyDestination +import org.prairieserver.prairie.watchtogether.WatchPartyEligibility +import org.prairieserver.prairie.watchtogether.WatchPartyFeatures +import org.prairieserver.prairie.watchtogether.WatchPartyInvite +import org.prairieserver.prairie.watchtogether.canRemoveSuggestion +import org.prairieserver.prairie.watchtogether.lobbyReadiness +import org.prairieserver.prairie.watchtogether.newWatchPartyId +import org.prairieserver.prairie.watchtogether.parseWatchPartyInvite +import org.prairieserver.prairie.watchtogether.watchPartyDestination +import org.prairieserver.prairie.watchtogether.watchPartyEligibility +import org.prairieserver.prairie.watchtogether.watchPartyErrorMessage +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** An item to stage or suggest, as the detail page or picker displayed it. */ +data class WatchPartyItem( + val contentId: String, + val contentType: String, + val title: String, + val subtitle: String? = null, + val posterUrl: String? = null, + val fileId: Int? = null, + val libraryId: Int? = null, + /** How spoiler protection treats the title until its detail loads. */ + val spoilers: WatchPartySpoilers = WatchPartySpoilers.forType(contentType), +) + +/** + * The Watch Party hub: availability, the live party (Return to Party), the + * recent party (Rejoin), and hosting or joining. A create keeps its + * caller-selected room id until it definitely succeeds or fails, so an + * explicit retry after an uncertain outcome replays the same room instead of + * creating a second one. + */ +class WatchPartyHubViewModel( + private val repository: WatchTogetherRepository, + private val roomSession: RoomSession, + private val availability: WatchPartyAvailabilityRepository, + private val recents: RecentWatchParties, + /** Whether a link's server is the server this app is signed in to (base path included). */ + private val isCurrentServer: (String) -> Boolean, +) : ViewModel() { + + data class UiState( + /** Null while the first probe runs. */ + val availability: WatchPartyAvailability? = null, + val current: RoomSnapshot? = null, + val recent: RecentWatchParty? = null, + val ended: WatchPartyEnded? = null, + val busy: WatchPartyPendingAction? = null, + val error: String? = null, + /** A create whose outcome is unknown can be retried with the same identity. */ + val createRetryable: Boolean = false, + val destination: WatchPartyDestination? = null, + /** + * A forced re-check is running. [availability] still shows the last + * answer meanwhile, so one-shot handoffs wait for this to clear. + */ + val checking: Boolean = false, + ) + + private val _uiState = MutableStateFlow(UiState()) + val uiState: StateFlow<UiState> = _uiState.asStateFlow() + + /** Forced re-checks still running; overlapping ones must all finish. */ + private val forcedChecks = MutableStateFlow(0) + + private var pendingCreate: CreateRoomRequest? = null + private var pendingStage: WatchPartyItem? = null + + init { + viewModelScope.launch { + combine( + repository.roomSnapshot, + repository.ended, + repository.pendingAction, + availability.availability, + ) { room, ended, pending, available -> + _uiState.update { + it.copy( + current = room?.takeIf { snapshot -> watchPartyDestination(snapshot) != null }, + ended = ended, + busy = pending, + availability = available ?: it.availability, + ) + } + }.collect {} + } + refresh() + } + + /** Probe availability (again when [force]) and reload the recent party. */ + fun refresh(force: Boolean = false) { + if (force) forcedChecks.update { it + 1 } + _uiState.update { it.copy(checking = forcedChecks.value > 0) } + viewModelScope.launch { + try { + val result = availability.refresh(force) + _uiState.update { it.copy(availability = result, recent = recents.current()) } + } finally { + if (force) forcedChecks.update { it - 1 } + _uiState.update { it.copy(checking = forcedChecks.value > 0) } + } + } + } + + /** Host an empty party. Host Picks unless [mode] says otherwise. */ + fun host(mode: RoomSelectionMode = RoomSelectionMode.HostPick) { + pendingStage = null + create(mode) + } + + /** + * Host a party with [item] staged. The item is staged in the lobby; nothing + * plays until the host starts it. If staging fails after the room exists, + * the room is kept and the lobby offers to try again. + */ + fun hostWithItem(item: WatchPartyItem) { + pendingStage = item + create(RoomSelectionMode.HostPick) + } + + /** Retry the last create with the same room identity. */ + fun retryCreate() { + val request = pendingCreate ?: return + launchCreate(request) + } + + private fun create(mode: RoomSelectionMode) { + if (_uiState.value.busy != null) return + val features = (availability.availability.value as? WatchPartyAvailability.Available)?.features + if (features?.stagedSelection != true) { + fail("This server can't host a Watch Party. Update the server to host one.") + return + } + // A different mode is a different draft; the same mode reuses an uncertain one. + val request = pendingCreate?.takeIf { it.selectionMode == mode.wire } + ?: CreateRoomRequest(roomId = newWatchPartyId(), selectionMode = mode.wire) + pendingCreate = request + launchCreate(request) + } + + private fun launchCreate(request: CreateRoomRequest) { + _uiState.update { it.copy(error = null, createRetryable = false) } + viewModelScope.launch { + when (val created = repository.createRoom(request)) { + is ApiResult.Success -> { + pendingCreate = null + enter(created.data.room) + pendingStage?.let { item -> stageAfterCreate(item) } + pendingStage = null + } + is ApiResult.NetworkError -> { + _uiState.update { + it.copy( + error = "Couldn't confirm the party was created. Try again.", + createRetryable = true, + ) + } + } + is ApiResult.Error -> { + if (created.error == "invalid_response") { + _uiState.update { + it.copy(error = watchPartyErrorMessage(created, CREATE_FAILED), createRetryable = true) + } + } else { + pendingCreate = null + fail(watchPartyErrorMessage(created, CREATE_FAILED)) + } + } + } + } + } + + private suspend fun stageAfterCreate(item: WatchPartyItem) { + val staged = repository.stageSelection( + SetSelectionRequest(contentId = item.contentId, fileId = item.fileId, libraryId = item.libraryId), + ) + if (staged !is ApiResult.Success) { + fail("The party is ready, but ${item.title} couldn't be added. Add it from the lobby.") + } + } + + /** Join by typed code or pasted invitation. */ + fun join(input: String) { + when (val invite = parseWatchPartyInvite(input)) { + null -> fail("Enter the 8-character party code or paste an invitation link.") + else -> joinInvite(invite) + } + } + + fun joinInvite(invite: WatchPartyInvite) { + if (_uiState.value.busy != null) return + if (repository.roomSnapshot.value != null) { + fail("You're already in a Watch Party. Leave it first to join a different one.") + return + } + val request = when (invite) { + is WatchPartyInvite.Code -> JoinRoomRequest(code = invite.code) + is WatchPartyInvite.Link -> { + if (!isCurrentServer(invite.serverUrl)) { + fail("This invitation is for a different Prairie server. Sign in to that server to join.") + return + } + JoinRoomRequest(joinToken = invite.token) + } + } + launchJoin(request, "Couldn't join. Check the code and try again.") + } + + /** Explicit rejoin of the recent party: a fresh join by code. */ + fun rejoinRecent() { + val recent = _uiState.value.recent ?: return + if (_uiState.value.busy != null) return + launchJoin(JoinRoomRequest(code = recent.code), "Couldn't rejoin. The party may have ended.") { failure -> + if (failure is ApiResult.Error && (failure.code == 404 || failure.code == 409)) { + recents.forget(recent.roomId) + _uiState.update { it.copy(recent = null) } + } + } + } + + /** Return to the live party. */ + fun returnToParty() { + val room = repository.roomSnapshot.value ?: return + enter(room) + } + + private fun launchJoin( + request: JoinRoomRequest, + fallback: String, + onFailure: suspend (ApiResult<RoomResponse>) -> Unit = {}, + ) { + _uiState.update { it.copy(error = null) } + viewModelScope.launch { + when (val joined = repository.joinRoom(request)) { + is ApiResult.Success -> enter(joined.data.room) + else -> { + onFailure(joined) + fail( + when { + joined is ApiResult.Error && joined.code == 404 -> "No Watch Party uses that code." + // None of the ordinary conflicts apply to joining, + // so a join 409 is a party that has ended. + joined is ApiResult.Error && joined.code == 409 -> "That Watch Party has ended." + else -> watchPartyErrorMessage(joined, fallback) + }, + ) + } + } + } + } + + private fun enter(room: RoomSnapshot) { + roomSession.adopt(room.roomId) + _uiState.update { it.copy(destination = watchPartyDestination(room), error = null) } + } + + private fun fail(message: String) { + _uiState.update { it.copy(error = message) } + } + + fun consumeDestination() = _uiState.update { it.copy(destination = null) } + fun clearError() = _uiState.update { it.copy(error = null, createRetryable = false) } + + private companion object { + const val CREATE_FAILED = "Couldn't create the party. Try again." + } +} + +/** How the room socket is doing, for the reconnect notice. */ +sealed interface WatchPartyConnectionStatus { + data object Connected : WatchPartyConnectionStatus + /** [sinceMs] is monotonic; show a notice only once it has lasted 2 s. */ + data class Reconnecting(val sinceMs: Long) : WatchPartyConnectionStatus +} + +/** + * The lobby: the empty or staged item, members and lobby Ready, Start, mode + * and guest policy, suggestions and votes. Every action goes through the room + * owner, which runs one at a time; the room's phase alone decides when to + * open the player. + */ +class WatchPartyLobbyViewModel( + private val roomId: String, + private val repository: WatchTogetherRepository, + private val roomSession: RoomSession, + private val availability: WatchPartyAvailabilityRepository, +) : ViewModel() { + + data class LobbyState( + val room: RoomSnapshot? = null, + val suggestions: List<Suggestion> = emptyList(), + val personalVotesKnown: Boolean = false, + val eligibility: WatchPartyEligibility = WatchPartyEligibility(), + val features: WatchPartyFeatures? = null, + val readiness: LobbyReadiness = LobbyReadiness(0, 0), + val connection: WatchPartyConnectionStatus = WatchPartyConnectionStatus.Connected, + val pending: WatchPartyPendingAction? = null, + val ended: WatchPartyEnded? = null, + /** The last suggestion draft whose outcome is unknown; retry keeps its id. */ + val suggestionRetry: WatchPartyItem? = null, + ) + + private val suggestionDraft = MutableStateFlow<Pair<AddSuggestionRequest, WatchPartyItem>?>(null) + + val state: StateFlow<LobbyState> = combine( + combine(repository.roomSnapshot, repository.suggestions, repository.personalVotesKnown, ::Triple), + combine(repository.pendingAction, repository.connectionState, repository.ended, ::Triple), + availability.availability, + suggestionDraft, + ) { (room, suggestions, votesKnown), (pending, connection, ended), available, draft -> + val features = (available as? WatchPartyAvailability.Available)?.features + LobbyState( + room = room, + suggestions = suggestions, + personalVotesKnown = votesKnown, + eligibility = watchPartyEligibility(room, features, busy = pending != null, personalVotesKnown = votesKnown), + features = features, + readiness = lobbyReadiness(room), + connection = connection.status(), + pending = pending, + ended = ended, + suggestionRetry = draft?.second, + ) + }.stateIn(viewModelScope, SharingStarted.Eagerly, LobbyState()) + + /** Where to go: the player once the room plays. */ + val destination: StateFlow<WatchPartyDestination?> = repository.roomSnapshot + .map { room -> room?.takeIf { it.roomId == roomId }?.let(::watchPartyDestination) } + .distinctUntilChanged() + .stateIn(viewModelScope, SharingStarted.Eagerly, null) + + private val _messages = MutableSharedFlow<String>(extraBufferCapacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST) + + /** One-shot notices for failed actions. */ + val messages: SharedFlow<String> = _messages.asSharedFlow() + + init { + roomSession.adopt(roomId) + viewModelScope.launch { availability.refresh() } + viewModelScope.launch { repository.errors.collect { _messages.tryEmit(it) } } + } + + fun stage(item: WatchPartyItem) = act("Couldn't add ${item.title}.") { + repository.stageSelection(SetSelectionRequest(item.contentId, item.fileId, item.libraryId)) + } + + fun start() = act("Couldn't start. Check the room and try again.") { repository.startPlayback() } + + fun setMode(mode: RoomSelectionMode) = act("Couldn't switch modes.") { repository.setSelectionMode(mode) } + + fun setGuestPolicy(policy: GuestControlPolicy) = act("Couldn't change who can play and pause.") { + repository.updatePolicy(policy) + } + + fun setLobbyReady(ready: Boolean) { + viewModelScope.launch { + if (!repository.setLobbyReady(ready)) _messages.tryEmit("Reconnecting to the party…") + } + } + + /** Suggest [item]. After an uncertain outcome, [retrySuggestion] resends the same suggestion. */ + fun suggest(item: WatchPartyItem) { + // An uncertain or in-flight suggestion of the same title resends its + // own id, so the server can't end up with the title twice. + val request = (suggestionDraft.value?.first ?: inFlightSuggestion)?.takeIf { it.contentId == item.contentId } ?: AddSuggestionRequest( + suggestionId = newWatchPartyId(), + contentId = item.contentId, + contentType = item.contentType, + title = item.title, + subtitle = item.subtitle, + posterUrl = item.posterUrl, + ) + sendSuggestion(request, item) + } + + fun retrySuggestion() { + val (request, item) = suggestionDraft.value ?: return + sendSuggestion(request, item) + } + + fun dismissSuggestionRetry() { + suggestionDraft.value = null + } + + /** The suggestion being sent now; a repeat of the same title reuses its id. */ + private var inFlightSuggestion: AddSuggestionRequest? = null + + private fun sendSuggestion(request: AddSuggestionRequest, item: WatchPartyItem) { + inFlightSuggestion = request + // Only this request's own outcome may clear or replace the retry draft. + fun draftIsMineOrEmpty() = suggestionDraft.value?.first?.suggestionId.let { it == null || it == request.suggestionId } + viewModelScope.launch { + val result = repository.addSuggestion(request) + if (inFlightSuggestion == request) inFlightSuggestion = null + when (result) { + is ApiResult.Success -> if (draftIsMineOrEmpty()) suggestionDraft.value = null + is ApiResult.NetworkError -> { + suggestionDraft.value = request to item + _messages.tryEmit("Couldn't confirm your suggestion. Try again.") + } + is ApiResult.Error -> { + if (draftIsMineOrEmpty()) suggestionDraft.value = (request to item).takeIf { result.error == "invalid_response" } + _messages.tryEmit(watchPartyErrorMessage(result, "Couldn't suggest ${item.title}.")) + } + } + } + } + + fun vote(suggestion: Suggestion) = act("Couldn't vote.") { + if (suggestion.votedByMe) repository.unvote(suggestion.id) else repository.vote(suggestion.id) + } + + fun remove(suggestion: Suggestion) { + if (!canRemoveSuggestion(repository.roomSnapshot.value, suggestion)) return + act("Couldn't remove the suggestion.") { repository.deleteSuggestion(suggestion.id) } + } + + /** Vote rooms: start a suggestion now (host). */ + fun promote(suggestion: Suggestion) = act("Couldn't start ${suggestion.title}.") { + repository.promoteSuggestion(suggestion.id) + } + + /** + * Host-pick rooms: stage a suggestion as the next pick ("Up next"). This + * uses the stage operation, never promote, so nothing starts playing. + */ + fun queue(suggestion: Suggestion) = stage( + WatchPartyItem( + contentId = suggestion.contentId, + contentType = suggestion.contentType, + title = suggestion.title, + subtitle = suggestion.subtitle, + posterUrl = suggestion.posterUrl, + ), + ) + + /** Who in the room has watched [contentId]: one bounded read for the selected candidate. */ + suspend fun memberState(contentId: String): ItemMemberState? { + val features = (availability.availability.value as? WatchPartyAvailability.Available)?.features + if (features?.memberState != true) return null + return (repository.memberState(listOf(contentId)) as? ApiResult.Success)?.data?.items?.firstOrNull() + } + + fun endForEveryone() = roomSession.depart(closeRoom = true) + + fun leave() = roomSession.depart(closeRoom = false) + + private fun <T> act(fallback: String, operation: suspend () -> ApiResult<T>): Job = + viewModelScope.launch { + val result = operation() + if (result !is ApiResult.Success) _messages.tryEmit(watchPartyErrorMessage(result, fallback)) + } +} + +private fun WatchTogetherConnectionState.status(): WatchPartyConnectionStatus = + if (writable) { + WatchPartyConnectionStatus.Connected + } else { + WatchPartyConnectionStatus.Reconnecting(disconnectedAtMs ?: 0L) + } + +/** + * The room picker: the server's shared rows (Continue Together, members' + * watchlists) and catalog search. Search is debounced and a newer query + * discards an older response. Rows only list what this profile may browse; + * they do not prove every member can play an item. + */ +class WatchPartyPickerViewModel( + private val repository: WatchTogetherRepository, + private val availability: WatchPartyAvailabilityRepository, + private val search: suspend (String) -> ApiResult<List<BrowseItem>>, +) : ViewModel() { + + data class PickerState( + val rows: PickerResponse? = null, + val rowsLoading: Boolean = false, + val query: String = "", + val results: List<BrowseItem> = emptyList(), + val searching: Boolean = false, + val error: String? = null, + ) + + private val _state = MutableStateFlow(PickerState()) + val state: StateFlow<PickerState> = _state.asStateFlow() + private val queries = MutableStateFlow("") + private var generation = 0L + + init { + viewModelScope.launch { + queries.collectLatest { query -> + val ticket = generation + if (query.length >= MIN_QUERY) { + delay(SEARCH_DEBOUNCE_MS) + runSearch(query, ticket) + } + } + } + } + + /** Load the shared rows when the server offers them; otherwise search alone. */ + fun loadRows() { + viewModelScope.launch { + val features = (availability.refresh() as? WatchPartyAvailability.Available)?.features + if (features?.picker != true) { + _state.update { it.copy(rows = null, rowsLoading = false) } + return@launch + } + _state.update { it.copy(rowsLoading = true) } + when (val result = repository.picker()) { + // The picker reads profile catalog access, which can include + // ebooks and other non-video items; a party plays video only, + // and TV never shows reading items. + is ApiResult.Success -> _state.update { + it.copy(rows = result.data.playableOnly(), rowsLoading = false, error = null) + } + else -> _state.update { + it.copy(rowsLoading = false, error = watchPartyErrorMessage(result, "Couldn't load suggestions.")) + } + } + } + } + + fun onQuery(query: String) { + val normalized = query.trim() + _state.update { it.copy(query = query) } + if (normalized == queries.value) return + // Invalidate before the debounce so an old completion cannot replace + // the results or loading state for the text already on screen. + generation++ + _state.update { + it.copy(results = emptyList(), searching = normalized.length >= MIN_QUERY, error = null) + } + queries.value = normalized + } + + private suspend fun runSearch(query: String, ticket: Long) { + if (ticket != generation) return + val result = search(query) + if (ticket != generation) return + when (result) { + is ApiResult.Success -> _state.update { + it.copy(results = result.data.filter { item -> item.type in PLAYABLE_TYPES }, searching = false, error = null) + } + else -> _state.update { it.copy(searching = false, error = watchPartyErrorMessage(result, "Search failed.")) } + } + } + + private fun PickerResponse.playableOnly(): PickerResponse = copy( + continueTogether = continueTogether.filter { it.item.type in PLAYABLE_TYPES }, + watchlistUnion = watchlistUnion.filter { it.item.type in PLAYABLE_TYPES }, + ) + + private companion object { + const val SEARCH_DEBOUNCE_MS = 300L + const val MIN_QUERY = 2 + val PLAYABLE_TYPES = setOf("movie", "series", "episode") + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RecentWatchParty.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RecentWatchParty.kt new file mode 100644 index 0000000000..80a7abc599 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RecentWatchParty.kt @@ -0,0 +1,180 @@ +package org.prairieserver.prairie.watchtogether + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.network.IdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionKind +import org.prairieserver.prairie.network.IdentityTransitionPhase +import org.prairieserver.prairie.network.PrairieJson +import org.prairieserver.prairie.repository.WatchPartyEndReason +import org.prairieserver.prairie.repository.WatchPartyEnded +import org.prairieserver.prairie.util.wallClockMillis + +/** + * The durable identity a recent party belongs to: the verified server, the + * persisted login, and the profile. Re-unlocking a PIN profile keeps the same + * owner. + */ +data class RecentPartyOwner( + val serverId: String, + val loginId: String, + val profileId: String, +) { + override fun toString(): String = "RecentPartyOwner(<redacted>)" +} + +/** + * The one party this identity may explicitly rejoin. Only the room code and + * display metadata are kept; rejoining exchanges the code for fresh proof, so + * no room token or join token is ever persisted. + */ +@Serializable +data class RecentWatchParty( + @SerialName("room_id") val roomId: String, + val code: String, + val title: String? = null, + @SerialName("was_host") val wasHost: Boolean = false, + @SerialName("saved_at_ms") val savedAtEpochMs: Long, +) + +@Serializable +private data class StoredRecentParty( + @SerialName("server_id") val serverId: String, + @SerialName("login_id") val loginId: String, + @SerialName("profile_id") val profileId: String, + val party: RecentWatchParty, +) + +/** Platform storage for one encrypted value. */ +interface RecentWatchPartyStorage { + fun read(): String? + fun write(value: String?) +} + +/** + * One recent party per device, shown only to the identity that saved it and + * only for [lifetimeMs]. Expired or foreign entries are never returned. + */ +class RecentWatchParties( + private val storage: RecentWatchPartyStorage, + private val owner: suspend () -> RecentPartyOwner?, + private val nowEpochMs: () -> Long = ::wallClockMillis, + private val lifetimeMs: Long = 24 * 60 * 60_000L, +) { + private val mutex = Mutex() + + suspend fun current(): RecentWatchParty? = mutex.withLock { + val stored = readLocked() ?: return@withLock null + if (nowEpochMs() - stored.party.savedAtEpochMs !in 0..lifetimeMs) { + storage.write(null) + return@withLock null + } + val current = owner() ?: return@withLock null + stored.party.takeIf { stored.matches(current) } + } + + suspend fun remember(roomId: String, code: String, title: String? = null, wasHost: Boolean) { + if (roomId.isBlank() || code.isBlank()) return + val current = owner() ?: return + mutex.withLock { + val previous = readLocked()?.takeIf { it.matches(current) && it.party.roomId == roomId } + val party = RecentWatchParty( + roomId = roomId, + code = code, + title = title ?: previous?.party?.title, + wasHost = wasHost, + savedAtEpochMs = nowEpochMs(), + ) + val stored = StoredRecentParty(current.serverId, current.loginId, current.profileId, party) + storage.write(PrairieJson.encodeToString(StoredRecentParty.serializer(), stored)) + } + } + + /** Forget the entry, or only [roomId]'s entry when given. */ + suspend fun forget(roomId: String? = null) = mutex.withLock { + val stored = readLocked() ?: return@withLock + if (roomId == null || stored.party.roomId == roomId) storage.write(null) + } + + private fun readLocked(): StoredRecentParty? { + val raw = storage.read() ?: return null + return try { + PrairieJson.decodeFromString(StoredRecentParty.serializer(), raw) + } catch (_: Exception) { + storage.write(null) + null + } + } + + private fun StoredRecentParty.matches(owner: RecentPartyOwner) = + serverId == owner.serverId && loginId == owner.loginId && profileId == owner.profileId +} + +/** + * Keeps the recent party in step with the engagement: remembered while a + * membership is live, kept after a replacement, a lost connection, or Leave so + * the user can rejoin, and forgotten when the room itself ended, including + * when this device ended it ([closedByThisDevice]). Sign-out, account + * replacement, and server removal clear it before the identity changes. + */ +class WatchPartyRecentsRecorder( + private val recents: RecentWatchParties, + room: StateFlow<RoomSnapshot?>, + ended: StateFlow<WatchPartyEnded?>, + scope: CoroutineScope, + identityTransitions: IdentityTransitionBarrier, + closedByThisDevice: Flow<String> = emptyFlow(), +) { + init { + identityTransitions.installGate { transition -> + if (transition.phase == IdentityTransitionPhase.WILL_CHANGE && transition.kind in CLEARING_KINDS) { + recents.forget() + } + } + scope.launch { + closedByThisDevice.collect { roomId -> recents.forget(roomId) } + } + scope.launch { + // Snapshots arrive often during playback; persist only identity changes. + val membership = room + .map { snapshot -> + snapshot?.takeIf { it.code.isNotBlank() } + ?.let { Membership(it.roomId, it.code, it.selfRole == MemberRole.Host) } + } + .distinctUntilChanged() + // The repository publishes an end while the last snapshot is still + // live, then clears both, so an end must win over the membership + // it ends; otherwise the room is remembered again as it closes. + combine(membership, ended) { live, end -> live to end }.collect { (live, end) -> + when { + end != null && end.reason in ROOM_GONE -> recents.forget(end.roomId) + live != null && end?.roomId != live.roomId -> + recents.remember(live.roomId, live.code, wasHost = live.isHost) + } + } + } + } + + private data class Membership(val roomId: String, val code: String, val isHost: Boolean) + + private companion object { + val CLEARING_KINDS = setOf( + IdentityTransitionKind.SIGN_OUT, + IdentityTransitionKind.ACCOUNT_REPLACE, + IdentityTransitionKind.SERVER_REMOVE, + ) + val ROOM_GONE = setOf(WatchPartyEndReason.HostLeft, WatchPartyEndReason.NotFound, WatchPartyEndReason.Ended) + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomCatchup.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomCatchup.kt new file mode 100644 index 0000000000..7029672387 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomCatchup.kt @@ -0,0 +1,157 @@ +package org.prairieserver.prairie.watchtogether + +import org.prairieserver.prairie.model.watchtogether.TransportAction +import kotlin.math.abs + +/** + * How a Watch Party client applies a room command's position. Ported from the + * web reference client (`web/src/player/utils/roomSyncCatchup.ts`). + * + * Small drift is absorbed with a temporary playback-rate nudge instead of a + * seek, because on copy-remux deliveries a seek outside the downloaded window + * rebuilds the whole stream. Every position the player can reach without new + * media stays a seek. The rate is a correction, never a user speed: it is not + * saved or shown, and it returns to exactly 1x. + */ +object RoomCatchup { + /** Drift within this band converges by rate instead of rebuilding the stream. */ + const val BAND_SECONDS = 2.0 + + /** Playback already this close to the room needs no correction. */ + const val DEADBAND_SECONDS = 0.35 + const val MIN_RATE = 0.9 + const val MAX_RATE = 1.25 + + /** A 1 s deficit plays at 1.125x and converges in about 8 s; the band edge reaches the cap. */ + private const val DIVISOR_SECONDS = 8.0 + + /** Rate for [driftSeconds] (room minus local), clamped to the correction bounds. */ + fun rateFor(driftSeconds: Double): Double = + (1.0 + driftSeconds / DIVISOR_SECONDS).coerceIn(MIN_RATE, MAX_RATE) + + sealed interface Decision { + data object Seek : Decision + data class Rate(val rate: Double) : Decision + data object None : Decision + } + + /** + * Decide how to reach [targetSeconds] from [localSeconds] for a command of + * [action]. [targetReachableLocally] is true when the player can take the + * target without loading new media. + */ + fun decide( + action: TransportAction, + targetSeconds: Double, + localSeconds: Double, + targetReachableLocally: Boolean, + ): Decision { + if (action == TransportAction.Seek) return Decision.Seek + val drift = targetSeconds - localSeconds + if (abs(drift) <= DEADBAND_SECONDS) return Decision.None + if (targetReachableLocally) return Decision.Seek + // Pausing is cheap and the next Play realigns; never rebuild a stream + // just to park a paused member at the anchor. + if (action == TransportAction.Pause) return Decision.None + if (abs(drift) > BAND_SECONDS) return Decision.Seek + return Decision.Rate(rateFor(drift)) + } + + /** The room position at [nowMs] for a target that starts advancing at 1x from [executeAtMs]. */ + fun expectedPosition(targetSeconds: Double, executeAtMs: Long, nowMs: Long): Double = + (targetSeconds + (nowMs - executeAtMs).coerceAtLeast(0L) / 1000.0).coerceAtLeast(0.0) + + fun converged(targetSeconds: Double, executeAtMs: Long, localSeconds: Double, nowMs: Long): Boolean = + abs(expectedPosition(targetSeconds, executeAtMs, nowMs) - localSeconds) <= DEADBAND_SECONDS +} + +/** + * Correction-driven media reloads. A Play correction whose target is not + * buffered loads new media, so the viewer lands late by the load time. Without + * a budget a viewer on a slow link chases the advancing room forever. Only one + * reload runs at a time; the next waits 10 s after the previous one started + * playing, doubling to 60 s until the viewer converges, and each aims ahead by + * the previous load time, at most 10 s and never past the end of the media. + * Explicit room seeks never go through this budget. + */ +class RoomReloadBudget( + private val minIntervalMs: Long = 10_000L, + private val maxIntervalMs: Long = 60_000L, + private val staleMs: Long = 30_000L, + private val maxLeadSeconds: Double = 10.0, +) { + var targetSeconds: Double? = null + private set + private var loadStarted = false + private var startedAtMs = 0L + private var nextAllowedAtMs = 0L + private var attempts = 0 + private var leadSeconds = 0.0 + + val inFlight: Boolean get() = targetSeconds != null + + fun allowed(nowMs: Long): Boolean = + if (targetSeconds != null) nowMs - startedAtMs >= staleMs else nowMs >= nextAllowedAtMs + + /** Record a reload toward [roomSeconds]; returns where to aim it. */ + fun begin(roomSeconds: Double, nowMs: Long, durationSeconds: Double? = null): Double { + var target = roomSeconds + leadSeconds + if (durationSeconds != null && durationSeconds > 0.0) { + target = minOf(target, maxOf(roomSeconds, durationSeconds)) + } + targetSeconds = target + loadStarted = false + startedAtMs = nowMs + attempts++ + nextAllowedAtMs = nowMs + staleMs + return target + } + + /** The reload's own seek or re-anchor was taken. */ + fun noteLoading() { + if (targetSeconds != null) loadStarted = true + } + + /** Whether playback at [localSeconds] is the in-flight reload playing. */ + fun landed(localSeconds: Double): Boolean { + val target = targetSeconds ?: return false + if (!loadStarted) return false + val offset = localSeconds - target + return offset >= -RoomCatchup.DEADBAND_SECONDS && offset <= RoomCatchup.BAND_SECONDS + } + + /** The reload is playing: remember its load time and space the next one. */ + fun land(nowMs: Long) { + if (targetSeconds == null) return + leadSeconds = ((nowMs - startedAtMs) / 1000.0).coerceIn(0.0, maxLeadSeconds) + targetSeconds = null + nextAllowedAtMs = nowMs + backoffMs() + } + + /** The reload was refused or superseded; space the next one. */ + fun abandon(nowMs: Long) { + targetSeconds = null + nextAllowedAtMs = nowMs + backoffMs() + } + + /** The viewer reached the room: the next drift starts a fresh backoff. */ + fun settle() { + attempts = 0 + nextAllowedAtMs = 0L + } + + /** A quality change starts pacing over for the new stream. */ + fun reset() { + targetSeconds = null + loadStarted = false + attempts = 0 + nextAllowedAtMs = 0L + leadSeconds = 0.0 + } + + private fun backoffMs(): Long { + var interval = minIntervalMs + repeat((attempts - 1).coerceAtLeast(0)) { interval = minOf(maxIntervalMs, interval * 2) } + return interval + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomClock.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomClock.kt new file mode 100644 index 0000000000..3e601504ba --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomClock.kt @@ -0,0 +1,93 @@ +package org.prairieserver.prairie.watchtogether + +import kotlin.math.abs +import kotlin.math.roundToLong + +/** + * The room's estimate of server time. [offsetMs] is server wall clock minus + * local wall clock, or null before a usable sample. [generation] changes + * whenever the estimate is reset, so scheduled work computed from an older + * estimate can tell it is stale. + */ +data class RoomClockEstimate( + val offsetMs: Long?, + val rttMs: Long?, + val generation: Long, +) + +/** + * NTP-style offset estimation from room ping/pong samples. + * + * The estimate is the offset of the lowest round-trip sample among the most + * recent [maxSamples] within [sampleLifetimeMs], because queueing delay only + * ever lengthens a round trip. Samples with a negative or implausibly long + * round trip are rejected. A jump in the local wall clock relative to the + * monotonic clock invalidates every sample, since offsets measured before the + * jump no longer describe this device. + */ +class RoomClockEstimator( + private val maxSamples: Int = 8, + private val maxRttMs: Long = 5_000L, + private val sampleLifetimeMs: Long = 10 * 60_000L, + private val discontinuityToleranceMs: Long = 1_000L, +) { + private data class Sample(val offsetMs: Long, val rttMs: Long, val monotonicMs: Long) + + private val samples = ArrayDeque<Sample>() + private var wallMinusMonotonicMs: Long? = null + private var generation = 0L + + var estimate: RoomClockEstimate = RoomClockEstimate(offsetMs = null, rttMs = null, generation = 0L) + private set + + /** + * Record one sample. [clientReceivedMs] must be stamped when the pong left + * the socket, and [monotonicNowMs] read at the same moment. Returns true + * when the sample was accepted. + */ + fun record( + clientSentMs: Long, + serverReceivedMs: Long, + serverSentMs: Long, + clientReceivedMs: Long, + monotonicNowMs: Long, + ): Boolean { + checkContinuity(clientReceivedMs, monotonicNowMs) + val serverHoldMs = serverSentMs - serverReceivedMs + val rttMs = (clientReceivedMs - clientSentMs) - serverHoldMs + if (serverHoldMs < 0 || rttMs < 0 || rttMs > maxRttMs) return false + val offsetMs = (((serverReceivedMs - clientSentMs) + (serverSentMs - clientReceivedMs)) / 2.0).roundToLong() + samples.addLast(Sample(offsetMs, rttMs, monotonicNowMs)) + while (samples.size > maxSamples) samples.removeFirst() + samples.removeAll { monotonicNowMs - it.monotonicMs > sampleLifetimeMs } + publish() + return true + } + + /** + * Detect a local wall-clock jump. Returns true (and resets the estimate) + * when the wall clock moved relative to the monotonic clock by more than + * the tolerance since the last check. + */ + fun checkContinuity(wallNowMs: Long, monotonicNowMs: Long): Boolean { + val skew = wallNowMs - monotonicNowMs + val previous = wallMinusMonotonicMs + wallMinusMonotonicMs = skew + if (previous == null || abs(skew - previous) <= discontinuityToleranceMs) return false + reset() + wallMinusMonotonicMs = skew + return true + } + + fun reset() { + samples.clear() + wallMinusMonotonicMs = null + generation++ + publish() + } + + private fun publish() { + val best = samples.minByOrNull { it.rttMs } + estimate = RoomClockEstimate(offsetMs = best?.offsetMs, rttMs = best?.rttMs, generation = generation) + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomPlaybackBinding.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomPlaybackBinding.kt new file mode 100644 index 0000000000..6e0621e0da --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomPlaybackBinding.kt @@ -0,0 +1,722 @@ +package org.prairieserver.prairie.watchtogether + +import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.RoomPlaybackState +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.TransportAction +import org.prairieserver.prairie.model.watchtogether.TransportCommand +import org.prairieserver.prairie.repository.RoomTransportAuthorization +import org.prairieserver.prairie.repository.ScheduledTransportCommand +import org.prairieserver.prairie.repository.WatchTogetherConnectionState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlin.math.abs + +/** The room side of the playback binding. [org.prairieserver.prairie.repository.WatchTogetherRepository] implements it. */ +interface RoomPlaybackRoom { + val roomSnapshot: StateFlow<RoomSnapshot?> + val connectionState: StateFlow<WatchTogetherConnectionState> + val roomDeliveryEcho: StateFlow<RoomDeliveryEcho?> + val latestTransportCommand: StateFlow<ScheduledTransportCommand?> + val clock: StateFlow<RoomClockEstimate> + suspend fun attachSession(sessionId: String): Boolean + suspend fun stateReport( + sessionId: String, + positionSeconds: Double, + isPaused: Boolean, + commandId: String? = null, + isReady: Boolean = false, + ): Boolean + suspend fun ready(sessionId: String, positionSeconds: Double, isPaused: Boolean, commandId: String? = null): Boolean + suspend fun buffering(sessionId: String, positionSeconds: Double, isPaused: Boolean): Boolean + suspend fun checkClockContinuity(): RoomClockEstimate + fun currentTransportAuthorization(): RoomTransportAuthorization? + suspend fun transportRequestForAuthorization( + authorization: RoomTransportAuthorization, + intent: RoomTransportIntent, + action: String, + positionSeconds: Double?, + isPaused: Boolean, + ): Boolean +} + +enum class RoomPlayerState { Idle, Buffering, Ready, Ended } + +/** + * What the player is actually doing. [sourcePositionSeconds] is the engine + * position mapped to the source timeline, never an optimistic seek target. + */ +data class RoomPlayerObservation( + /** The committed playback session, or null while none is committed. */ + val sessionId: String? = null, + /** The room selection revision this player was prepared for. */ + val selectionRevision: Long = -1L, + val sourcePositionSeconds: Double = 0.0, + val durationSeconds: Double = 0.0, + val playWhenReady: Boolean = false, + val isPlaying: Boolean = false, + val state: RoomPlayerState = RoomPlayerState.Idle, + /** A seek, replan, or media mount is in flight; the position is not a decision. */ + val seekPending: Boolean = false, + /** + * Playback is held for a local reason (audio focus, background, sleep + * timer, subtitle preparation). Nothing is reported while it lasts. + */ + val suspended: Boolean = false, +) + +/** The player side of the binding, implemented by each platform's player. */ +interface RoomPlayerPort { + val observations: StateFlow<RoomPlayerObservation> + + /** Apply a room seek. Maps or re-anchors as the current stream requires. */ + fun seekTo(sourceSeconds: Double) + + /** Apply the room's play/pause state. Never echoed back as user intent. */ + fun setPlaying(playing: Boolean) + + /** A temporary correction rate, or null for exactly 1x. Never saved or shown. */ + fun setCorrectionRate(rate: Double?) + + /** Whether [sourceSeconds] is already buffered, so reaching it loads no new media. */ + fun isBuffered(sourceSeconds: Double): Boolean +} + +/** Client timing choices from the web reference client. */ +data class RoomPlaybackTiming( + val reportIntervalMs: Long = 1_500L, + val readyRetryMs: Long = 500L, + val bufferingGraceMs: Long = 2_000L, + val tickMs: Long = 250L, + val clockWaitMs: Long = 3_000L, + val commandQuietMs: Long = 250L, + val attachEchoTimeoutMs: Long = 2_000L, + val mountDeferMs: Long = 5_000L, + val maxLeadMs: Long = 5_000L, + val guestSeekToleranceSeconds: Double = 1.0, + val hostSeekToleranceSeconds: Double = 15.0, + val stallWindowMs: Long = 5 * 60_000L, +) + +/** Something the viewer should be told. */ +sealed interface RoomPlaybackNotice { + /** Only the host can seek; guests may play and pause only when the host allows it. */ + data class Denied(val intent: RoomTransportIntent) : RoomPlaybackNotice + /** The room socket is reconnecting; the action was not sent. */ + data object Reconnecting : RoomPlaybackNotice + /** Playback is paused until a usable server clock sample arrives. */ + data object ClockUnavailable : RoomPlaybackNotice + /** The request could not be delivered. */ + data object Undelivered : RoomPlaybackNotice +} + +/** What the binding is doing, for debug tooling and harness runs. Never contains credentials. */ +data class RoomPlaybackDebug( + val attachedSessionId: String? = null, + val serverAttached: Boolean = false, + val pendingCommandId: String? = null, + val appliedCommandId: String? = null, + val readinessCommandId: String? = null, + val catchingUp: Boolean = false, + val correctionRate: Double? = null, + val reloadInFlight: Boolean = false, + val stallReported: Boolean = false, + val suspended: Boolean = false, +) + +/** Outcome of a user transport intent. */ +enum class RoomTransportResult { Sent, Denied, Reconnecting, NotInRoom, Ignored } + +/** + * Binds one room engagement to one player for the current playback epoch. + * + * It attaches the committed playback session on every socket epoch and waits + * for the server's echo before reporting; reports what the player is actually + * doing every 1.5 s and stays quiet while stalled, seeking, or suspended; + * schedules accepted commands on the server clock and advances a late Play; + * acknowledges readiness for the exact waiting command until a fresh snapshot + * confirms it; reports buffering after a 2 s stall; acknowledges recovery + * while catching up; and converges small drift by a temporary rate with media + * reloads paced by [RoomReloadBudget]. + * + * Everything runs on one event loop, so no state is shared between coroutines. + */ +class RoomPlaybackBinding( + private val room: RoomPlaybackRoom, + private val player: RoomPlayerPort, + parentScope: CoroutineScope, + private val monotonicNowMs: () -> Long, + private val wallNowMs: () -> Long, + private val timing: RoomPlaybackTiming = RoomPlaybackTiming(), +) { + private sealed interface Event { + data object Tick : Event + data object Changed : Event + data class Command(val scheduled: ScheduledTransportCommand) : Event + data class Execute(val token: Long) : Event + data object QualityChanged : Event + } + + private val job = SupervisorJob(parentScope.coroutineContext[Job]) + private val scope = CoroutineScope(parentScope.coroutineContext + job) + private val events = Channel<Event>(Channel.UNLIMITED) + + private val _notices = MutableSharedFlow<RoomPlaybackNotice>( + extraBufferCapacity = 8, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val notices: SharedFlow<RoomPlaybackNotice> = _notices.asSharedFlow() + + private val _catchingUp = MutableStateFlow(false) + + /** This viewer is catching up on its own while the room keeps going. */ + val catchingUp: StateFlow<Boolean> = _catchingUp.asStateFlow() + + private val _debug = MutableStateFlow(RoomPlaybackDebug()) + + /** The binding's state for debug tooling. */ + val debug: StateFlow<RoomPlaybackDebug> = _debug.asStateFlow() + + private val _offerLowerQuality = MutableStateFlow(false) + + /** Two sustained stalls within five minutes: offer one step down the same file's ladder. */ + val offerLowerQuality: StateFlow<Boolean> = _offerLowerQuality.asStateFlow() + + // ---- loop state (touched only by the event loop) ---- + private var attachedKey: RoomDeliveryKey? = null + private var lastAttachAttemptMs = NEVER_MS + private var lastReportMs = NEVER_MS + private var lastSeenCommandId: String? = null + private var lastCommandConnection: Pair<Long, Long>? = null + private var pending: PendingCommand? = null + private var executionJob: Job? = null + private var nextToken = 0L + private var applied: TransportCommand? = null + private var appliedAtMs = 0L + private var readiness: Readiness? = null + private var lastCatchUpAckMs = NEVER_MS + private var stallStartedAtMs: Long? = null + private var stallReported = false + private val sustainedStalls = ArrayDeque<Long>() + private var convergence: Convergence? = null + + // A Play correction that needs new media while the reload budget is + // spacing reloads. It runs once the budget allows, unless the viewer + // reaches the room first or a newer command replaces it. + private var deferredReload: Convergence? = null + private var rateApplied: Double? = null + private val reloads = RoomReloadBudget() + private var wasSuspended = false + private var lastRevision = NEVER_MS + private var lastContinuityCheckMs = NEVER_MS + + private data class PendingCommand( + val token: Long, + val scheduled: ScheduledTransportCommand, + val executeAtServerMs: Long?, + ) { + /** When execution was first held for a media mount in flight. */ + var deferredSinceMs: Long? = null + } + + private data class Readiness( + val commandId: String?, + val seekTarget: Double?, + val armedWith: RoomSnapshot?, + var lastSentMs: Long = NEVER_MS, + ) + + private data class Convergence(val targetSeconds: Double, val executeAtMonotonicMs: Long) + + fun start() { + scope.launch { loop() } + scope.launch { room.roomSnapshot.collect { events.send(Event.Changed) } } + scope.launch { room.connectionState.collect { events.send(Event.Changed) } } + scope.launch { room.roomDeliveryEcho.collect { events.send(Event.Changed) } } + scope.launch { room.clock.collect { events.send(Event.Changed) } } + scope.launch { player.observations.collect { events.send(Event.Changed) } } + scope.launch { room.latestTransportCommand.collect { it?.let { command -> events.send(Event.Command(command)) } } } + scope.launch { + while (true) { + delay(timing.tickMs) + events.send(Event.Tick) + } + } + } + + /** Stop this binding. The room engagement continues; the correction rate returns to 1x. */ + fun dispose() { + job.cancel() + if (rateApplied != null) player.setCorrectionRate(null) + rateApplied = null + } + + /** + * Acknowledge that the viewer changed quality: the offer and reload pacing + * start over. The offer hides at once; the loop clears its own state. + */ + fun onQualityChanged() { + _offerLowerQuality.value = false + events.trySend(Event.QualityChanged) + } + + // ---- user intents --------------------------------------------------------- + + /** Ask the room to play or pause. The player changes only when the room's command arrives. */ + fun requestPlayPause(pause: Boolean): RoomTransportResult { + if (!pause && player.observations.value.state == RoomPlayerState.Ended) return RoomTransportResult.Ignored + return request(RoomTransportIntent.PlayPause, if (pause) TransportAction.Pause else TransportAction.Play, null, pause) + } + + /** Ask the room to seek (host only). */ + fun requestSeek(sourceSeconds: Double): RoomTransportResult { + if (!sourceSeconds.isFinite() || sourceSeconds < 0.0) return RoomTransportResult.Ignored + val paused = room.roomSnapshot.value?.isPaused ?: true + return request(RoomTransportIntent.Seek, TransportAction.Seek, sourceSeconds, paused) + } + + private fun request( + intent: RoomTransportIntent, + action: TransportAction, + positionSeconds: Double?, + isPaused: Boolean, + ): RoomTransportResult { + val snapshot = room.roomSnapshot.value ?: return RoomTransportResult.NotInRoom + if (snapshot.phase != RoomPhase.Playing) return RoomTransportResult.NotInRoom + if (!roomTransportAuthorized(snapshot, intent)) { + _notices.tryEmit(RoomPlaybackNotice.Denied(intent)) + return RoomTransportResult.Denied + } + val authorization = room.currentTransportAuthorization() + if (authorization == null || !room.connectionState.value.writable) { + _notices.tryEmit(RoomPlaybackNotice.Reconnecting) + return RoomTransportResult.Reconnecting + } + val position = positionSeconds ?: player.observations.value.sourcePositionSeconds + scope.launch { + val delivered = room.transportRequestForAuthorization(authorization, intent, action.wire, position, isPaused) + if (!delivered) _notices.tryEmit(RoomPlaybackNotice.Undelivered) + } + return RoomTransportResult.Sent + } + + // ---- event loop ----------------------------------------------------------- + + private suspend fun loop() { + for (event in events) { + when (event) { + is Event.Command -> accept(event.scheduled) + is Event.Execute -> execute(event.token) + Event.QualityChanged -> { + sustainedStalls.clear() + reloads.reset() + _offerLowerQuality.value = false + } + Event.Tick, Event.Changed -> Unit + } + step() + publishDebug() + } + } + + private fun publishDebug() { + val obs = player.observations.value + val echo = room.roomDeliveryEcho.value + _debug.value = RoomPlaybackDebug( + attachedSessionId = attachedKey?.playbackSessionId, + serverAttached = attachedKey != null && echo?.playbackSessionId == attachedKey?.playbackSessionId && + echo?.connectionEpoch == attachedKey?.connectionEpoch, + pendingCommandId = pending?.scheduled?.command?.commandId, + appliedCommandId = applied?.commandId, + readinessCommandId = readiness?.commandId, + catchingUp = _catchingUp.value, + correctionRate = rateApplied, + reloadInFlight = reloads.inFlight, + stallReported = stallReported, + suspended = obs.suspended, + ) + } + + private suspend fun step() { + val now = monotonicNowMs() + val snapshot = room.roomSnapshot.value + val connection = room.connectionState.value + val obs = player.observations.value + if (snapshot == null || snapshot.phase != RoomPhase.Playing) { + resetEpoch() + return + } + if (snapshot.selectionRevision != lastRevision) { + lastRevision = snapshot.selectionRevision + resetEpoch() + // A new title is a new stream: the old one's reload backoff and + // stall history say nothing about it. + reloads.reset() + sustainedStalls.clear() + _offerLowerQuality.value = false + } + if (now - lastContinuityCheckMs >= 1_000L) { + lastContinuityCheckMs = now + room.checkClockContinuity() + } + + val sessionId = obs.sessionId?.takeIf { obs.selectionRevision == snapshot.selectionRevision } + val key = sessionId?.takeIf { connection.writable }?.let { + RoomDeliveryKey(connection.generation, connection.epoch, it) + } + + // A local suspension ends: resynchronize before reporting again. While + // the room plays, re-attaching makes the server send a command at the + // room position; a paused room sends nothing, so align locally. + if (wasSuspended && !obs.suspended) { + attachedKey = null + if (snapshot.isPaused) alignPausedToAnchor(snapshot, obs) + } + if (obs.suspended && !wasSuspended) stopCorrections(now) + wasSuspended = obs.suspended + + val echo = room.roomDeliveryEcho.value + val echoed = key != null && echo != null && + echo.connectionGeneration == key.connectionGeneration && + echo.connectionEpoch == key.connectionEpoch && + echo.playbackSessionId == key.playbackSessionId + // Attach on every socket epoch and session change; resend if the + // server never echoes it. + val attachDue = attachedKey != key || + (!echoed && now - lastAttachAttemptMs >= timing.attachEchoTimeoutMs) + if (key != null && attachDue && now - lastAttachAttemptMs >= timing.readyRetryMs) { + lastAttachAttemptMs = now + if (room.attachSession(key.playbackSessionId)) attachedKey = key + } + val serverAttached = key != null && attachedKey == key && echoed + if (!serverAttached || sessionId == null) { + // No room to converge toward while detached or reconnecting; a + // correction rate left running would carry this viewer past it. + clearRate() + return + } + + val playable = !obs.seekPending && (obs.state == RoomPlayerState.Ready || obs.state == RoomPlayerState.Ended) + val executedLatest = pending == null + val stalled = obs.state == RoomPlayerState.Buffering && obs.playWhenReady && !obs.seekPending + val positionOk = { readiness: Readiness -> + val target = readiness.seekTarget + val tolerance = if (snapshot.selfRole == MemberRole.Host) { + timing.hostSeekToleranceSeconds + } else { + timing.guestSeekToleranceSeconds + } + target == null || obs.state == RoomPlayerState.Ended || abs(obs.sourcePositionSeconds - target) <= tolerance + } + val paused = obs.state == RoomPlayerState.Ended || !obs.playWhenReady + + // Readiness for the waiting command. + val waiting = readiness + if (waiting != null) { + val settled = snapshot.playbackState != RoomPlaybackState.Waiting || + (snapshot !== waiting.armedWith && snapshot.selfMember?.isReady == true) + if (settled) { + readiness = null + } else if (executedLatest && playable && !obs.suspended && positionOk(waiting) && + now - waiting.lastSentMs >= timing.readyRetryMs + ) { + waiting.lastSentMs = now + room.ready(sessionId, obs.sourcePositionSeconds, paused, waiting.commandId) + room.stateReport(sessionId, obs.sourcePositionSeconds, paused, waiting.commandId, isReady = true) + lastReportMs = now + } + } + + // Catching up after the room stopped waiting for this viewer. + val catchingUp = snapshot.selfCatchingUp + _catchingUp.value = catchingUp + if (catchingUp && readiness == null && snapshot.selfMember?.isReady != true && executedLatest && playable && + !obs.suspended && now - lastCatchUpAckMs >= timing.readyRetryMs + ) { + lastCatchUpAckMs = now + room.ready(sessionId, obs.sourcePositionSeconds, paused, lastSeenCommandId) + } + + // Buffering: stalls under the grace stay local; a longer one is reported once. + if (stalled && snapshot.playbackState == RoomPlaybackState.Playing && !obs.suspended) { + val started = stallStartedAtMs ?: now.also { stallStartedAtMs = it } + if (!stallReported && now - started >= timing.bufferingGraceMs) { + stallReported = true + room.buffering(sessionId, obs.sourcePositionSeconds, false) + recordSustainedStall(now) + } + } else if (!stalled) { + stallStartedAtMs = null + stallReported = false + } + + // Rate convergence and reload pacing. + converge(now, obs) + retryDeferredReload(now, obs) + + // Ordinary reports: only positions that are decisions. + val readinessPending = readiness != null || (catchingUp && snapshot.selfMember?.isReady != true) + val quiet = pending?.let { abs(expectedLocalExecuteMs(it) - now) <= timing.commandQuietMs } ?: false + if (!obs.suspended && !stalled && !obs.seekPending && !(readinessPending && !playable) && !quiet && + now - lastReportMs >= timing.reportIntervalMs + ) { + lastReportMs = now + room.stateReport(sessionId, obs.sourcePositionSeconds, paused, null, isReady = false) + } + } + + private fun resetEpoch() { + attachedKey = null + pending = null + executionJob?.cancel() + applied = null + readiness = null + deferredReload = null + stallStartedAtMs = null + stallReported = false + _catchingUp.value = false + clearRate() + } + + // ---- commands ------------------------------------------------------------- + + private fun accept(scheduled: ScheduledTransportCommand) { + val command = scheduled.command + val connection = room.connectionState.value + if (scheduled.connection.generation != connection.generation || scheduled.connection.epoch != connection.epoch) return + val commandConnection = connection.generation to connection.epoch + if (command.commandId.isBlank() || + (command.commandId == lastSeenCommandId && commandConnection == lastCommandConnection) + ) return + lastSeenCommandId = command.commandId + lastCommandConnection = commandConnection + stopCorrections(monotonicNowMs()) + val token = ++nextToken + val queued = PendingCommand(token, scheduled, scheduled.executeAtMs) + pending = queued + readiness = null + scheduleExecution(queued) + } + + private fun scheduleExecution(command: PendingCommand) { + executionJob?.cancel() + if (room.clock.value.offsetMs == null) player.setPlaying(false) + executionJob = scope.launch { + val waitStarted = monotonicNowMs() + var notified = false + // Socket pings keep trying. Retain the command until one supplies + // the server clock; a device clock is never a usable substitute. + while (room.clock.value.offsetMs == null) { + if (!notified && monotonicNowMs() - waitStarted >= timing.clockWaitMs) { + _notices.tryEmit(RoomPlaybackNotice.ClockUnavailable) + notified = true + } + delay(timing.tickMs) + } + val serverNow = serverNowMs() ?: return@launch + delay(((command.executeAtServerMs ?: serverNow) - serverNow).coerceIn(0L, timing.maxLeadMs)) + events.send(Event.Execute(command.token)) + } + } + + private fun execute(token: Long) { + val scheduled = pending?.takeIf { it.token == token } ?: return + // A command that lands while media is mounting or seeking would act + // on media that is being replaced; hold it briefly until it settles. + // A late Play still advances by the full elapsed server time. + if (player.observations.value.seekPending) { + val now = monotonicNowMs() + val since = scheduled.deferredSinceMs ?: now.also { scheduled.deferredSinceMs = it } + if (now - since < timing.mountDeferMs) { + scope.launch { + delay(timing.tickMs) + events.send(Event.Execute(token)) + } + return + } + } + val command = scheduled.scheduled.command + val snapshot = room.roomSnapshot.value ?: return + val connection = room.connectionState.value + val obs = player.observations.value + val sessionId = obs.sessionId + if ( + snapshot.phase != RoomPhase.Playing || + command.selectionRevision != snapshot.selectionRevision || + obs.selectionRevision != snapshot.selectionRevision || + (command.sessionId.isNotBlank() && command.sessionId != sessionId) || + scheduled.scheduled.connection.generation != connection.generation || + scheduled.scheduled.connection.epoch != connection.epoch + ) { + pending = null + return + } + val serverNow = serverNowMs() + if (serverNow == null) { + scheduleExecution(scheduled) + return + } + pending = null + val now = monotonicNowMs() + val advancing = command.action != TransportAction.Pause && command.playbackState == RoomPlaybackState.Playing + val lateMs = (serverNow - (scheduled.executeAtServerMs ?: serverNow)).coerceAtLeast(0L) + val target = command.positionSeconds + if (advancing) lateMs / 1000.0 else 0.0 + val playing = when (command.action) { + TransportAction.Play -> command.playbackState != RoomPlaybackState.Waiting + TransportAction.Pause -> false + else -> command.playbackState == RoomPlaybackState.Playing + } + when (val decision = RoomCatchup.decide( + action = command.action, + targetSeconds = target, + localSeconds = obs.sourcePositionSeconds, + targetReachableLocally = player.isBuffered(target), + )) { + RoomCatchup.Decision.Seek -> { + if (command.action == TransportAction.Seek || player.isBuffered(target)) { + player.seekTo(target) + } else if (reloads.allowed(now)) { + // A Play correction that must load new media is paced. + beginReload(target, now, obs) + } else { + // Held until the budget allows, so the viewer is not left + // playing at the wrong position with nothing to fix it. + deferredReload = Convergence(target, now) + } + } + is RoomCatchup.Decision.Rate -> { + // [target] already includes the late start, so it advances from now. + convergence = Convergence(target, now) + setRate(decision.rate) + } + RoomCatchup.Decision.None -> if (command.action == TransportAction.Play) reloads.settle() + } + // A local suspension holds playback: the viewer resumes on their own and + // the binding re-attaches, so the server resends the room position. + if (!playing || !obs.suspended) player.setPlaying(playing) + applied = command + appliedAtMs = now + if (command.playbackState == RoomPlaybackState.Waiting) { + readiness = Readiness( + commandId = command.commandId, + seekTarget = target.takeIf { command.action == TransportAction.Seek }, + armedWith = snapshot, + ) + } + } + + private fun converge(now: Long, obs: RoomPlayerObservation) { + if (reloads.inFlight && obs.isPlaying && reloads.landed(obs.sourcePositionSeconds)) reloads.land(now) + val target = convergence ?: return + if (obs.seekPending || !obs.isPlaying || obs.suspended) { + stopCorrections(now) + return + } + val expected = RoomCatchup.expectedPosition(target.targetSeconds, target.executeAtMonotonicMs, now) + val drift = expected - obs.sourcePositionSeconds + if (abs(drift) <= RoomCatchup.DEADBAND_SECONDS || abs(drift) > RoomCatchup.BAND_SECONDS) { + convergence = null + setRate(null) + if (abs(drift) <= RoomCatchup.DEADBAND_SECONDS) reloads.settle() + } else { + setRate(RoomCatchup.rateFor(drift)) + } + } + + /** + * Run a held Play correction once the reload budget allows, aimed at where + * the room is now. Drift that shrank in the meantime is settled the + * cheaper way: nothing, a buffered seek, or a rate. + */ + private fun retryDeferredReload(now: Long, obs: RoomPlayerObservation) { + val held = deferredReload ?: return + if (obs.seekPending || !obs.isPlaying || obs.suspended) return + val expected = RoomCatchup.expectedPosition(held.targetSeconds, held.executeAtMonotonicMs, now) + val drift = expected - obs.sourcePositionSeconds + when { + abs(drift) <= RoomCatchup.DEADBAND_SECONDS -> { + deferredReload = null + reloads.settle() + } + player.isBuffered(expected) -> { + deferredReload = null + player.seekTo(expected) + } + abs(drift) <= RoomCatchup.BAND_SECONDS -> { + deferredReload = null + convergence = held + setRate(RoomCatchup.rateFor(drift)) + } + reloads.allowed(now) -> { + deferredReload = null + beginReload(expected, now, obs) + } + } + } + + private fun beginReload(target: Double, now: Long, obs: RoomPlayerObservation) { + val aimed = reloads.begin(target, now, obs.durationSeconds.takeIf { it > 0.0 }) + player.seekTo(aimed) + reloads.noteLoading() + } + + private fun stopCorrections(now: Long) { + clearRate() + deferredReload = null + if (reloads.inFlight) reloads.abandon(now) + } + + private fun clearRate() { + if (convergence != null || rateApplied != null) { + convergence = null + setRate(null) + } + } + + private fun setRate(rate: Double?) { + val normalized = rate?.takeIf { abs(it - 1.0) > 0.001 } + if (normalized == rateApplied) return + rateApplied = normalized + player.setCorrectionRate(normalized) + } + + private fun alignPausedToAnchor(snapshot: RoomSnapshot, obs: RoomPlayerObservation) { + val anchor = snapshot.anchorPositionSeconds + if (abs(anchor - obs.sourcePositionSeconds) > RoomCatchup.DEADBAND_SECONDS && player.isBuffered(anchor)) { + player.seekTo(anchor) + } + player.setPlaying(false) + } + + private fun recordSustainedStall(now: Long) { + sustainedStalls.addLast(now) + while (sustainedStalls.isNotEmpty() && now - sustainedStalls.first() > timing.stallWindowMs) { + sustainedStalls.removeFirst() + } + if (sustainedStalls.size >= 2) _offerLowerQuality.value = true + } + + private fun serverNowMs(): Long? = room.clock.value.offsetMs?.let { wallNowMs() + it } + + private companion object { + /** A "last sent" time far enough in the past that any interval has elapsed, without overflow. */ + const val NEVER_MS = -1_000_000_000_000L + } + + private fun expectedLocalExecuteMs(pending: PendingCommand): Long { + val serverNow = serverNowMs() ?: return monotonicNowMs() + return monotonicNowMs() + ((pending.executeAtServerMs ?: serverNow) - serverNow) + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomProof.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomProof.kt new file mode 100644 index 0000000000..e6d1c8d330 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomProof.kt @@ -0,0 +1,37 @@ +package org.prairieserver.prairie.watchtogether + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.longOrNull +import org.prairieserver.prairie.network.PrairieJson +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * A room access token and the expiry the server signed into it. Renewing the + * proof never changes room membership. + */ +class RoomProof( + val token: String, + val expiresAtEpochMs: Long?, +) { + override fun toString(): String = "RoomProof(token=<redacted>, expiresAtEpochMs=$expiresAtEpochMs)" +} + +/** + * Reads the signed `exp` claim (epoch seconds) from a room proof JWT without + * verifying it; the server verifies. Returns epoch milliseconds, or null when + * the token does not carry a readable expiry. + */ +@OptIn(ExperimentalEncodingApi::class) +fun roomProofExpiryMs(token: String): Long? { + val payload = token.split('.').takeIf { it.size == 3 }?.get(1)?.takeIf { it.isNotEmpty() } ?: return null + val padded = payload + "=".repeat((4 - payload.length % 4) % 4) + val claims = try { + PrairieJson.parseToJsonElement(Base64.UrlSafe.decode(padded).decodeToString()) as? JsonObject + } catch (_: Exception) { + null + } ?: return null + val exp = (claims["exp"] as? JsonPrimitive)?.longOrNull ?: return null + return exp.takeIf { it > 0 }?.times(1_000L) +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomSession.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomSession.kt index 08fe624ec5..6c1aaf7d12 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomSession.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomSession.kt @@ -21,21 +21,28 @@ interface RoomSessionRepository { val roomSnapshot: StateFlow<RoomSnapshot?> val roomClosedReason: StateFlow<String?> + /** + * The local generation of the current membership, or null when there is no + * live membership. Joining the same room again produces a new generation. + */ + suspend fun membershipGeneration(): Long? suspend fun connect(roomId: String) suspend fun reset() suspend fun closeRoom(): ApiResult<Unit> } -/** A Watch Together room, not a device-local episode queue, owns title changes. */ +/** A Watch Party room, not a device-local episode queue, owns title changes. */ fun shouldNavigateToLocalNext(inWatchTogetherRoom: Boolean): Boolean = !inWatchTogetherRoom /** - * The single application-scoped owner of a Watch Together connection. + * The single application-scoped owner of a Watch Party connection. * * Screen scopes adopt this session; they never own the socket. Replacement * and leave are serialized, and the previous job is joined before repository - * state can be reused by another room. + * state can be reused by another membership. Connections are keyed by + * membership generation, not room id, so an explicit rejoin of the same room + * replaces the obsolete connection instead of being mistaken for it. */ class RoomSession( private val repository: RoomSessionRepository, @@ -44,7 +51,7 @@ class RoomSession( ) { private val mutex = Mutex() private var connectionJob: Job? = null - private var connectedRoomId: String? = null + private var connectedGeneration: Long? = null val room: StateFlow<RoomSnapshot?> = repository.roomSnapshot val closedReason: StateFlow<String?> = repository.roomClosedReason @@ -65,14 +72,14 @@ class RoomSession( scope.launch(start = CoroutineStart.UNDISPATCHED) { enter(roomId) } /** - * UI-facing durable departure. If requested, close is attempted while the - * current lease is still live; teardown then joins the socket and resets - * state even if the initiating screen disappears. + * UI-facing durable departure. If requested, End is attempted while the + * current membership is still live; teardown then joins the socket and + * resets state even if the initiating screen disappears. */ fun depart(closeRoom: Boolean = false): Job = scope.launch(start = CoroutineStart.UNDISPATCHED) { mutex.withLock { - if (closeRoom && connectedRoomId != null) { + if (closeRoom && connectedGeneration != null) { try { repository.closeRoom() } finally { @@ -87,11 +94,14 @@ class RoomSession( suspend fun enter(roomId: String) { if (roomId.isBlank()) return mutex.withLock { - if (connectedRoomId == roomId && connectionJob?.isActive == true) return + val generation = repository.membershipGeneration() ?: return + if (connectedGeneration == generation && connectionJob?.isActive == true) return + // Join the obsolete socket's teardown before the new membership + // can own shared state. connectionJob?.cancelAndJoin() - connectedRoomId = roomId + connectedGeneration = generation // Establish repository ownership before enter() returns. Without - // UNDISTPATCHED, a concurrent replacement can cancel a queued job + // UNDISPATCHED, a concurrent replacement can cancel a queued job // before it ever installs its connection owner. connectionJob = scope.launch(start = CoroutineStart.UNDISPATCHED) { repository.connect(roomId) @@ -110,7 +120,7 @@ class RoomSession( private suspend fun leaveLocked() { connectionJob?.cancelAndJoin() connectionJob = null - connectedRoomId = null + connectedGeneration = null repository.reset() } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomVote.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomVote.kt index 680f5d185f..9ce34c9f89 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomVote.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomVote.kt @@ -3,15 +3,27 @@ package org.prairieserver.prairie.watchtogether import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode import org.prairieserver.prairie.model.watchtogether.RoomSnapshot import org.prairieserver.prairie.model.watchtogether.Suggestion +import org.prairieserver.prairie.util.parseRfc3339ToEpochMillis /** - * Returns the winner from the server-ordered suggestion list. - * - * The server sorts by vote count and then age, so the first suggestion wins - * only after somebody has voted. An unvoted list intentionally has no winner. + * The server's vote order: most votes first, then the oldest suggestion. + * HTTP pages arrive in creation order, so display and winner selection must + * rank the merged list rather than trust the order it arrived in. + */ +fun rankSuggestions(suggestions: List<Suggestion>): List<Suggestion> = + suggestions.sortedWith( + compareByDescending<Suggestion> { it.voteCount } + .thenBy { parseRfc3339ToEpochMillis(it.createdAt) ?: Long.MAX_VALUE } + .thenBy { it.id }, + ) + +/** + * The leading suggestion once somebody has voted. An unvoted list has no + * winner. The tally is advisory: a host with the override capability may + * promote any suggestion. */ fun roomVoteWinner(suggestions: List<Suggestion>): Suggestion? = - suggestions.firstOrNull()?.takeIf { it.voteCount > 0 } + rankSuggestions(suggestions).firstOrNull()?.takeIf { it.voteCount > 0 } /** Whether this room decides its selection by vote. */ fun RoomSnapshot?.isVoteRoom(): Boolean = diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyAvailabilityRepository.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyAvailabilityRepository.kt new file mode 100644 index 0000000000..0857ef3422 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyAvailabilityRepository.kt @@ -0,0 +1,103 @@ +package org.prairieserver.prairie.watchtogether + +import org.prairieserver.prairie.model.watchtogether.WatchTogetherCapabilitiesV2 +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.apiv2.PlaybackCapabilitiesV2 +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Discovers whether this server and profile can use Watch Party, from the room + * and playback capability documents, and caches the answer for the captured + * authority. A change of server, account, or profile discards the cache. A + * failed probe never downgrades a known answer: it keeps the last result for + * this authority and only reports [WatchPartyAvailability.ProbeFailed] when + * nothing is known yet. + */ +class WatchPartyAvailabilityRepository( + private val roomCapabilities: suspend (AuthScopeSnapshot) -> ApiResult<WatchTogetherCapabilitiesV2>, + private val playbackCapabilities: suspend (AuthScopeSnapshot) -> ApiResult<PlaybackCapabilitiesV2>, + private val authScopeProvider: suspend () -> AuthScopeSnapshot?, +) { + private val mutex = Mutex() + private var cachedOwner: AuthScopeSnapshot? = null + private val _availability = MutableStateFlow<WatchPartyAvailability?>(null) + + /** The last known answer for the current authority, or null before the first probe. */ + val availability: StateFlow<WatchPartyAvailability?> = _availability.asStateFlow() + + /** The features of the current authority, when Watch Party is available. */ + val features: WatchPartyFeatures? + get() = (_availability.value as? WatchPartyAvailability.Available)?.features + + /** + * Probe unless a definite answer for the current authority is cached. + * [force] probes again even then (the user pressed Retry, or the app + * returned to the foreground). + */ + suspend fun refresh(force: Boolean = false): WatchPartyAvailability = mutex.withLock { + val scope = authScopeProvider() + ?: return@withLock WatchPartyAvailability.ProbeFailed(NOT_SIGNED_IN).also { forgetLocked() } + if (!sameOwner(cachedOwner, scope)) { + cachedOwner = scope + _availability.value = null + } + val cached = _availability.value + if (!force && cached != null && cached !is WatchPartyAvailability.ProbeFailed) return@withLock cached + val probed = probe(scope) + if (!sameOwner(authScopeProvider(), scope)) { + return@withLock WatchPartyAvailability.ProbeFailed(IDENTITY_CHANGED) + } + val published = if (probed is WatchPartyAvailability.ProbeFailed && cached != null && + cached !is WatchPartyAvailability.ProbeFailed + ) { + cached + } else { + probed + } + _availability.value = published + published + } + + /** Drop the cached answer, for example on sign-out. */ + suspend fun forget() = mutex.withLock { forgetLocked() } + + private fun forgetLocked() { + cachedOwner = null + _availability.value = null + } + + private suspend fun probe(scope: AuthScopeSnapshot): WatchPartyAvailability { + val room = when (val r = roomCapabilities(scope)) { + is ApiResult.Success -> r.data + is ApiResult.Error -> return WatchPartyAvailability.ProbeFailed(r.message.ifBlank { r.error }) + is ApiResult.NetworkError -> return WatchPartyAvailability.ProbeFailed(r.exception.message ?: NETWORK) + } + if (room.state != "available") return watchPartyAvailability(room, UNAVAILABLE_PLAYBACK) + val playback = when (val r = playbackCapabilities(scope)) { + is ApiResult.Success -> r.data + is ApiResult.Error -> return WatchPartyAvailability.ProbeFailed(r.message.ifBlank { r.error }) + is ApiResult.NetworkError -> return WatchPartyAvailability.ProbeFailed(r.exception.message ?: NETWORK) + } + return watchPartyAvailability(room, playback) + } + + private fun sameOwner(a: AuthScopeSnapshot?, b: AuthScopeSnapshot?): Boolean = + a != null && b != null && a.isSameIdentityAs(b) && a.profileId == b.profileId + + private companion object { + const val NOT_SIGNED_IN = "not_signed_in" + const val IDENTITY_CHANGED = "identity_changed" + const val NETWORK = "network_error" + val UNAVAILABLE_PLAYBACK = PlaybackCapabilitiesV2( + state = "unavailable", + allowed = false, + protocolVersions = emptyList(), + features = emptyList(), + ) + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyCapabilities.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyCapabilities.kt new file mode 100644 index 0000000000..9f2e71477e --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyCapabilities.kt @@ -0,0 +1,79 @@ +package org.prairieserver.prairie.watchtogether + +import org.prairieserver.prairie.model.watchtogether.WatchTogetherCapabilitiesV2 +import org.prairieserver.prairie.network.apiv2.PlaybackCapabilitiesV2 + +/** Playback capability features Watch Party depends on. */ +object WatchPartyPlaybackFeatures { + const val Coordinator = "watch_party_coordinator_v1" + const val FixedMediaFile = "fixed_media_file_v1" + const val SourceFallback = "watch_party_source_fallback_v1" +} + +/** The v2 room socket protocol a server must serve for native Watch Party. */ +const val WATCH_PARTY_SOCKET_PROTOCOL = "prairie.room.v2" + +/** + * Optional behaviour, each gated by its own server flag. A missing flag hides + * the matching control; it never substitutes a different operation. + */ +data class WatchPartyFeatures( + val stagedSelection: Boolean, + val stopPlayback: Boolean, + val selectionModeSwitch: Boolean, + val lobbyReady: Boolean, + val picker: Boolean, + val memberState: Boolean, + val maxMemberStateIds: Int, + val voteHostOverride: Boolean, + val sourceFallback: Boolean, +) + +sealed interface WatchPartyAvailability { + data class Available(val features: WatchPartyFeatures) : WatchPartyAvailability + + /** The server does not serve rooms, or lacks a capability native Watch Party requires. */ + data class Unsupported(val missing: List<String>) : WatchPartyAvailability + + /** The server serves rooms, but this account may not use them. */ + data object NotAllowed : WatchPartyAvailability + + /** Discovery itself failed. Offer Retry; this is not evidence the server lacks support. */ + data class ProbeFailed(val message: String) : WatchPartyAvailability +} + +private const val STATE_AVAILABLE = "available" + +/** + * Combines the room and playback capability documents. Entry needs a served + * v2 room socket with the replacement signal, plus the shared coordinator and + * fixed-file playback. Everything else is an optional [WatchPartyFeatures] flag. + */ +fun watchPartyAvailability( + room: WatchTogetherCapabilitiesV2, + playback: PlaybackCapabilitiesV2, +): WatchPartyAvailability { + val missing = buildList { + if (room.state != STATE_AVAILABLE) add("rooms") + if (room.socketProtocol != WATCH_PARTY_SOCKET_PROTOCOL) add("room_socket") + if (!room.connectionReplaced) add("connection_replaced") + if (playback.state != STATE_AVAILABLE) add("playback") + if (WatchPartyPlaybackFeatures.Coordinator !in playback.features) add(WatchPartyPlaybackFeatures.Coordinator) + if (WatchPartyPlaybackFeatures.FixedMediaFile !in playback.features) add(WatchPartyPlaybackFeatures.FixedMediaFile) + } + if (missing.isNotEmpty()) return WatchPartyAvailability.Unsupported(missing) + if (room.allowed != true || !playback.allowed) return WatchPartyAvailability.NotAllowed + return WatchPartyAvailability.Available( + WatchPartyFeatures( + stagedSelection = room.stagedSelection, + stopPlayback = room.stopPlayback, + selectionModeSwitch = room.selectionModeSwitch, + lobbyReady = room.lobbyReady, + picker = room.picker, + memberState = room.memberState && room.maxMemberStateIds > 0, + maxMemberStateIds = if (room.memberState) room.maxMemberStateIds.coerceAtLeast(0) else 0, + voteHostOverride = room.voteHostOverride, + sourceFallback = WatchPartyPlaybackFeatures.SourceFallback in playback.features, + ), + ) +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyEligibility.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyEligibility.kt new file mode 100644 index 0000000000..1de5a490de --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyEligibility.kt @@ -0,0 +1,132 @@ +package org.prairieserver.prairie.watchtogether + +import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.Suggestion +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.repository.WatchPartyEndReason + +/** Where the party screens should be for a room: its phase alone decides. */ +sealed interface WatchPartyDestination { + val roomId: String + + data class Lobby(override val roomId: String) : WatchPartyDestination + data class Player(override val roomId: String) : WatchPartyDestination +} + +/** + * The lobby while the room is in its lobby phase, the player while it plays. + * A staged item never opens the player, and a host alone still plays. + */ +fun watchPartyDestination(room: RoomSnapshot): WatchPartyDestination? = when (room.phase) { + RoomPhase.Lobby -> WatchPartyDestination.Lobby(room.roomId) + RoomPhase.Playing -> WatchPartyDestination.Player(room.roomId) + RoomPhase.Ended, RoomPhase.Unknown -> null +} + +/** + * What this member may do right now. Every flag depends on the phase, the + * member's role, the server's capabilities, and whether an action is already + * in flight; an unknown phase, role, or mode grants nothing. + */ +data class WatchPartyEligibility( + val isHost: Boolean = false, + val canStage: Boolean = false, + val canStart: Boolean = false, + val canStop: Boolean = false, + val canSwitchMode: Boolean = false, + val canSetPolicy: Boolean = false, + val canLobbyReady: Boolean = false, + val canSuggest: Boolean = false, + val canVote: Boolean = false, + val canPromote: Boolean = false, + val canQueue: Boolean = false, + val canEnd: Boolean = false, + val busy: Boolean = false, +) + +fun watchPartyEligibility( + room: RoomSnapshot?, + features: WatchPartyFeatures?, + busy: Boolean, + personalVotesKnown: Boolean, +): WatchPartyEligibility { + if (room == null || features == null) return WatchPartyEligibility(busy = busy) + val host = room.selfRole == MemberRole.Host && room.selfCanManageRoom + val known = room.selfRole != MemberRole.Unknown + val lobby = room.phase == RoomPhase.Lobby + val playing = room.phase == RoomPhase.Playing + val hostPick = room.selectionMode == RoomSelectionMode.HostPick + val vote = room.selectionMode == RoomSelectionMode.Vote + val staged = !room.selectedContentId.isNullOrBlank() + val free = !busy + return WatchPartyEligibility( + isHost = host, + canStage = free && host && lobby && hostPick && features.stagedSelection, + canStart = free && host && lobby && staged && features.stagedSelection, + canStop = free && host && playing && features.stopPlayback, + canSwitchMode = free && host && lobby && features.selectionModeSwitch && (hostPick || vote), + canSetPolicy = free && host && (lobby || playing), + // Advisory and allowed in any lobby, as on Apple: the server clears it + // whenever the selection changes. + canLobbyReady = known && !host && lobby && features.lobbyReady, + canSuggest = free && known && (lobby || playing) && (hostPick || vote), + canVote = free && known && vote && (lobby || playing) && personalVotesKnown, + canPromote = free && host && vote && (lobby || playing) && features.voteHostOverride, + canQueue = free && host && lobby && hostPick && features.stagedSelection, + canEnd = host && (lobby || playing), + busy = busy, + ) +} + +/** + * Whether a "host a party" entry point should show. Hidden once the server is + * known not to host (no staged selection, unsupported, or not allowed); shown + * while unknown, since the hub then explains what the probe finds. + */ +fun watchPartyHostingOffered(availability: WatchPartyAvailability?): Boolean = when (availability) { + is WatchPartyAvailability.Available -> availability.features.stagedSelection + is WatchPartyAvailability.Unsupported, WatchPartyAvailability.NotAllowed -> false + is WatchPartyAvailability.ProbeFailed, null -> true +} + +/** The host, or the suggester matched by both account and profile, may remove a suggestion. */ +fun canRemoveSuggestion(room: RoomSnapshot?, suggestion: Suggestion): Boolean { + room ?: return false + if (room.selfRole == MemberRole.Host && room.selfCanManageRoom) return true + val self = room.selfMember ?: return false + return self.userId.isNotBlank() && self.profileId.isNotBlank() && + suggestion.suggesterUserId == self.userId && suggestion.suggesterProfileId == self.profileId +} + +/** Advisory lobby readiness: connected guests who marked themselves ready, out of all connected guests. */ +data class LobbyReadiness(val ready: Int, val guests: Int) + +fun lobbyReadiness(room: RoomSnapshot?): LobbyReadiness { + val guests = room?.members.orEmpty().filter { it.connected && !it.isHost } + return LobbyReadiness(ready = guests.count { it.lobbyReady }, guests = guests.size) +} + +/** Plain text for a failed Watch Party request. [fallback] covers anything unrecognized. */ +fun watchPartyErrorMessage(result: ApiResult<*>, fallback: String): String = when (result) { + is ApiResult.Success -> fallback + is ApiResult.NetworkError -> "Couldn't reach the server. Check your connection and try again." + is ApiResult.Error -> when { + result.error == WatchPartyEndReason.Ended || result.error == "no_active_room" -> "This Watch Party has ended." + result.error == "obsolete_room_request" || result.error == "identity_changed" -> + "Your profile changed, so that didn't go through." + result.error == "invalid_response" || result.error == "invalid_room_response" -> + "The server sent an unexpected answer. Try again." + result.code == 401 -> "Sign in again to use Watch Party." + result.code == 403 && result.error == "profile_verification_required" -> "Unlock this profile to use Watch Party." + result.code == 403 -> "Only the host can do that." + result.code == 404 -> "That Watch Party wasn't found." + result.code == 409 -> "That didn't work because the party changed. Check the room and try again." + result.code == 422 -> "That item can't be played in a Watch Party." + result.code == 429 -> "Too many requests. Wait a moment and try again." + result.code == 503 -> "Watch Party is unavailable on this server right now." + else -> fallback + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyIds.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyIds.kt new file mode 100644 index 0000000000..17d7fbfb41 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyIds.kt @@ -0,0 +1,12 @@ +package org.prairieserver.prairie.watchtogether + +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * A caller-selected room or suggestion identity. Create it once per logical + * action and keep it for an explicit retry, so the server can replay instead + * of creating a duplicate. + */ +@OptIn(ExperimentalUuidApi::class) +fun newWatchPartyId(): String = Uuid.random().toString() diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyInvitation.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyInvitation.kt new file mode 100644 index 0000000000..3bfdec65c0 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyInvitation.kt @@ -0,0 +1,139 @@ +package org.prairieserver.prairie.watchtogether + +/** Something a user entered, pasted, or opened to join a Watch Party. */ +sealed interface WatchPartyInvite { + /** An 8-character room code for the current server. */ + data class Code(val code: String) : WatchPartyInvite + + /** + * A join token from an invitation link. [serverUrl] is the deployment the + * link names (scheme, host, port, and base path); the caller must match it + * to the current server before sending the token anywhere. + */ + data class Link(val serverUrl: String, val token: String) : WatchPartyInvite { + override fun toString(): String = "Link(serverUrl=<redacted>, token=<redacted>)" + } +} + +/** Room codes are 8 characters from this alphabet (no 0, 1, I, or O). */ +const val WATCH_PARTY_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" +const val WATCH_PARTY_CODE_LENGTH = 8 + +/** + * Normalizes typed input into a room code: trims, drops spaces and dashes, + * and uppercases. Returns null unless the result is a well-formed code. The + * server matches codes without regard to case but does not strip + * separators, so this must run before sending. + */ +fun normalizeWatchPartyCode(input: String): String? { + val code = input.filterNot { it.isWhitespace() || it == '-' }.uppercase() + return code.takeIf { it.length == WATCH_PARTY_CODE_LENGTH && it.all { c -> c in WATCH_PARTY_CODE_ALPHABET } } +} + +/** + * Parses a room code, a server invitation link + * (`https://host[:port][/base]/rooms/join?token=…`), or an app link + * (`prairie://watch-party?server=<server URL>&token=…`). The app link is + * validated as strictly as the web link: an http or https server with no + * credentials, query, or fragment, and exactly one non-empty `server` and + * `token`. Account invitation links (`prairie://invite`) are not party links. + */ +fun parseWatchPartyInvite(input: String): WatchPartyInvite? { + val text = input.trim() + if (text.isEmpty()) return null + normalizeWatchPartyCode(text)?.let { return WatchPartyInvite.Code(it) } + val scheme = text.substringBefore("://", missingDelimiterValue = "").lowercase() + return when (scheme) { + "http", "https" -> parseWebInvite(text) + "prairie" -> parseAppInvite(text) + else -> null + } +} + +private fun parseWebInvite(text: String): WatchPartyInvite? { + val withoutFragment = text.substringBefore('#') + val base = withoutFragment.substringBefore('?') + val query = withoutFragment.substringAfter('?', missingDelimiterValue = "") + if (!base.endsWith(JOIN_PATH)) return null + val server = base.removeSuffix(JOIN_PATH) + if (!validServerUrl(server)) return null + val token = singleParameter(query, "token") ?: return null + if (parameterNames(query).any { it != "token" }) return null + return WatchPartyInvite.Link(serverUrl = server, token = token) +} + +private fun parseAppInvite(text: String): WatchPartyInvite? { + if (text.contains('#')) return null + val afterScheme = text.substringAfter("://") + val host = afterScheme.substringBefore('?').removeSuffix("/") + if (!host.equals(APP_LINK_HOST, ignoreCase = true)) return null + val query = afterScheme.substringAfter('?', missingDelimiterValue = "") + val names = parameterNames(query) + if (names.any { it != "server" && it != "token" }) return null + val server = singleParameter(query, "server")?.removeSuffix("/") ?: return null + val token = singleParameter(query, "token") ?: return null + if (!validServerUrl(server)) return null + return WatchPartyInvite.Link(serverUrl = server, token = token) +} + +private fun validServerUrl(url: String): Boolean { + val scheme = url.substringBefore("://", missingDelimiterValue = "").lowercase() + if (scheme != "http" && scheme != "https") return false + val rest = url.substringAfter("://") + val authority = rest.substringBefore('/') + return authority.isNotEmpty() && '@' !in authority && '?' !in url && '#' !in url && + authority.none { it.isWhitespace() } +} + +private fun parameterNames(query: String): List<String> = + query.split('&').filter { it.isNotEmpty() }.map { it.substringBefore('=') } + +/** The single non-empty value of [name], or null when missing, empty, or repeated. */ +private fun singleParameter(query: String, name: String): String? { + val values = query.split('&').filter { it.substringBefore('=') == name }.map { it.substringAfter('=', "") } + if (values.size != 1) return null + return percentDecode(values.single()).takeIf { it.isNotBlank() } +} + +private fun percentDecode(value: String): String { + if ('%' !in value && '+' !in value) return value + val bytes = ArrayList<Byte>(value.length) + var i = 0 + while (i < value.length) { + val c = value[i] + when { + c == '%' && i + 2 <= value.lastIndex -> { + val hex = value.substring(i + 1, i + 3).toIntOrNull(16) + if (hex == null) { + bytes += c.code.toByte() + i++ + } else { + bytes += hex.toByte() + i += 3 + } + } + c == '+' -> { + bytes += ' '.code.toByte() + i++ + } + else -> { + c.toString().encodeToByteArray().forEach { bytes += it } + i++ + } + } + } + return bytes.toByteArray().decodeToString() +} + +private const val JOIN_PATH = "/rooms/join" +private const val APP_LINK_HOST = "watch-party" + +/** + * The web invitation link for a host's `invite_path`, resolved against the + * verified server URL including its base path. Null when the path is not a + * relative room invitation. + */ +fun watchPartyInviteUrl(serverUrl: String, invitePath: String?): String? { + val path = invitePath?.takeIf { it.startsWith("$JOIN_PATH?") } ?: return null + return serverUrl.trimEnd('/') + path +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyLobbyPresentation.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyLobbyPresentation.kt new file mode 100644 index 0000000000..39a52cffe7 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyLobbyPresentation.kt @@ -0,0 +1,260 @@ +package org.prairieserver.prairie.watchtogether + +import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.model.watchtogether.RoomMember +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.Suggestion + +/* + * How the lobby reads, shared by phone and TV so both say what the Apple + * clients say (`WatchPartyLobbyPolicy` in prairie-apple). Layout stays in each + * app; the words and the one foregrounded action live here. + */ + +/** How a member's seat reads in the lobby and during playback. */ +enum class WatchPartySeatState { Ready, NotReady, Away, Watching, Syncing, Buffering, Joining } + +/** The host starts playback, so the host never has to mark ready. */ +fun watchPartySeatState(member: RoomMember, phase: RoomPhase): WatchPartySeatState = when { + !member.connected -> WatchPartySeatState.Away + phase == RoomPhase.Lobby -> + if (member.isHost || member.lobbyReady) WatchPartySeatState.Ready else WatchPartySeatState.NotReady + member.isBuffering -> WatchPartySeatState.Buffering + member.isSyncing -> WatchPartySeatState.Syncing + member.isReady -> WatchPartySeatState.Watching + else -> WatchPartySeatState.Joining +} + +/** The short word under a seat's name, or null when the ring already says it. */ +fun watchPartySeatStatus(state: WatchPartySeatState): String? = when (state) { + WatchPartySeatState.Ready, WatchPartySeatState.Watching -> null + WatchPartySeatState.NotReady -> "not ready" + WatchPartySeatState.Away -> "reconnecting" + WatchPartySeatState.Syncing -> "syncing" + WatchPartySeatState.Buffering -> "buffering" + WatchPartySeatState.Joining -> "joining" +} + +/** Host first, then this viewer, then everyone else by name. */ +fun watchPartySeatOrder(members: List<RoomMember>): List<RoomMember> = + members.sortedWith( + compareByDescending<RoomMember> { it.isHost } + .thenByDescending { it.isSelf } + .thenBy { it.displayName.lowercase() }, + ) + +/** The trailing summary beside "Here now": "only you so far", "2 of 3 ready". */ +fun watchPartyPresenceSummary(members: List<RoomMember>, phase: RoomPhase): String { + val here = members.filter { it.connected } + if (here.size <= 1) return if (here.size == 1) "only you so far" else "nobody connected" + return if (phase == RoomPhase.Lobby) { + val ready = here.count { watchPartySeatState(it, phase) == WatchPartySeatState.Ready } + "$ready of ${here.size} ready" + } else { + val watching = here.count { watchPartySeatState(it, phase) == WatchPartySeatState.Watching } + "$watching of ${here.size} watching" + } +} + +/** Connected guests who have not marked ready, for the host's advisory hint. */ +fun watchPartyLobbyWaitingNames(members: List<RoomMember>): List<String> = + members.filter { it.connected && !it.isHost && !it.lobbyReady }.map { it.displayName.ifBlank { "Someone" } } + +/** The one action the lobby foregrounds for this member right now. */ +sealed interface WatchPartyPrimaryAction { + /** Host in host-pick mode with nothing staged. */ + data object ChooseTitle : WatchPartyPrimaryAction + + /** Host may start now. [title] names the vote winner in a voting room. */ + data class Start(val title: String?) : WatchPartyPrimaryAction + + /** Host in a voting room before any suggestion has a vote. */ + data object WaitingForVotes : WatchPartyPrimaryAction + + /** A guest's lobby readiness toggle. */ + data class Ready(val isReady: Boolean) : WatchPartyPrimaryAction + + /** The room is playing; this device can go back to the player. */ + data object ReturnToPlayback : WatchPartyPrimaryAction + + data object None : WatchPartyPrimaryAction +} + +private val RoomSnapshot.managedBySelf: Boolean + get() = selfRole == MemberRole.Host && selfCanManageRoom + +/** + * Whether the host could start now, independent of any request in flight so + * the lobby's buttons keep their place while one runs. A voting room starts + * its leader; a host-pick room starts its staged title. + */ +fun watchPartyCanStart(room: RoomSnapshot?, features: WatchPartyFeatures?, suggestions: List<Suggestion>): Boolean { + if (room == null || !room.managedBySelf || room.phase != RoomPhase.Lobby) return false + return when (room.selectionMode) { + // Starting a voting room promotes its leader, which needs the promote route. + RoomSelectionMode.Vote -> features?.voteHostOverride == true && roomVoteWinner(suggestions) != null + RoomSelectionMode.HostPick -> features?.stagedSelection == true && !room.selectedContentId.isNullOrBlank() + else -> false + } +} + +fun watchPartyPrimaryAction( + room: RoomSnapshot, + features: WatchPartyFeatures?, + suggestions: List<Suggestion>, + selectionUnavailable: Boolean = false, +): WatchPartyPrimaryAction { + val manages = room.managedBySelf + when (room.phase) { + RoomPhase.Playing -> + return if (selectionUnavailable && !manages) WatchPartyPrimaryAction.None else WatchPartyPrimaryAction.ReturnToPlayback + RoomPhase.Lobby -> Unit + else -> return WatchPartyPrimaryAction.None + } + // A guest who cannot see the title has nothing to ready up for. + if (selectionUnavailable && !manages) return WatchPartyPrimaryAction.None + val canStart = watchPartyCanStart(room, features, suggestions) + if (manages) { + return when (room.selectionMode) { + RoomSelectionMode.Vote -> + if (canStart) WatchPartyPrimaryAction.Start(roomVoteWinner(suggestions)?.title) else WatchPartyPrimaryAction.WaitingForVotes + RoomSelectionMode.HostPick -> + if (canStart) WatchPartyPrimaryAction.Start(null) else WatchPartyPrimaryAction.ChooseTitle + else -> WatchPartyPrimaryAction.None + } + } + val self = room.selfMember + if (features?.lobbyReady == true && self != null) return WatchPartyPrimaryAction.Ready(self.lobbyReady) + return WatchPartyPrimaryAction.None +} + +/** The secondary lobby action beside the primary one, if any. */ +enum class WatchPartySecondaryAction { ChangeTitle, Suggest } + +fun watchPartySecondaryAction(room: RoomSnapshot, primary: WatchPartyPrimaryAction): WatchPartySecondaryAction? { + if (room.phase != RoomPhase.Lobby) return null + val manages = room.managedBySelf + return when { + manages && room.selectionMode == RoomSelectionMode.HostPick && !room.selectedContentId.isNullOrBlank() && + primary is WatchPartyPrimaryAction.Start -> WatchPartySecondaryAction.ChangeTitle + room.selectionMode == RoomSelectionMode.Vote && primary is WatchPartyPrimaryAction.Start -> + WatchPartySecondaryAction.Suggest + // Guests in either mode can put a title forward. + !manages && room.selfRole != MemberRole.Unknown && + (room.selectionMode == RoomSelectionMode.Vote || room.selectionMode == RoomSelectionMode.HostPick) -> + WatchPartySecondaryAction.Suggest + else -> null + } +} + +private fun hostName(room: RoomSnapshot): String = + room.members.firstOrNull { it.isHost }?.displayName?.ifBlank { null } ?: "The host" + +/** The quiet line under the lobby's buttons, or null. */ +fun watchPartyLobbyHint(room: RoomSnapshot, suggestions: List<Suggestion>): String? { + if (room.phase != RoomPhase.Lobby) return null + val host = hostName(room) + if (room.managedBySelf) { + if (room.selectionMode == RoomSelectionMode.Vote && roomVoteWinner(suggestions) == null) { + return if (suggestions.isEmpty()) "Suggest a title to get the vote going." else "Nobody has voted yet." + } + if (room.selectionMode == RoomSelectionMode.HostPick && room.selectedContentId.isNullOrBlank()) return null + val waiting = watchPartyLobbyWaitingNames(room.members) + if (waiting.isEmpty()) return if (room.members.count { it.connected } > 1) "Everyone's ready." else null + val names = waiting.take(2).joinToString(" and ") + if (waiting.size > 2) " and others" else "" + return "$names ${if (waiting.size == 1) "hasn't" else "haven't"} marked ready. You can still start." + } + if (room.selectionMode == RoomSelectionMode.Vote) { + return "$host starts ${roomVoteWinner(suggestions)?.title ?: "the winner"} when voting settles." + } + return if (room.selectedContentId.isNullOrBlank()) null else "$host starts the movie when everyone's set." +} + +/** The hero's eyebrow, title, and supporting line for the lobby. */ +data class WatchPartyLobbyHero( + val eyebrow: String, + /** Null means "use the staged title's own name". */ + val title: String?, + val supporting: String?, + /** A voting lobby before playback: the question is the title and no poster shows. */ + val isVoteQuestion: Boolean = false, +) + +fun watchPartyLobbyHero(room: RoomSnapshot, suggestions: List<Suggestion>, selectionUnavailable: Boolean = false): WatchPartyLobbyHero { + val host = hostName(room) + val manages = room.managedBySelf + if (room.selectionMode == RoomSelectionMode.Vote && room.phase == RoomPhase.Lobby) { + val count = suggestions.size + return WatchPartyLobbyHero( + eyebrow = "Voting · $count ${if (count == 1) "title" else "titles"}", + title = "What are we watching?", + supporting = if (manages) "Everyone votes. You start the leader." else "Tap a title to vote. $host starts the winner.", + isVoteQuestion = true, + ) + } + val eyebrow = if (room.phase == RoomPhase.Playing) "Now watching · together" else "Up next · together" + if (!room.selectedContentId.isNullOrBlank()) { + return WatchPartyLobbyHero( + eyebrow = eyebrow, + title = if (selectionUnavailable) "A title you can't see" else null, + supporting = if (room.selfRole == MemberRole.Host) "You chose this" else "$host chose this", + ) + } + return WatchPartyLobbyHero( + eyebrow = "Your party is open", + title = if (manages) "Pick something\nto watch" else "Waiting for\n$host", + supporting = if (manages) { + "Friends can join now with the code. They'll see your pick the moment you choose." + } else { + "$host is choosing what to watch. You'll see it here the moment they do." + }, + ) +} + +/** Connection state for the dot beside the code pill. */ +enum class WatchPartyConnectionTone { Good, Warning, Neutral, Bad } + +/** + * The dot's colour and word: "Open" or "2 here" when connected, "Host away" + * for a guest whose host dropped, else the connection state. + */ +fun watchPartyConnectionLabel( + room: RoomSnapshot, + connected: Boolean, + reconnecting: Boolean, +): Pair<WatchPartyConnectionTone, String> = when { + connected && !room.hostConnected && room.selfRole != MemberRole.Host -> WatchPartyConnectionTone.Warning to "Host away" + connected -> { + val here = room.members.count { it.connected } + WatchPartyConnectionTone.Good to if (here <= 1) "Open" else "$here here" + } + reconnecting -> WatchPartyConnectionTone.Warning to "Reconnecting" + else -> WatchPartyConnectionTone.Neutral to "Connecting" +} + +/** The lobby's single status banner, in Apple's order of importance, or null. */ +fun watchPartyLobbyBanner(room: RoomSnapshot, selectionUnavailable: Boolean, reconnecting: Boolean): String? { + val host = hostName(room) + return when { + selectionUnavailable && room.managedBySelf -> + "This title isn't available to your profile. Choose something else so everyone can watch." + selectionUnavailable -> "This title isn't available to your profile. $host will need to pick something else." + !room.hostConnected && room.selfRole != MemberRole.Host -> + "$host lost connection. The party holds for two minutes while they come back." + reconnecting -> "Reconnecting to the party…" + else -> null + } +} + +/** + * Which avatar colour a member gets, matching Apple's `avatarGradient(for:)`: + * djb2 over "userId:profileId" with 64-bit wrapping, then `abs % size`, so a + * person has the same colour on every client. + */ +fun watchPartyAvatarIndex(member: RoomMember, paletteSize: Int): Int { + if (paletteSize <= 0) return 0 + val hash = "${member.userId}:${member.profileId}".fold(5381L) { acc, c -> acc * 33 + c.code } + return (kotlin.math.abs(hash) % paletteSize).toInt().coerceAtLeast(0) +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyPlaybackContext.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyPlaybackContext.kt new file mode 100644 index 0000000000..e71903e03c --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyPlaybackContext.kt @@ -0,0 +1,41 @@ +package org.prairieserver.prairie.watchtogether + +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.RoomPlaybackState +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot + +/** + * What a player must prepare for one room playback epoch: the room's exact + * content, file, and library, the room position, and whether it starts + * paused. [positionSeconds] is the snapshot anchor as sent; the server has + * already projected it, and the command sent after attach corrects the rest. + */ +data class WatchPartyPlaybackContext( + val roomId: String, + val selectionRevision: Long, + val contentId: String, + val fileId: Int, + val libraryId: Int?, + val positionSeconds: Double, + val paused: Boolean, +) + +/** + * The playback context for a playing room, or null when the room is not + * playing or has not resolved its file yet. + */ +fun RoomSnapshot.playbackContext(): WatchPartyPlaybackContext? { + if (phase != RoomPhase.Playing) return null + val content = selectedContentId?.takeIf { it.isNotBlank() } ?: return null + val file = selectedFileId?.takeIf { it > 0 } ?: return null + return WatchPartyPlaybackContext( + roomId = roomId, + selectionRevision = selectionRevision, + contentId = content, + fileId = file, + libraryId = selectedLibraryId, + positionSeconds = anchorPositionSeconds.takeIf { it.isFinite() && it >= 0.0 } ?: 0.0, + // Everyone starts paused; the room's commands decide when to play. + paused = isPaused || playbackState != RoomPlaybackState.Playing, + ) +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryGateway.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryGateway.kt deleted file mode 100644 index 2968ea95a0..0000000000 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryGateway.kt +++ /dev/null @@ -1,16 +0,0 @@ -package org.prairieserver.prairie.watchtogether - -import org.prairieserver.prairie.model.watchtogether.CreateRoomRequest -import org.prairieserver.prairie.model.watchtogether.JoinRoomRequest -import org.prairieserver.prairie.model.watchtogether.RoomResponse -import org.prairieserver.prairie.model.watchtogether.RoomSnapshot -import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest -import org.prairieserver.prairie.network.ApiResult -import kotlinx.coroutines.flow.StateFlow - -interface WatchTogetherEntryGateway { - val roomSnapshot: StateFlow<RoomSnapshot?> - suspend fun createRoom(request: CreateRoomRequest): ApiResult<RoomResponse> - suspend fun joinRoom(request: JoinRoomRequest): ApiResult<RoomResponse> - suspend fun setSelection(request: SetSelectionRequest): ApiResult<RoomResponse> -} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryPolicy.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryPolicy.kt deleted file mode 100644 index d1b3a791f3..0000000000 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryPolicy.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.prairieserver.prairie.watchtogether - -import org.prairieserver.prairie.model.watchtogether.MemberRole -import org.prairieserver.prairie.model.watchtogether.RoomPhase -import org.prairieserver.prairie.model.watchtogether.RoomSnapshot - -enum class WatchTogetherEntryTarget { - Lobby, - Player, -} - -fun watchTogetherEntryTarget(room: RoomSnapshot): WatchTogetherEntryTarget = - if ( - !room.selectedContentId.isNullOrBlank() && - !(room.selfRole == MemberRole.Host && room.memberCount <= 1) - ) { - WatchTogetherEntryTarget.Player - } else { - WatchTogetherEntryTarget.Lobby - } - -fun resumableWatchTogetherRoom(room: RoomSnapshot?): RoomSnapshot? = - room?.takeIf { it.roomId.isNotBlank() && it.phase != RoomPhase.Ended } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/cast/PrairieCastMessageTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/cast/PrairieCastMessageTest.kt index e44d39633d..72c39c7b2e 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/cast/PrairieCastMessageTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/cast/PrairieCastMessageTest.kt @@ -1,8 +1,10 @@ package org.prairieserver.prairie.cast +import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertIs import kotlin.test.assertNull @@ -132,7 +134,6 @@ class PrairieCastMessageTest { assertEquals(12.5, PrairieCastControlCommand.seek(12.5).seconds) assertEquals(1.25, PrairieCastControlCommand.setPlaybackSpeed(1.25).speed) assertEquals("fit", PrairieCastControlCommand.setVideoGravity("fit").value) - assertEquals(true, PrairieCastControlCommand.setHdrEnabled(true).enabled) assertEquals("bottom", PrairieCastControlCommand.setSubtitlePosition("bottom").value) assertEquals(0.4, PrairieCastControlCommand.setVolume(0.4).volume) assertEquals(true, PrairieCastControlCommand.setMuted(true).enabled) @@ -330,4 +331,21 @@ class PrairieCastMessageTest { PrairieCastMessage.HandoffCancel(PrairieCastHandoffCancel("req-1", "denied", "No")), ) } + + @Test + fun unknownMessageTypeIsDroppedNotFatal() { + assertNull(PrairieCastMessage.decodeOrNull(json, """{"type":"queue_update","v":3,"queueUpdate":{"items":[]}}""")) + assertIs<PrairieCastMessage.Ping>(PrairieCastMessage.decodeOrNull(json, """{"type":"ping","v":2}""")) + // A known kind with a broken payload is still a protocol error. + assertFailsWith<SerializationException> { + PrairieCastMessage.decodeOrNull(json, """{"type":"state","v":2,"state":{"title":"x"}}""") + } + assertEquals( + setOf( + "hello", "handoff_offer", "handoff_challenge", "handoff_ready", "handoff_cancel", + "launch", "control", "state", "error", "ping", "pong", "close", + ), + PrairieCastMessage.knownTypes, + ) + } } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/catalog/LibraryMediaScopeTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/catalog/LibraryMediaScopeTest.kt new file mode 100644 index 0000000000..2390c178c8 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/catalog/LibraryMediaScopeTest.kt @@ -0,0 +1,98 @@ +package org.prairieserver.prairie.catalog + +import kotlinx.coroutines.test.runTest +import org.prairieserver.prairie.model.catalog.BrowseItem +import org.prairieserver.prairie.model.catalog.CatalogResponse +import org.prairieserver.prairie.model.section.ResolvedSection +import org.prairieserver.prairie.model.section.SectionItem +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.apiv2.CatalogContinuationV2 +import kotlin.test.* + +class LibraryMediaScopeTest { + private val movies = (1..20).map { SectionItem("film-$it", "movie", "Film $it") } + + /** Recently Added/Released report `total_count = len(items)` after `LIMIT item_limit`; a full slice proves nothing. */ + @Test fun fullInlineSliceRefillsEvenWhenTotalCountEqualsItsSize() = runTest { + val row = ResolvedSection("recent", "recently_added", "Recent", itemLimit = 20, totalCount = 20, items = movies) + var loads = 0 + val result = scopeLibrarySection(row, "series") { + loads++ + ApiResult.Success(CatalogResponse(items = movies.map { BrowseItem(it.contentId, it.type, it.title) } + + BrowseItem("show", "series", "Show"))) + } + assertEquals(1, loads) + assertEquals(listOf("show"), result.section.items.map { it.contentId }) + assertFalse(result.incomplete) + } + + /** A Random shelf pages a fresh sample, which need not include the series already shown inline. */ + @Test fun completedRefillKeepsMatchingInlineItems() = runTest { + val row = ResolvedSection("random", "random", "Random", itemLimit = 3, totalCount = 3, + items = listOf(SectionItem("film", "movie", "Film"), SectionItem("film-2", "movie", "Film 2"), SectionItem("inline-show", "series", "Inline"))) + val result = scopeLibrarySection(row, "series") { + ApiResult.Success(CatalogResponse(items = listOf(BrowseItem("other-show", "series", "Other"), BrowseItem("film-3", "movie", "Film 3")))) + } + assertEquals(listOf("inline-show", "other-show"), result.section.items.map { it.contentId }) + assertFalse(result.incomplete) + } + + /** A fresh sample that alone fills the shelf must not evict the series already shown. */ + @Test fun refillThatFillsTheShelfKeepsMatchingInlineItems() = runTest { + val row = ResolvedSection("random", "random", "Random", itemLimit = 2, totalCount = 2, + items = listOf(SectionItem("film", "movie", "Film"), SectionItem("inline-show", "series", "Inline"))) + val result = scopeLibrarySection(row, "series") { + ApiResult.Success(CatalogResponse(items = listOf(BrowseItem("show-a", "series", "A"), BrowseItem("show-b", "series", "B")), hasMore = true)) + } + assertEquals(listOf("inline-show", "show-a"), result.section.items.map { it.contentId }) + assertFalse(result.incomplete) + } + + /** Inline items count toward the shelf, so a refill page that completes it ends the paging. */ + @Test fun refillStopsOnceInlineAndRefilledItemsFillTheShelf() = runTest { + val row = ResolvedSection("random", "random", "Random", itemLimit = 3, totalCount = 3, + items = listOf(SectionItem("film", "movie", "Film"), SectionItem("show-1", "series", "One"), SectionItem("show-2", "series", "Two"))) + var loads = 0 + val result = scopeLibrarySection(row, "series") { + loads++ + ApiResult.Success(CatalogResponse(items = listOf(BrowseItem("show-$loads-new", "series", "New"), BrowseItem("film-$loads", "movie", "Film")), + hasMore = true, continuation = CatalogContinuationV2("random", "page-$loads", null, emptySet()))) + } + assertEquals(1, loads) + assertEquals(listOf("show-1", "show-2", "show-1-new"), result.section.items.map { it.contentId }) + assertFalse(result.incomplete) + } + + @Test fun underfilledInlineSliceIsExhaustedWithoutRefill() = runTest { + val row = ResolvedSection("recent", "recently_added", "Recent", itemLimit = 20, totalCount = 2, + items = listOf(SectionItem("film", "movie", "Film"), SectionItem("show", "series", "Show"))) + val result = scopeLibrarySection(row, "series") { error("An under-filled slice is the whole shelf") } + assertEquals(listOf("show"), result.section.items.map { it.contentId }) + assertFalse(result.incomplete) + } + + /** The catalog section source reads the stored admin definition, so a profile override would be dropped. */ + @Test fun profileCustomizedShelfNeverRefillsFromTheStoredDefinition() = runTest { + val row = ResolvedSection("random", "random", "Random", itemLimit = 20, totalCount = 21, customized = true, items = movies) + val result = scopeLibrarySection(row, "series") { + ApiResult.Success(CatalogResponse(items = listOf(BrowseItem("show", "series", "Show")))) + } + assertEquals(emptyList(), result.section.items) + assertTrue(result.incomplete, "The scoped slice is unverified, so say so rather than hide it silently") + } + + @Test fun profileAddedShelfNeverRefillsFromTheCatalogSource() = runTest { + val episode = SectionItem("episode", "episode", "Episode") + val row = ResolvedSection("mine", "random", "Mine", itemLimit = 20, totalCount = 21, isCustom = true, items = movies.take(19) + episode) + val result = scopeLibrarySection(row, "series") { error("Profile-added sections have no stored catalog definition") } + assertEquals(listOf(episode), result.section.items) + assertTrue(result.incomplete) + } + + @Test fun customizedShelfAlreadyFilledByItsScopeIsComplete() = runTest { + val row = ResolvedSection("random", "random", "Random", itemLimit = 20, totalCount = 21, customized = true, items = movies) + val result = scopeLibrarySection(row, "movie") { error("A filled scoped shelf needs no refill") } + assertEquals(movies, result.section.items) + assertFalse(result.incomplete) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/domain/settings/ProfileSettingsControllerTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/domain/settings/ProfileSettingsControllerTest.kt index ae70bd0789..2b2518524e 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/domain/settings/ProfileSettingsControllerTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/domain/settings/ProfileSettingsControllerTest.kt @@ -8,9 +8,12 @@ import org.prairieserver.prairie.model.settings.SettingKeys import org.prairieserver.prairie.model.settings.SettingsContractCapabilities import org.prairieserver.prairie.model.settings.StoredSettingValue import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.repository.port.CatalogCacheScope import org.prairieserver.prairie.network.api.SettingsApi import org.prairieserver.prairie.repository.SettingsRepository import io.ktor.client.HttpClient +import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull @@ -37,9 +40,13 @@ class ProfileSettingsControllerTest { ApiResult.Success(StoredSettingValue(key = it, scope = "profile")) }, val deleteResult: ApiResult<Unit> = ApiResult.Success(Unit), + val effectiveHook: suspend () -> Unit = {}, + val deleteHook: suspend () -> Unit = {}, ) : SettingsApi(org.prairieserver.prairie.network.apiv2.SettingsV2Api(HttpClient(), org.prairieserver.prairie.network.TokenManagerImpl(), org.prairieserver.prairie.network.apiv2.ApiV2Gate.Unrestricted)) { val calls = mutableListOf<Call>() + val writeAuthorities = mutableListOf<AuthScopeSnapshot?>() + val readAuthorities = mutableListOf<AuthScopeSnapshot?>() override suspend fun getContractCapabilities(): ApiResult<SettingsContractCapabilities> = capabilities @@ -48,7 +55,11 @@ class ProfileSettingsControllerTest { libraryIds: List<Int>, seriesIds: List<String>, authority: org.prairieserver.prairie.network.AuthScopeSnapshot?, - ): ApiResult<EffectiveSettingValuesResponse> = effective + ): ApiResult<EffectiveSettingValuesResponse> { + readAuthorities += authority + effectiveHook() + return effective + } override suspend fun putValue( key: String, @@ -57,6 +68,7 @@ class ProfileSettingsControllerTest { profileId: String?, authority: org.prairieserver.prairie.network.AuthScopeSnapshot?, ): ApiResult<StoredSettingValue> { + writeAuthorities += authority calls += Call.Put(key, scope.scope, value) return putResult(key) } @@ -67,7 +79,9 @@ class ProfileSettingsControllerTest { profileId: String?, authority: org.prairieserver.prairie.network.AuthScopeSnapshot?, ): ApiResult<Unit> { + writeAuthorities += authority calls += Call.Delete(key, scope.scope) + deleteHook() return deleteResult } } @@ -127,6 +141,94 @@ class ProfileSettingsControllerTest { ) } + @Test + fun `only a metadata language write that lands bumps the catalog language revision`() = runTest { + val revision = CatalogLanguageRevision() + val controller = ProfileSettingsController(SettingsRepository(FakeSettingsApi()), revision) + + controller.setSubtitleLanguage("nl") + assertEquals(0L, revision.revision.value) + controller.setMetadataLanguage("nl") + assertEquals(1L, revision.revision.value) + + val failing = ProfileSettingsController( + SettingsRepository(FakeSettingsApi(putResult = { ApiResult.Error(500, "server_error", "Failed") })), + revision, + ) + failing.setMetadataLanguage("de") + assertEquals(1L, revision.revision.value) + } + + @Test + fun `committed language write invalidates cards even when settings reload is canceled`() = runTest { + val revision = CatalogLanguageRevision() + val api = FakeSettingsApi(effectiveHook = { throw kotlinx.coroutines.CancellationException("Settings closed") }) + var invalidated = false + val controller = ProfileSettingsController(SettingsRepository(api), revision) { invalidated = true } + kotlin.test.assertFailsWith<kotlinx.coroutines.CancellationException> { + controller.setMetadataLanguage("de") + } + assertEquals(1L, revision.revision.value) + assertEquals(1, api.calls.size) + assertTrue(invalidated) + } + + @Test + fun `cancellation after committed language write still finishes durable invalidation`() = runTest { + val entered = kotlinx.coroutines.CompletableDeferred<Unit>() + val release = kotlinx.coroutines.CompletableDeferred<Unit>() + var invalidated = false + val controller = ProfileSettingsController(SettingsRepository(FakeSettingsApi())) { + entered.complete(Unit) + release.await() + invalidated = true + } + val job = launch { + controller.setMetadataLanguage("de") + } + entered.await() + job.cancel() + release.complete(Unit) + job.join() + assertTrue(invalidated) + } + + @Test + fun `metadata write clear signal and reread retain the initiating owner after a switch`() = runTest { + val a = AuthScopeSnapshot("server", "a", "https://example.test", null) + val b = a.copy(profileId = "b") + for (tag in listOf("de", "")) { + var active = a + val language = CatalogLanguageRevision() + val api = FakeSettingsApi( + putResult = { + active = b + ApiResult.Success(StoredSettingValue(key = it, scope = "profile")) + }, + deleteHook = { active = b }, + ) + val controller = ProfileSettingsController( + SettingsRepository(api), language, captureAuthority = { active }, + ) + assertTrue(controller.setMetadataLanguage(tag).succeeded) + assertEquals(b, active) + assertEquals<List<AuthScopeSnapshot?>>(listOf(a), api.writeAuthorities) + assertEquals<List<AuthScopeSnapshot?>>(listOf(a), api.readAuthorities) + assertEquals<Map<CatalogCacheScope?, Long>>(mapOf(CatalogCacheScope("server", "a") to 1L), language.changes.scopes) + } + } + + @Test + fun `metadata change without a captured profile sends no request or signal`() = runTest { + val api = FakeSettingsApi() + val language = CatalogLanguageRevision() + val controller = ProfileSettingsController(SettingsRepository(api), language, captureAuthority = { null }) + assertFalse(controller.setMetadataLanguage("de").succeeded) + assertTrue(api.calls.isEmpty()) + assertTrue(api.readAuthorities.isEmpty()) + assertEquals(0L, language.revision.value) + } + @Test fun `clearing an absent value succeeds`() = runTest { // 404 means nothing was stored there, which is the state the caller diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/domain/settings/TitleArtControllerTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/domain/settings/TitleArtControllerTest.kt new file mode 100644 index 0000000000..0878eb4842 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/domain/settings/TitleArtControllerTest.kt @@ -0,0 +1,259 @@ +package org.prairieserver.prairie.domain.settings + +import org.prairieserver.prairie.model.settings.EffectiveSettingValue +import org.prairieserver.prairie.model.settings.EffectiveSettingValuesResponse +import org.prairieserver.prairie.model.settings.SettingKeys +import org.prairieserver.prairie.model.settings.SettingScope +import org.prairieserver.prairie.model.settings.SettingScopeIdentity +import org.prairieserver.prairie.model.settings.SettingsContractCapabilities +import org.prairieserver.prairie.model.settings.StoredSettingValue +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.api.SettingsApi +import org.prairieserver.prairie.repository.SettingsRepository +import io.ktor.client.HttpClient +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class TitleArtControllerTest { + + private sealed interface Call { + data class Put(val scope: SettingScope, val value: Boolean) : Call + data class Delete(val scope: SettingScope) : Call + } + + /** + * A settings server that stores `ui.title_art` at profile and + * profile_device scope and resolves it in the contract's order + * (profile → profile_device → default). + */ + private class FakeTitleArtServer( + var capabilities: ApiResult<SettingsContractCapabilities> = ApiResult.Success(SUPPORTED), + var profileValue: Boolean? = null, + var deviceValue: Boolean? = null, + ) : SettingsApi( + org.prairieserver.prairie.network.apiv2.SettingsV2Api( + HttpClient(), + org.prairieserver.prairie.network.TokenManagerImpl(), + org.prairieserver.prairie.network.apiv2.ApiV2Gate.Unrestricted, + ), + ) { + val calls = mutableListOf<Call>() + var failPutAt: SettingScope? = null + var deleteResult: ApiResult<Unit>? = null + var effectiveFails = false + + override suspend fun getContractCapabilities(): ApiResult<SettingsContractCapabilities> = capabilities + + override suspend fun getEffectiveValues( + keys: List<String>, + libraryIds: List<Int>, + seriesIds: List<String>, + authority: org.prairieserver.prairie.network.AuthScopeSnapshot?, + ): ApiResult<EffectiveSettingValuesResponse> { + if (effectiveFails) return ApiResult.Error(500, "boom", "Boom") + val (value, source) = when { + profileValue != null -> profileValue!! to "profile" + deviceValue != null -> deviceValue!! to "profile_device" + else -> true to EffectiveSettingValue.SOURCE_DEFAULT + } + return ApiResult.Success( + EffectiveSettingValuesResponse( + settings = listOf( + EffectiveSettingValue( + key = SettingKeys.UI_TITLE_ART, + value = JsonPrimitive(value), + source = source, + ), + ), + ), + ) + } + + override suspend fun putValue( + key: String, + scope: SettingScopeIdentity, + value: JsonElement, + profileId: String?, + authority: org.prairieserver.prairie.network.AuthScopeSnapshot?, + ): ApiResult<StoredSettingValue> { + val bool = (value as JsonPrimitive).content.toBooleanStrict() + calls += Call.Put(scope.scope, bool) + if (failPutAt == scope.scope) return ApiResult.Error(500, "boom", "Server error") + when (scope.scope) { + SettingScope.PROFILE -> profileValue = bool + SettingScope.PROFILE_DEVICE -> deviceValue = bool + else -> return ApiResult.Error(400, "invalid_scope", "Scope not allowed") + } + return ApiResult.Success(StoredSettingValue(key = key, scope = scope.scope.wire)) + } + + override suspend fun deleteValue( + key: String, + scope: SettingScopeIdentity, + profileId: String?, + authority: org.prairieserver.prairie.network.AuthScopeSnapshot?, + ): ApiResult<Unit> { + calls += Call.Delete(scope.scope) + deleteResult?.let { return it } + val existed = when (scope.scope) { + SettingScope.PROFILE -> (profileValue != null).also { profileValue = null } + SettingScope.PROFILE_DEVICE -> (deviceValue != null).also { deviceValue = null } + else -> false + } + return if (existed) ApiResult.Success(Unit) else ApiResult.Error(404, "not_found", "Not Found") + } + } + + private fun controller(server: FakeTitleArtServer) = TitleArtController(SettingsRepository(server)) + + @Test + fun loadReadsTheServerResolvedValueAndItsSource() = runTest { + assertEquals( + TitleArtController.LoadResult.Supported(TitleArtPreference(showTitleArt = true, appliesToAllDevices = false)), + controller(FakeTitleArtServer()).load(), + ) + assertEquals( + TitleArtController.LoadResult.Supported(TitleArtPreference(showTitleArt = false, appliesToAllDevices = false)), + controller(FakeTitleArtServer(deviceValue = false)).load(), + ) + // Profile wins over this device's own value, and marks "all devices". + assertEquals( + TitleArtController.LoadResult.Supported(TitleArtPreference(showTitleArt = true, appliesToAllDevices = true)), + controller(FakeTitleArtServer(profileValue = true, deviceValue = false)).load(), + ) + } + + @Test + fun serversBeforeRevision16AreUnsupportedAndNotRead() = runTest { + val older = FakeTitleArtServer(capabilities = ApiResult.Success(SUPPORTED.copy(manifestRevision = 15))) + assertEquals(TitleArtController.LoadResult.Unsupported, controller(older).load()) + + val noContract = FakeTitleArtServer(capabilities = ApiResult.Error(404, "not_found", "Not Found")) + assertEquals(TitleArtController.LoadResult.Unsupported, controller(noContract).load()) + + val down = FakeTitleArtServer(capabilities = ApiResult.Error(503, "unavailable", "Down")) + assertIs<TitleArtController.LoadResult.Failed>(controller(down).load()) + + val offline = FakeTitleArtServer(capabilities = ApiResult.NetworkError(RuntimeException("offline"))) + assertIs<TitleArtController.LoadResult.Failed>(controller(offline).load()) + } + + @Test + fun mainSwitchWritesThisDeviceUnlessTheValueAppliesToAllDevices() = runTest { + val server = FakeTitleArtServer() + val result = controller(server).setShowTitleArt(show = false, appliesToAllDevices = false) + + assertEquals(listOf<Call>(Call.Put(SettingScope.PROFILE_DEVICE, false)), server.calls) + assertEquals( + TitleArtController.WriteResult.Saved(TitleArtPreference(showTitleArt = false, appliesToAllDevices = false)), + result, + ) + } + + @Test + fun mainSwitchWritesTheProfileWhileTheValueAppliesToAllDevices() = runTest { + val server = FakeTitleArtServer(profileValue = true) + val result = controller(server).setShowTitleArt(show = false, appliesToAllDevices = true) + + assertEquals(listOf<Call>(Call.Put(SettingScope.PROFILE, false)), server.calls) + assertEquals( + TitleArtController.WriteResult.Saved(TitleArtPreference(showTitleArt = false, appliesToAllDevices = true)), + result, + ) + } + + @Test + fun applyToAllDevicesOnWritesTheCurrentValueAtProfileScope() = runTest { + val server = FakeTitleArtServer(deviceValue = false) + val result = controller(server).setAppliesToAllDevices(enabled = true, showTitleArt = false) + + assertEquals(listOf<Call>(Call.Put(SettingScope.PROFILE, false)), server.calls) + assertEquals( + TitleArtController.WriteResult.Saved(TitleArtPreference(showTitleArt = false, appliesToAllDevices = true)), + result, + ) + } + + @Test + fun applyToAllDevicesOffPinsThisDeviceBeforeClearingTheProfile() = runTest { + val server = FakeTitleArtServer(profileValue = false, deviceValue = true) + val result = controller(server).setAppliesToAllDevices(enabled = false, showTitleArt = false) + + assertEquals( + listOf(Call.Put(SettingScope.PROFILE_DEVICE, false), Call.Delete(SettingScope.PROFILE)), + server.calls, + ) + // This device keeps the look it had; the profile value is gone. + assertNull(server.profileValue) + assertEquals(false, server.deviceValue) + assertEquals( + TitleArtController.WriteResult.Saved(TitleArtPreference(showTitleArt = false, appliesToAllDevices = false)), + result, + ) + } + + @Test + fun aMissingProfileValueOnDeleteCountsAsCleared() = runTest { + val server = FakeTitleArtServer() + server.deleteResult = ApiResult.Error(404, "not_found", "Not Found") + val result = controller(server).setAppliesToAllDevices(enabled = false, showTitleArt = true) + + assertEquals( + listOf(Call.Put(SettingScope.PROFILE_DEVICE, true), Call.Delete(SettingScope.PROFILE)), + server.calls, + ) + assertIs<TitleArtController.WriteResult.Saved>(result) + } + + @Test + fun aFailedDevicePinNeverClearsTheProfileValue() = runTest { + val server = FakeTitleArtServer(profileValue = false) + server.failPutAt = SettingScope.PROFILE_DEVICE + val result = controller(server).setAppliesToAllDevices(enabled = false, showTitleArt = false) + + assertEquals(listOf<Call>(Call.Put(SettingScope.PROFILE_DEVICE, false)), server.calls) + assertEquals(false, server.profileValue) + assertIs<TitleArtController.WriteResult.Failed>(result) + } + + @Test + fun aFailedProfileDeleteOtherThan404IsReported() = runTest { + val server = FakeTitleArtServer(profileValue = false) + server.deleteResult = ApiResult.Error(500, "boom", "Server error") + val result = controller(server).setAppliesToAllDevices(enabled = false, showTitleArt = false) + + assertEquals(TitleArtController.WriteResult.Failed("Server error"), result) + } + + @Test + fun aWriteThatLandedStillSucceedsWhenTheReReadFails() = runTest { + val server = FakeTitleArtServer() + server.effectiveFails = true + val result = controller(server).setShowTitleArt(show = false, appliesToAllDevices = false) + + assertEquals(TitleArtController.WriteResult.Saved(resolved = null), result) + assertTrue(server.calls.isNotEmpty()) + } + + @Test + fun aNonBooleanValueResolvesToTheDefault() { + val preference = TitleArtController.preferenceOf( + EffectiveSettingValue(key = SettingKeys.UI_TITLE_ART, value = JsonPrimitive("nope"), source = "profile"), + ) + assertEquals(TitleArtPreference(showTitleArt = true, appliesToAllDevices = true), preference) + } + + private companion object { + val SUPPORTED = SettingsContractCapabilities( + apiVersion = 1, + manifestRevision = 16, + supportsBatchedEffective = true, + ) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/metadata/DescriptionTranslationControllerTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/metadata/DescriptionTranslationControllerTest.kt index 67a7906760..5c77c3ce20 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/metadata/DescriptionTranslationControllerTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/metadata/DescriptionTranslationControllerTest.kt @@ -77,8 +77,117 @@ class DescriptionTranslationControllerTest { ) assertEquals(DescriptionTranslationPhase.Failed, controller.phase.value) - // Apple's bounded backoff schedule has 8 slots. - assertEquals(8, polls) + // Bounded backoff: 11 slots, 45 seconds in all (the web client's budget). + assertEquals(11, polls) + } + + @Test + fun pollBudgetSpansFortyFiveSeconds() = runTest { + val api = FakeApi(translateResult = ApiResult.Success(org.prairieserver.prairie.model.metadata.MetadataTranslationJob("1", "season", "season-1", "nl", "pending"))) + var waitedMs = 0L + val controller = DescriptionTranslationController( + repository = MetadataAiRepository(api), + delayMs = { waitedMs += it }, + ) + + controller.translate( + contentId = "season-1", + targetLanguage = "nl", + refetchPendingLanguage = { "nl" }, + onTranslated = { }, + ) + + assertEquals(45_000L, waitedMs) + } + + @Test + fun onPollRunsBeforeEveryRefetchSoDependentListsRefresh() = runTest { + val api = FakeApi(translateResult = ApiResult.Success(org.prairieserver.prairie.model.metadata.MetadataTranslationJob("1", "season", "season-1", "nl", "pending"))) + val events = mutableListOf<String>() + var polls = 0 + val controller = DescriptionTranslationController( + repository = MetadataAiRepository(api), + delayMs = { }, + ) + + controller.translate( + contentId = "season-1", + targetLanguage = "nl", + refetchPendingLanguage = { + polls += 1 + events += "refetch" + if (polls < 2) "nl" else null + }, + onTranslated = { events += "translated" }, + onPoll = { events += "poll" }, + ) + + assertEquals(listOf("poll", "refetch", "poll", "refetch", "translated"), events) + } + + @Test + fun runningContentIdNamesTheItemBeingTranslated() = runTest { + val api = FakeApi(translateResult = ApiResult.Success(org.prairieserver.prairie.model.metadata.MetadataTranslationJob("1", "item", "movie-1", "nl", "pending"))) + val controller = DescriptionTranslationController( + repository = MetadataAiRepository(api), + delayMs = { }, + ) + var seenWhilePolling: String? = null + var phaseWhilePolling: DescriptionTranslationPhase? = null + + assertEquals(null, controller.runningContentId.value) + controller.translate( + contentId = "movie-1", + targetLanguage = "nl", + refetchPendingLanguage = { + seenWhilePolling = controller.runningContentId.value + phaseWhilePolling = controller.phase.value + null + }, + onTranslated = { }, + ) + + assertEquals("movie-1", seenWhilePolling) + assertEquals(DescriptionTranslationPhase.Translating, phaseWhilePolling) + assertEquals(DescriptionTranslationPhase.Idle, controller.phase.value) + } + + @Test + fun buttonTriggerIsSingleFlightWhileATranslationRuns() = runTest { + var translateCalls = 0 + val api = object : MetadataAiApi { + override suspend fun status(): ApiResult<MetadataAiStatus> = + ApiResult.NetworkError(IllegalStateException("not used")) + + override suspend fun translateDescription( + contentId: String, + targetLanguage: String, + scope: org.prairieserver.prairie.network.AuthScopeSnapshot?, + ): ApiResult<org.prairieserver.prairie.model.metadata.MetadataTranslationJob> { + translateCalls += 1 + return ApiResult.Success(org.prairieserver.prairie.model.metadata.MetadataTranslationJob("1", "item", contentId, targetLanguage, "pending")) + } + } + val controller = DescriptionTranslationController( + repository = MetadataAiRepository(api), + delayMs = { }, + ) + + controller.translate( + contentId = "movie-1", + targetLanguage = "nl", + refetchPendingLanguage = { + // A second press while the first job polls must not queue another. + controller.translate("movie-1", "nl", refetchPendingLanguage = { null }, onTranslated = { }) + null + }, + onTranslated = { }, + ) + + assertEquals(1, translateCalls) + // Once idle again, the button can fire a fresh request. + controller.translate("movie-1", "nl", refetchPendingLanguage = { null }, onTranslated = { }) + assertEquals(2, translateCalls) } @Test @@ -100,6 +209,48 @@ class DescriptionTranslationControllerTest { assertEquals(DescriptionTranslationPhase.Failed, controller.phase.value) } + @Test + fun aSeasonSwitchedToWhileAnotherTranslatesIsDeferredNotLatched() = runTest { + val started = mutableListOf<String>() + val api = object : MetadataAiApi { + override suspend fun status(): ApiResult<MetadataAiStatus> = + ApiResult.NetworkError(IllegalStateException("not used")) + + override suspend fun translateDescription( + contentId: String, + targetLanguage: String, + scope: org.prairieserver.prairie.network.AuthScopeSnapshot?, + ): ApiResult<org.prairieserver.prairie.model.metadata.MetadataTranslationJob> { + started += contentId + return ApiResult.Success(org.prairieserver.prairie.model.metadata.MetadataTranslationJob("1", "season", contentId, targetLanguage, "pending")) + } + } + val controller = DescriptionTranslationController(MetadataAiRepository(api), delayMs = { }) + var claimedWhileBusy: Boolean? = null + + // Season 1's job runs; the viewer switches to season 2 mid-poll. + assertTrue(controller.claimAutoFire("season-1", "de")) + controller.translate( + contentId = "season-1", + targetLanguage = "de", + refetchPendingLanguage = { + claimedWhileBusy = controller.claimAutoFire("season-2", "de") + null + }, + onTranslated = { }, + ) + + assertEquals(false, claimedWhileBusy) + // The refused claim is reported once, and season 2 was not latched. + assertTrue(controller.takeDeferredAuto()) + assertEquals(false, controller.takeDeferredAuto()) + assertTrue(controller.claimAutoFire("season-2", "de")) + controller.translate("season-2", "de", refetchPendingLanguage = { null }, onTranslated = { }) + assertEquals(listOf("season-1", "season-2"), started) + // Season 1 stays latched for the session. + assertEquals(false, controller.claimAutoFire("season-1", "de")) + } + @Test fun autoFireLatchesPerContentAndLanguage() { val controller = DescriptionTranslationController( diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/auth/DeviceCodeFormatTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/auth/DeviceCodeFormatTest.kt new file mode 100644 index 0000000000..35b69270bb --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/auth/DeviceCodeFormatTest.kt @@ -0,0 +1,39 @@ +package org.prairieserver.prairie.model.auth + +import kotlin.test.Test +import kotlin.test.assertEquals + +class DeviceCodeFormatTest { + @Test + fun groupsEightCharacterCodesFourPlusFour() { + assertEquals("4821 7730", DeviceCodeFormat.display("4821-7730")) + assertEquals("4821 7730", DeviceCodeFormat.display("48217730")) + // Older servers issue letters; they are shown the same way. + assertEquals("ABCD EFGH", DeviceCodeFormat.display("abcd-efgh")) + // Anything else is shown as sent. + assertEquals("WXYZ 12", DeviceCodeFormat.display("WXYZ-12")) + } + + @Test + fun normalizesTypedInputAndSpellsItOut() { + assertEquals("48217730", DeviceCodeFormat.normalize(" 4821 77-30 ")) + assertEquals("4 8 2 1 7 7 3 0", DeviceCodeFormat.spoken("4821-7730")) + } + + @Test + fun activateTextDropsHttpsButKeepsHttpAndPaths() { + assertEquals( + "prairie.example.test/activate", + DeviceCodeFormat.activateText("https://prairie.example.test/activate", ""), + ) + assertEquals( + "http://192.168.1.5:8090/activate", + DeviceCodeFormat.activateText("http://192.168.1.5:8090/activate", ""), + ) + assertEquals( + "media.example.com/prairie/activate", + DeviceCodeFormat.activateText("", "https://media.example.com/prairie/activate?code=48217730"), + ) + assertEquals("prairie.example.test", DeviceCodeFormat.host("https://prairie.example.test/activate")) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/catalog/EpisodeTranslationMergeTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/catalog/EpisodeTranslationMergeTest.kt new file mode 100644 index 0000000000..7200ce9b61 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/catalog/EpisodeTranslationMergeTest.kt @@ -0,0 +1,50 @@ +package org.prairieserver.prairie.model.catalog + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class EpisodeTranslationMergeTest { + @Test fun translationResponsePreservesWatchedProgressAndFiles() { + val current = EpisodeListItem("episode", 1, 1, overview = "Original", + userData = LeafItemUserData(played = true, positionSeconds = 42.0), + files = listOf(EpisodeFile(17))) + val stale = current.copy(overview = "Localized", userData = LeafItemUserData(played = false), + files = emptyList(), machineTranslatedFields = listOf("overview")) + val merged = current.withTranslatedDescription(stale) + assertEquals(current.userData, merged.userData) + assertEquals(current.files, merged.files) + assertEquals("Localized", merged.overview) + assertEquals(listOf("overview"), merged.machineTranslatedFields) + } + + @Test fun sameTextStillUpdatesProvenanceAndClearsPendingLanguage() { + val detail = ItemDetail("episode", "episode", title = "Episode", overview = "Same", + pendingTranslationLanguage = "de", userData = LeafItemUserData(played = true)) + val row = EpisodeListItem("episode", 1, 1, overview = "Same", + machineTranslatedFields = listOf("overview")) + val merged = detail.withEpisodeDescription(row) + assertEquals(listOf("overview"), merged.machineTranslatedFields) + assertNull(merged.pendingTranslationLanguage) + assertEquals(detail.userData, merged.userData) + } + + @Test fun lateDetailUsesCurrentTranslationWithoutReplacingPlaybackMetadata() { + val late = ItemDetail("episode", "episode", title = "Episode", overview = "Original", + pendingTranslationLanguage = "de", userData = LeafItemUserData(positionSeconds = 23.0)) + val row = EpisodeListItem("episode", 1, 1, overview = "Localized", + machineTranslatedFields = listOf("overview")) + val merged = late.withEpisodeDescription(row) + assertEquals("Localized", merged.overview) + assertEquals(late.userData, merged.userData) + assertNull(merged.pendingTranslationLanguage) + assertEquals(late, late.withEpisodeDescription(row.copy(contentId = "other"))) + } + @Test fun pendingRowCannotReplaceCompletedDetail() { + val detail = ItemDetail("episode", "episode", title = "Episode", overview = "Localized", + machineTranslatedFields = listOf("overview")) + val stale = EpisodeListItem("episode", 1, 1, overview = "Original", pendingTranslationLanguage = "de") + assertEquals(detail, detail.withEpisodeDescription(stale)) + } + +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/catalog/ExternalRatingsTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/catalog/ExternalRatingsTest.kt new file mode 100644 index 0000000000..fca878de53 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/catalog/ExternalRatingsTest.kt @@ -0,0 +1,129 @@ +package org.prairieserver.prairie.model.catalog + +import kotlinx.serialization.json.Json +import org.prairieserver.prairie.network.PrairieJson +import org.prairieserver.prairie.network.apiv2.ItemDetailReadV2 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ExternalRatingsTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun serverListIsShownVerbatimAndInOrder() { + val server = listOf( + DisplayRating("rt_critic", "RT", 93.0, "93%"), + DisplayRating("imdb", "IMDb", 85.0, "8.5"), + DisplayRating("letterboxd", "Letterboxd", 84.0, "4.2"), + ) + + assertEquals(server, ExternalRatings.forTitle(server, ratingImdb = 1.0, ratingTmdb = 2.0)) + } + + @Test + fun emptyServerListMeansNothingToShow() { + assertEquals(emptyList(), ExternalRatings.forTitle(emptyList(), ratingImdb = 8.5, ratingTmdb = 8.3)) + } + + @Test + fun olderServerFallsBackToImdbThenTmdbOnly() { + val detail = json.decodeFromString<ItemDetail>( + """ + {"content_id":"movie-1","type":"movie","title":"Movie", + "rating_imdb":8.45,"rating_tmdb":8.25, + "rating_rt_critic":93,"rating_rt_audience":95} + """.trimIndent(), + ) + + assertNull(detail.ratings) + assertEquals( + listOf( + DisplayRating("imdb", "IMDb", 84.5, "8.5"), + DisplayRating("tmdb", "TMDB", 82.5, "8.3"), + ), + detail.titleRatings(), + ) + } + + @Test + fun fallbackSkipsMissingAndOutOfRangeScores() { + listOf(null, Double.NaN, Double.POSITIVE_INFINITY, 0.0, -1.0, 10.5).forEach { invalid -> + assertEquals( + listOf("TMDB"), + ExternalRatings.forTitle(null, ratingImdb = invalid, ratingTmdb = 7.0).map { it.name }, + ) + assertNull(ExternalRatings.primary(invalid, invalid)) + } + assertEquals("10.0", ExternalRatings.imdb(10.0)?.display) + } + + @Test + fun phoneShowsTheFirstThreeInServerOrder() { + val server = listOf( + DisplayRating("imdb", "IMDb", 85.0, "8.5"), + DisplayRating("tmdb", "TMDB", 82.5, "8.3"), + DisplayRating("rt_critic", "RT", 93.0, "93%"), + DisplayRating("rt_audience", "RT Audience", 95.0, "95%"), + DisplayRating("metacritic", "Metacritic", 87.0, "87"), + ) + + assertEquals(3, ExternalRatings.PHONE_LIMIT) + assertEquals(server.take(3), ExternalRatings.forPhone(server)) + assertEquals(server.take(2), ExternalRatings.forPhone(server.take(2))) + assertEquals(emptyList(), ExternalRatings.forPhone(emptyList())) + } + + @Test + fun primaryPrefersImdbAndFallsBackToTmdb() { + assertEquals(DisplayRating("imdb", "IMDb", 79.0, "7.9"), ExternalRatings.primary(7.9, 8.2)) + assertEquals(DisplayRating("tmdb", "TMDB", 82.0, "8.2"), ExternalRatings.primary(null, 8.2)) + assertEquals("TMDB", ExternalRatings.primary(0.0, 8.2)?.name) + } + + @Test + fun oneDecimalIsLocaleIndependent() { + assertEquals("8.5", ExternalRatings.formatOneDecimal(8.45)) + assertEquals("7.0", ExternalRatings.formatOneDecimal(7.0)) + assertEquals("7.0", ExternalRatings.formatOneDecimal(6.96)) + assertEquals("0.1", ExternalRatings.formatOneDecimal(0.05)) + } + + @Test + fun v2DetailCarriesTheServerRatingsList() { + val detail = json.decodeFromString<ItemDetailReadV2>( + """ + {"content_id":"movie-1","type":"movie","title":"Movie","rating_imdb":1.0, + "cast":[],"crew":[],"versions":[],"subtitles":[], + "ratings":[{"source":"tmdb","name":"TMDB","score":82.5,"display":"8.3"}]} + """.trimIndent(), + ).toDomain() + + assertEquals(listOf(DisplayRating("tmdb", "TMDB", 82.5, "8.3")), detail.titleRatings()) + } + + @Test + fun malformedRatingEntriesAreDroppedOneByOne() { + val detail = PrairieJson.decodeFromString<ItemDetailReadV2>( + """ + {"content_id":"movie-1","type":"movie","title":"Movie", + "cast":[],"crew":[],"versions":[],"subtitles":[], + "ratings":[ + {"source":"imdb","name":"IMDb","score":85.0,"display":"8.5"}, + {"source":"plugin","name":"Plugin","score":null}, + {"source":"blank","name":"","score":50,"display":"5"}, + "not an object", + {"source":"letterboxd","name":"Letterboxd","score":null,"display":"4.2"} + ]} + """.trimIndent(), + ).toDomain() + + assertEquals( + listOf( + DisplayRating("imdb", "IMDb", 85.0, "8.5"), + DisplayRating("letterboxd", "Letterboxd", null, "4.2"), + ), + detail.titleRatings(), + ) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/download/OfflineArtworkTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/download/OfflineArtworkTest.kt new file mode 100644 index 0000000000..83ac996c7d --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/download/OfflineArtworkTest.kt @@ -0,0 +1,79 @@ +package org.prairieserver.prairie.model.download + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class OfflineArtworkTest { + + @Test + fun decodesAnEpisodeManifestWithASeriesPoster() { + val artwork = assertNotNull( + decodeOfflineManifestArtwork( + """ + { + "download_id": "dl_1", + "audio_tracks": [{"index": 0, "language": "eng"}], + "poster_thumbhash": "EPISODE", + "backdrop_thumbhash": "BACKDROP", + "series_poster_thumbhash": "SERIES", + "artwork_urls": { + "poster": "/api/v2/downloads/dl_1/artwork/poster", + "backdrop": "/api/v2/downloads/dl_1/artwork/backdrop", + "logo": "/api/v2/downloads/dl_1/artwork/logo", + "series_poster": "/api/v2/downloads/dl_1/artwork/series_poster" + } + } + """.trimIndent(), + ), + ) + + assertEquals("EPISODE", artwork.posterThumbhash) + assertEquals("SERIES", artwork.seriesPosterThumbhash) + assertEquals("/api/v2/downloads/dl_1/artwork/poster", artwork.artworkUrls.poster) + assertEquals("/api/v2/downloads/dl_1/artwork/series_poster", artwork.artworkUrls.seriesPoster) + } + + @Test + fun serversWithoutSeriesPostersDecodeWithoutThem() { + val artwork = assertNotNull( + decodeOfflineManifestArtwork( + """{"poster_thumbhash": "MOVIE", "artwork_urls": {"poster": "/api/v2/downloads/dl_1/artwork/poster"}}""", + ), + ) + + assertEquals("MOVIE", artwork.posterThumbhash) + assertNull(artwork.seriesPosterThumbhash) + assertNull(artwork.artworkUrls.seriesPoster) + } + + @Test + fun aManifestWithNoArtworkDecodesEmpty() { + val artwork = assertNotNull(decodeOfflineManifestArtwork("""{"download_id": "dl_1", "artwork_urls": null}""")) + + assertNull(artwork.posterThumbhash) + assertNull(artwork.artworkUrls.poster) + assertNull(decodeOfflineManifestArtwork("not json")) + } + + @Test + fun onlySameServerArtworkProxyPathsForTheKindAreFetched() { + assertTrue(isOfflineArtworkFetchUrl("/api/v2/downloads/dl_1/artwork/poster", OFFLINE_ARTWORK_POSTER)) + assertTrue(isOfflineArtworkFetchUrl(" /api/v2/downloads/dl_1/artwork/series_poster ", OFFLINE_ARTWORK_SERIES_POSTER)) + + assertFalse(isOfflineArtworkFetchUrl("https://evil.example/api/v2/downloads/dl_1/artwork/poster", OFFLINE_ARTWORK_POSTER)) + assertFalse(isOfflineArtworkFetchUrl("//evil.example/api/v2/downloads/dl_1/artwork/poster", OFFLINE_ARTWORK_POSTER)) + assertFalse(isOfflineArtworkFetchUrl("/api/v2/downloads/dl_1/artwork/backdrop", OFFLINE_ARTWORK_POSTER)) + assertFalse(isOfflineArtworkFetchUrl("/api/v2/downloads/dl_1/artwork/poster", OFFLINE_ARTWORK_SERIES_POSTER)) + assertFalse(isOfflineArtworkFetchUrl("/api/v2/downloads/dl_1/subtitles/poster", OFFLINE_ARTWORK_POSTER)) + assertFalse(isOfflineArtworkFetchUrl("/api/v2/downloads/../users/artwork/poster", OFFLINE_ARTWORK_POSTER)) + assertFalse(isOfflineArtworkFetchUrl("/api/v2/downloads/a/b/artwork/poster", OFFLINE_ARTWORK_POSTER)) + assertFalse(isOfflineArtworkFetchUrl("/api/v2/downloads//artwork/poster", OFFLINE_ARTWORK_POSTER)) + assertFalse(isOfflineArtworkFetchUrl("/api/v2/downloads/dl_1/artwork/poster?x=1", OFFLINE_ARTWORK_POSTER)) + assertFalse(isOfflineArtworkFetchUrl("/api/v2/downloads/dl_1\\artwork\\poster", OFFLINE_ARTWORK_POSTER)) + assertFalse(isOfflineArtworkFetchUrl("", OFFLINE_ARTWORK_POSTER)) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/download/OfflineTracksTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/download/OfflineTracksTest.kt index 71f8add948..10235c07a6 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/download/OfflineTracksTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/download/OfflineTracksTest.kt @@ -143,4 +143,64 @@ class OfflineTracksTest { assertFalse(isOfflineSubtitleFetchUrl("/api/v2/downloads/dl_1/artwork/poster")) assertFalse(isOfflineSubtitleFetchUrl("/api/v1/downloads/dl_1/subtitles/external:0")) } + + @Test + fun findsSavedSidecarsWhoseStoredSubtitleWasRetimed() { + val manifest = requireNotNull( + decodeOfflineManifestTracks( + """ + {"subtitles": [ + {"language": "en", "format": "srt", "fetch_url": "/api/v2/downloads/dl_1/subtitles/downloaded:7", "revision": "3"}, + {"language": "fr", "format": "srt", "fetch_url": "/api/v2/downloads/dl_1/subtitles/downloaded:8", "revision": "1"}, + {"language": "de", "format": "srt", "fetch_url": "/api/v2/downloads/dl_1/subtitles/external:0"} + ]} + """.trimIndent(), + ), + ) + fun saved(path: String, url: String?, revision: String?) = + OfflineSubtitleFile(path = path, format = "srt", fetchUrl = url, revision = revision) + val retimed = saved("/d/0.srt", "/api/v2/downloads/dl_1/subtitles/downloaded:7", "2") + val current = saved("/d/1.srt", "/api/v2/downloads/dl_1/subtitles/downloaded:8", "1") + val external = saved("/d/2.srt", "/api/v2/downloads/dl_1/subtitles/external:0", null) + val legacy = saved("/d/3.srt", null, null) + val tracks = OfflineTrackInfo(subtitles = listOf(retimed, current, external, legacy)) + + assertEquals(listOf(retimed to "3"), tracks.subtitlesWithNewRevision(manifest)) + // A capture from a server without revisions gets them on its first refresh. + val unversioned = OfflineTrackInfo(subtitles = listOf(current.copy(revision = null))) + assertEquals(listOf(current.copy(revision = null) to "1"), unversioned.subtitlesWithNewRevision(manifest)) + } + + @Test + fun refreshesSavedExternalSidecarsButNotOnesWhosePositionNowNamesAnotherFile() { + val manifest = requireNotNull( + decodeOfflineManifestTracks( + """ + {"subtitles": [ + {"language": "en", "format": "srt", "fetch_url": "/api/v2/downloads/dl_1/subtitles/external:0", "revision": "x2"}, + {"language": "de", "format": "srt", "fetch_url": "/api/v2/downloads/dl_1/subtitles/external:1", "revision": "y2"}, + {"language": "es", "format": "ass", "fetch_url": "/api/v2/downloads/dl_1/subtitles/external:2", "revision": "z2"}, + {"language": "en", "format": "srt", "title": "Commentary", "fetch_url": "/api/v2/downloads/dl_1/subtitles/external:3", "revision": "t2"}, + {"language": "en", "format": "srt", "hearing_impaired": true, "fetch_url": "/api/v2/downloads/dl_1/subtitles/external:4", "revision": "h2"}, + {"language": "fr", "format": "srt", "fetch_url": "/api/v2/downloads/dl_1/subtitles/downloaded:9", "revision": "d2"} + ]} + """.trimIndent(), + ), + ) + val english = OfflineSubtitleFile( + path = "/d/0.srt", format = "srt", language = "en", + fetchUrl = "/api/v2/downloads/dl_1/subtitles/external:0", revision = "x1", + ) + // external:1 was French when saved; another file now sits at that position. + val shifted = english.copy(path = "/d/1.srt", language = "fr", fetchUrl = "/api/v2/downloads/dl_1/subtitles/external:1", revision = "y1") + val reformatted = english.copy(path = "/d/2.srt", language = "es", fetchUrl = "/api/v2/downloads/dl_1/subtitles/external:2", revision = "z1") + // Same language and format, but the row now lists another title or SDH flag. + val retitled = english.copy(path = "/d/3.srt", fetchUrl = "/api/v2/downloads/dl_1/subtitles/external:3", revision = "t1") + val nowSdh = english.copy(path = "/d/4.srt", fetchUrl = "/api/v2/downloads/dl_1/subtitles/external:4", revision = "h1") + // A stored subtitle's ref is stable, so its edited metadata does not block a refresh. + val stored = english.copy(path = "/d/5.srt", fetchUrl = "/api/v2/downloads/dl_1/subtitles/downloaded:9", revision = "d1") + val tracks = OfflineTrackInfo(subtitles = listOf(english, shifted, reformatted, retitled, nowSdh, stored)) + + assertEquals(listOf(english to "x2", stored to "d2"), tracks.subtitlesWithNewRevision(manifest)) + } } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/feature/ClientSurfacePolicyTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/feature/ClientSurfacePolicyTest.kt deleted file mode 100644 index 766b69e028..0000000000 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/feature/ClientSurfacePolicyTest.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.prairieserver.prairie.model.feature - -import kotlin.test.Test -import kotlin.test.assertFalse - -class ClientSurfacePolicyTest { - @Test - fun watchTogetherCodeStaysPresentButHiddenFromUserMenus() { - assertFalse(CLIENT_WATCH_TOGETHER_SURFACE_ENABLED) - } -} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/feature/RequestsFeatureStoreTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/feature/RequestsFeatureStoreTest.kt index 906830c92a..54c9d02619 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/feature/RequestsFeatureStoreTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/feature/RequestsFeatureStoreTest.kt @@ -3,26 +3,62 @@ package org.prairieserver.prairie.model.feature import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue +import org.prairieserver.prairie.model.request.AdminRequestAction +import org.prairieserver.prairie.model.request.AdminRequestCapabilities import org.prairieserver.prairie.model.request.CreateMediaRequest import org.prairieserver.prairie.model.request.MediaRequest import org.prairieserver.prairie.model.request.RequestMediaDetail import org.prairieserver.prairie.model.request.RequestMediaPage +import org.prairieserver.prairie.model.request.RequestMediaResult +import org.prairieserver.prairie.model.request.RequestMediaType import org.prairieserver.prairie.model.request.RequestsDiscoverResponse import org.prairieserver.prairie.model.request.RequestsFeatureStatus import org.prairieserver.prairie.model.request.RequestsListResponse import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.api.RequestsApi +import org.prairieserver.prairie.repository.RequestDetailCache import org.prairieserver.prairie.repository.RequestsRepository class RequestsFeatureStoreTest { + @Test + fun aBlockedProfileKeepsRequestsHidden() = runTest { + val store = RequestsFeatureStore(RequestsRepository(FakeRequestsApi(ApiResult.Success(available(allowed = false))))) + + store.refresh() + + assertFalse(store.isEnabled.value) + } + + @Test + fun moderationFollowsTheAdminCapabilityProbe() = runTest { + val admin = RequestsFeatureStore( + RequestsRepository( + FakeRequestsApi( + ApiResult.Success(available()), + capabilities = ApiResult.Success(AdminRequestCapabilities(available = true)), + ), + ), + ) + val member = RequestsFeatureStore(RequestsRepository(FakeRequestsApi(ApiResult.Success(available())))) + + admin.refresh() + member.refresh() + + assertTrue(admin.canModerate.value) + assertFalse(member.canModerate.value) + admin.reset() + assertFalse(admin.canModerate.value) + } + @Test fun startsHiddenAndFollowsSuccessfulStatusProbe() = runTest { val store = RequestsFeatureStore( RequestsRepository( FakeRequestsApi( - ApiResult.Success(RequestsFeatureStatus(requestsEnabled = true)), + ApiResult.Success(available()), ), ), ) @@ -37,7 +73,7 @@ class RequestsFeatureStoreTest { @Test fun transientFailureKeepsPreviousCapabilityValue() = runTest { val api = FakeRequestsApi( - ApiResult.Success(RequestsFeatureStatus(requestsEnabled = true)), + ApiResult.Success(available()), ApiResult.NetworkError(IllegalStateException("offline")), ) val store = RequestsFeatureStore(RequestsRepository(api)) @@ -50,23 +86,31 @@ class RequestsFeatureStoreTest { @Test fun resetHidesRequestsBeforeNextProbe() = runTest { - val store = RequestsFeatureStore( - RequestsRepository( - FakeRequestsApi( - ApiResult.Success(RequestsFeatureStatus(requestsEnabled = true)), - ), + val repository = RequestsRepository( + FakeRequestsApi( + ApiResult.Success(available()), ), ) + val store = RequestsFeatureStore(repository) + val seen = RequestMediaResult(mediaType = RequestMediaType.Movie, tmdbId = 7, title = "Seen") + repository.cache.seed(seen) store.refresh() store.reset() assertFalse(store.isEnabled.value) + // Sign-out and server or profile switches all reset here, so the + // previous session's request pages go with it. + assertNull(repository.cache.firstFrameDetail(RequestDetailCache.Key(RequestMediaType.Movie, 7))) } } +private fun available(allowed: Boolean = true) = + RequestsFeatureStatus(requestsEnabled = true, allowed = allowed, state = "available") + private class FakeRequestsApi( vararg statusResults: ApiResult<RequestsFeatureStatus>, + private val capabilities: ApiResult<AdminRequestCapabilities> = ApiResult.Error(403, "forbidden", ""), ) : RequestsApi { private val statusResults = statusResults.toMutableList() @@ -107,4 +151,16 @@ private class FakeRequestsApi( override suspend fun cancel(id: String): ApiResult<MediaRequest> = ApiResult.NetworkError(IllegalStateException("not used")) + + override suspend fun adminCapabilities(): ApiResult<AdminRequestCapabilities> = capabilities + + override suspend fun adminRequests( + status: String?, + outcome: String?, + mediaType: String?, + tmdbId: Int?, + ): ApiResult<RequestsListResponse> = ApiResult.NetworkError(IllegalStateException("not used")) + + override suspend fun adminAction(id: String, action: AdminRequestAction, reason: String?): ApiResult<MediaRequest> = + ApiResult.NetworkError(IllegalStateException("not used")) } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/feature/ShuffleFeatureStoreTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/feature/ShuffleFeatureStoreTest.kt new file mode 100644 index 0000000000..acaf9eaa74 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/feature/ShuffleFeatureStoreTest.kt @@ -0,0 +1,95 @@ +package org.prairieserver.prairie.model.feature + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.engine.mock.respondError +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.test.runTest +import kotlinx.io.IOException +import org.prairieserver.prairie.model.shuffle.ShuffleScopeKind +import org.prairieserver.prairie.network.* +import org.prairieserver.prairie.network.apiv2.ApiV2Gate +import org.prairieserver.prairie.network.apiv2.ShufflesV2Api +import org.prairieserver.prairie.network.apiv2.shuffleJson +import kotlin.test.* + +class ShuffleFeatureStoreTest { + private val owner = AuthScopeSnapshot("s", "p", "https://example.invalid", "pin", identityGeneration = 1) + private val tokens = object : TokenManager by TokenManagerImpl() { + override suspend fun snapshotCurrentScope() = owner + } + + private var answer: () -> Pair<HttpStatusCode, String> = { HttpStatusCode.OK to "{}" } + private var offline = false + private val client = HttpClient(MockEngine { + if (offline) throw IOException("offline") + val (status, body) = answer() + if (status.isSuccess()) { + respond(body, status, headersOf(HttpHeaders.ContentType, "application/json")) + } else { + respondError(status, """{"type":"https://prairieserver.org/docs/api/v2/problems/x","title":"T","status":${status.value}}""", + headersOf(HttpHeaders.ContentType, "application/problem+json")) + } + }) { install(ContentNegotiation) { json(PrairieJson) } } + private val store = ShuffleFeatureStore(ShufflesV2Api(client, tokens, ApiV2Gate.Unrestricted)) + + @AfterTest fun close() = client.close() + + @Test fun capabilityStartsHiddenSurvivesTransientFailuresAndResets() = runTest { + assertFalse(store.capability.value.supports(ShuffleScopeKind.LIBRARY)) + answer = { HttpStatusCode.OK to """{"state":"available","allowed":true,"revision":"r","scope_kinds":["library"]}""" } + store.refresh() + assertTrue(store.capability.value.supports(ShuffleScopeKind.LIBRARY)) + + offline = true + store.refresh() + assertTrue(store.capability.value.supports(ShuffleScopeKind.LIBRARY)) + offline = false + + answer = { HttpStatusCode.InternalServerError to "" } + store.refresh() + assertTrue(store.capability.value.supports(ShuffleScopeKind.LIBRARY)) + + // A server without shuffles answers 404. + answer = { HttpStatusCode.NotFound to "" } + store.refresh() + assertFalse(store.capability.value.supports(ShuffleScopeKind.LIBRARY)) + + answer = { HttpStatusCode.OK to """{"state":"available","allowed":true,"revision":"r","scope_kinds":["library"]}""" } + store.refresh() + store.reset() + assertFalse(store.capability.value.supports(ShuffleScopeKind.LIBRARY)) + } + + @Test fun startSaysWhenNothingCanPlay() = runTest { + answer = { HttpStatusCode.Created to shuffleJson() } + assertEquals("sh1", assertIs<ShuffleStart.Started>(store.start(ShuffleScopeKind.SERIES, "show")).shuffle.id) + // The player of that shuffle takes the started pick once. + assertNull(store.takeStarted("other")) + assertEquals("mv-2", store.playback("sh1").nextPickAfter("ep-1")?.contentId) + assertNull(store.takeStarted("sh1")) + answer = { HttpStatusCode.Conflict to "" } + assertEquals(ShuffleFeatureStore.NOTHING_PLAYABLE, assertIs<ShuffleStart.Failed>(store.start(ShuffleScopeKind.SERIES, "show")).message) + answer = { HttpStatusCode.NotFound to "" } + assertEquals(ShuffleFeatureStore.START_FAILED, assertIs<ShuffleStart.Failed>(store.start(ShuffleScopeKind.SERIES, "show")).message) + offline = true + assertEquals(ShuffleFeatureStore.START_FAILED, assertIs<ShuffleStart.Failed>(store.start(ShuffleScopeKind.SERIES, "show")).message) + } + + @Test fun seasonsShuffleWithTwoPlayableEpisodesAndOnlyVideoLibrariesShuffle() { + fun episode(n: Int, playable: Boolean) = org.prairieserver.prairie.model.catalog.EpisodeListItem( + contentId = "e$n", seasonNumber = 1, episodeNumber = n, + files = if (playable) listOf(org.prairieserver.prairie.model.catalog.EpisodeFile(fileId = n)) else emptyList(), + ) + assertFalse(org.prairieserver.prairie.model.shuffle.canShuffleSeason(listOf(episode(1, true), episode(2, false)))) + assertTrue(org.prairieserver.prairie.model.shuffle.canShuffleSeason(listOf(episode(1, true), episode(2, true)))) + assertTrue(org.prairieserver.prairie.model.shuffle.isShuffleLibraryType("movies")) + assertTrue(org.prairieserver.prairie.model.shuffle.isShuffleLibraryType("Mixed")) + assertTrue(org.prairieserver.prairie.model.shuffle.isShuffleLibraryType("series")) + assertFalse(org.prairieserver.prairie.model.shuffle.isShuffleLibraryType("audiobooks")) + assertFalse(org.prairieserver.prairie.model.shuffle.isShuffleLibraryType(null)) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/AutoSubtitleResolverTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/AutoSubtitleResolverTest.kt index 69b24409c6..1a69ae1e32 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/AutoSubtitleResolverTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/AutoSubtitleResolverTest.kt @@ -16,12 +16,13 @@ class AutoSubtitleResolverTest { // --- the regression ------------------------------------------------- @Test - fun preferredLanguageAlwaysPicksTheExternalTextTrackOverAnEmbeddedBitmapOne() { + fun aPlainExternalTextTrackBeatsAnSdhEmbeddedBitmapOne() { // Shield, direct-play MKV: embedded PGS "English (SDH)" + an external // English SRT, preference English/Always. The detail row previewed the // SRT; the player, ranking only Media3's mounted tracks, started the // PGS. Over the full catalog the SRT wins — and its combined index is - // what the start request can carry. + // what the start request can carry. It wins because the PGS track is + // SDH, not because it is a bitmap: Android renders embedded PGS itself. val tracks = listOf( SubtitleTrack(index = 2, codec = "hdmv_pgs_subtitle", language = "eng", title = "English (SDH)"), SubtitleTrack(index = 0, codec = "srt", language = "eng", external = true), @@ -105,6 +106,158 @@ class AutoSubtitleResolverTest { assertEquals(1, selected?.selectionIndex) } + // --- embedded over external (prairie-server #1849 parity) ------------- + + @Test + fun anEmbeddedTextTrackBeatsAnExternalOneInTheSameLanguage() { + // External sidecars are the ones that drift out of sync. The catalog + // lists the sidecar first here, so caller order alone would pick it. + val tracks = listOf( + SubtitleTrack(index = 0, codec = "srt", language = "eng", title = "English", external = true), + SubtitleTrack(index = 3, codec = "subrip", language = "eng", title = "English"), + ) + + val selected = resolveAutoSubtitle( + candidates = catalogAutoSubtitleCandidates(tracks), + context = AutoSubtitleContext(preferredLanguage = "en", mode = "always"), + ).selectedCandidate() + + // Embedded tracks follow every external in combined space. + assertEquals(1, selected?.selectionIndex) + } + + @Test + fun theDetailPreviewAndThePlayerInventoryPickTheSameEmbeddedTrack() { + // The detail page ranks the catalog (embedded first); the player + // fallback ranks the session inventory (externals first). Source now + // decides the tie, so the two agree. + val catalog = listOf( + SubtitleTrack(index = 3, codec = "subrip", language = "eng", title = "English"), + SubtitleTrack(index = 0, codec = "srt", language = "eng", title = "English", external = true), + ) + val inventory = listOf( + PlayerSubtitleInfo(index = 0, language = "eng", codec = "srt", url = "/s/0.vtt", catalogLabel = "English", catalogSource = "external", serverDelivery = SUBTITLE_DELIVERY_SIDECAR), + PlayerSubtitleInfo(index = 1, language = "eng", codec = "subrip", url = "/s/1.vtt", catalogLabel = "English", catalogSource = "embedded", serverDelivery = SUBTITLE_DELIVERY_SIDECAR), + ) + val context = AutoSubtitleContext(preferredLanguage = "en", mode = "always") + + val fromCatalog = resolveAutoSubtitle(catalogAutoSubtitleCandidates(catalog), context).selectedCandidate() + val fromInventory = resolveAutoSubtitle(inventoryAutoSubtitleCandidates(inventory), context).selectedCandidate() + + assertEquals(1, fromCatalog?.selectionIndex) + assertEquals(1, fromInventory?.selectionIndex) + } + + @Test + fun anEmbeddedPgsTrackTheDeviceRendersBeatsAnExternalTextTrack() { + // Android plays embedded PGS without a transcode (in-stream on direct + // play, a raw .sup sidecar over HLS), so being a bitmap is no reason to + // fall back to the sidecar. + val tracks = listOf( + SubtitleTrack(index = 0, codec = "srt", language = "eng", external = true), + SubtitleTrack(index = 2, codec = "hdmv_pgs_subtitle", language = "eng", title = "English"), + ) + + val selected = resolveAutoSubtitle( + candidates = catalogAutoSubtitleCandidates(tracks), + context = AutoSubtitleContext(preferredLanguage = "en", mode = "always"), + ).selectedCandidate() + + assertEquals(1, selected?.selectionIndex) + } + + @Test + fun anEmbeddedBitmapTrackThatNeedsABurnInLosesToAnExternalTextTrack() { + // VobSub has no client route, so the server would transcode to show it. + val tracks = listOf( + SubtitleTrack(index = 0, codec = "srt", language = "eng", external = true), + SubtitleTrack(index = 2, codec = "dvd_subtitle", language = "eng", title = "English"), + ) + + val selected = resolveAutoSubtitle( + candidates = catalogAutoSubtitleCandidates(tracks), + context = AutoSubtitleContext(preferredLanguage = "en", mode = "always"), + ).selectedCandidate() + + assertEquals(0, selected?.selectionIndex) + } + + @Test + fun aServerBurnInOnlyEmbeddedPgsRowLosesToAnExternalTextRow() { + // The inventory's own delivery wins over the codec rule. + val rows = listOf( + PlayerSubtitleInfo(index = 0, language = "eng", codec = "srt", url = "/s/0.vtt", catalogSource = "external", serverDelivery = SUBTITLE_DELIVERY_SIDECAR), + PlayerSubtitleInfo(index = 1, language = "eng", codec = "pgs", url = "", catalogSource = "embedded", serverDelivery = SUBTITLE_DELIVERY_BURN_IN_ONLY), + ) + + val selected = resolveAutoSubtitle( + candidates = inventoryAutoSubtitleCandidates(rows), + context = AutoSubtitleContext(preferredLanguage = "en", mode = "always"), + ).selectedCandidate() + + assertEquals(0, selected?.selectionIndex) + } + + @Test + fun aFullExternalTrackBeatsAForcedEmbeddedOne() { + val tracks = listOf( + SubtitleTrack(index = 0, codec = "srt", language = "eng", external = true), + SubtitleTrack(index = 2, codec = "subrip", language = "eng", title = "Forced", forced = true), + ) + + val selected = resolveAutoSubtitle( + candidates = catalogAutoSubtitleCandidates(tracks), + context = AutoSubtitleContext(preferredLanguage = "en", mode = "always", showForced = true), + ).selectedCandidate() + + assertEquals(0, selected?.selectionIndex) + } + + @Test + fun anExternalTrackBeatsADownloadedOne() { + val rows = listOf( + PlayerSubtitleInfo(index = 1, language = "eng", codec = "srt", url = "/s/1.vtt", catalogSource = "downloaded", serverDelivery = SUBTITLE_DELIVERY_SIDECAR), + PlayerSubtitleInfo(index = 0, language = "eng", codec = "srt", url = "/s/0.vtt", catalogSource = "external", serverDelivery = SUBTITLE_DELIVERY_SIDECAR), + ) + + val selected = resolveAutoSubtitle( + candidates = inventoryAutoSubtitleCandidates(rows), + context = AutoSubtitleContext(preferredLanguage = "en", mode = "always"), + ).selectedCandidate() + + assertEquals(0, selected?.selectionIndex) + } + + @Test + fun aBurnInTrackStillWinsWhenItIsTheOnlyMatch() { + val tracks = listOf( + SubtitleTrack(index = 0, codec = "srt", language = "fre", external = true), + SubtitleTrack(index = 2, codec = "dvd_subtitle", language = "eng"), + ) + + val selected = resolveAutoSubtitle( + candidates = catalogAutoSubtitleCandidates(tracks), + context = AutoSubtitleContext(preferredLanguage = "en", mode = "always"), + ).selectedCandidate() + + assertEquals(1, selected?.selectionIndex) + } + + @Test + fun theForcedTrackForMatchingAudioPrefersTheEmbeddedOne() { + val tracks = listOf( + SubtitleTrack(index = 0, codec = "srt", language = "eng", title = "Forced", forced = true, external = true), + SubtitleTrack(index = 2, codec = "subrip", language = "eng", title = "Forced", forced = true), + ) + + val selected = resolveAutoSubtitle( + candidates = catalogAutoSubtitleCandidates(tracks), + context = AutoSubtitleContext(preferredLanguage = "en", mode = "auto", showForced = true, audioLanguage = "eng"), + ).selectedCandidate() + + assertEquals(1, selected?.selectionIndex) + } + // --- ported detail-preview cases ------------------------------------- @Test diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/NativeEmbeddedSubtitleProtocolTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/NativeEmbeddedSubtitleProtocolTest.kt index fb036424ad..ab170708b7 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/NativeEmbeddedSubtitleProtocolTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/NativeEmbeddedSubtitleProtocolTest.kt @@ -39,6 +39,20 @@ class NativeEmbeddedSubtitleProtocolTest { assertIs<PlaybackV3Validation.ReplanRequired>(validate(plan.copy(source = PlaybackSourceDescriptorV3(container = "mkv")))) } + @Test fun matroskaTextTracksArePlayableOnlyForAdvertisedCodecs() { + fun mkv(codec: String) = plan.copy( + source = PlaybackSourceDescriptorV3(container = "mkv"), + subtitle = plan.subtitle.copy(inventory = listOf(track.copy(codec = codec))), + ) + assertIs<PlaybackV3Validation.Playable>(validate(mkv("subrip"))) + assertIs<PlaybackV3Validation.Playable>(validate(mkv("ass"))) + assertIs<PlaybackV3Validation.Playable>(validate(mkv("srt"))) + assertIs<PlaybackV3Validation.ReplanRequired>(validate(mkv("webvtt"))) + assertIs<PlaybackV3Validation.ReplanRequired>(validate(plan.copy( + subtitle = plan.subtitle.copy(inventory = listOf(track.copy(codec = "subrip"))), + ))) + } + @Test fun nativeCapabilitiesHaveExactWireNames() { val capability = DeliverySubtitleCapabilities(nativeEmbedded = listOf(NativeEmbeddedSubtitleCapability("mp4", listOf("mov_text"), "container_track_id"))) val json = PrairieJson.encodeToString(capability) diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/PlaybackProtocolV3Test.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/PlaybackProtocolV3Test.kt index e792af7da8..856aa61ab4 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/PlaybackProtocolV3Test.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/PlaybackProtocolV3Test.kt @@ -686,4 +686,22 @@ class PlaybackProtocolV3Test { playbackClientFeaturesV3(context.copy(output = PlaybackOutputContext())), ) } + + @Test + fun `watch party starts pin the file and solo starts leave the field out`() { + fun encode(allow: Boolean?) = PrairieJson.encodeToString( + PlaybackStartRequestV3( + clientFeatures = PLAYBACK_START_CLIENT_FEATURES_V3, + fileId = 12, + profileId = "profile", + playbackAttemptId = "attempt", + subtitleFidelityPreference = SubtitleFidelityPreference.PRESERVE, + capabilities = ClientCodecCapabilities(), + clientPlaybackContext = ClientPlaybackContext(formFactor = "mobile", appVersion = "test"), + allowAlternateVersions = allow, + ), + ) + assertFalse(encode(null).contains("allow_alternate_versions")) + assertTrue(encode(false).contains("\"allow_alternate_versions\":false")) + } } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/PlaybackQualityMenuTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/PlaybackQualityMenuTest.kt new file mode 100644 index 0000000000..216eb1c152 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/PlaybackQualityMenuTest.kt @@ -0,0 +1,136 @@ +package org.prairieserver.prairie.model.playback + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class PlaybackQualityMenuTest { + private val ladder = listOf( + PlaybackAvailableQualityV3("original", 2160, 50_700, preservesSource = true), + PlaybackAvailableQualityV3("1080p", 1080, 12_000, displayName = "1080p"), + PlaybackAvailableQualityV3("1080p-medium", 1080, 6_000, displayName = "1080p Medium"), + PlaybackAvailableQualityV3("720p-low", 720, 1_500, displayName = "720p Low"), + ) + + @Test + fun menuListsAutoThenEveryServerEntryInServerOrder() { + val menu = playbackQualityMenu(ladder) + + assertEquals(listOf("auto", "original", "1080p", "1080p-medium", "720p-low"), menu.map { it.id }) + assertEquals(listOf("Auto", "Original", "1080p", "1080p Medium", "720p Low"), menu.map { it.name }) + assertEquals( + listOf(null, "50.7 Mbps", "12 Mbps", "6 Mbps", "1.5 Mbps"), + menu.map { it.bitrateLabel }, + ) + } + + @Test + fun aSingleEntryStillGetsAutoAndNoEntriesGetNoMenu() { + val sole = listOf(PlaybackAvailableQualityV3("original", 1080, 8_000, preservesSource = true)) + + assertEquals(listOf("auto", "original"), playbackQualityMenu(sole).map { it.id }) + assertEquals(listOf("Auto", "Original"), playbackQualityMenu(sole).map { it.name }) + assertEquals(emptyList(), playbackQualityMenu(emptyList())) + } + + @Test + fun namePrefersTheServerDisplayNameThenOriginalThenTheLabel() { + assertEquals("4K High", playbackQualityName(PlaybackAvailableQualityV3("2160p-high", displayName = "4K High"))) + assertEquals("Original", playbackQualityName(PlaybackAvailableQualityV3("original", preservesSource = true))) + assertEquals("Original", playbackQualityName(PlaybackAvailableQualityV3("original", displayName = " ", preservesSource = true))) + assertEquals("480p", playbackQualityName(PlaybackAvailableQualityV3("480p"))) + } + + @Test + fun bitrateIsMbpsToOneDecimalAndOmittedWhenUnknown() { + assertEquals("40 Mbps", formatPlaybackQualityBitrate(40_000)) + assertEquals("6 Mbps", formatPlaybackQualityBitrate(6_000)) + assertEquals("1.5 Mbps", formatPlaybackQualityBitrate(1_500)) + assertEquals("50.7 Mbps", formatPlaybackQualityBitrate(50_712)) + assertEquals("2 Mbps", formatPlaybackQualityBitrate(1_960)) + assertEquals("800 kbps", formatPlaybackQualityBitrate(800)) + assertNull(formatPlaybackQualityBitrate(0)) + assertNull(formatPlaybackQualityBitrate(-1)) + } + + @Test + fun activeEntryMatchesThePreferenceExactly() { + val menu = playbackQualityMenu(ladder) + + assertEquals("1080p-medium", activePlaybackQualityId(menu, "1080p-medium")) + assertEquals("1080p", activePlaybackQualityId(menu, "1080p")) + assertEquals("original", activePlaybackQualityId(menu, "ORIGINAL")) + } + + @Test + fun noPreferenceAndAutoSelectTheAutoRow() { + val menu = playbackQualityMenu(ladder) + + assertEquals("auto", activePlaybackQualityId(menu, null)) + assertEquals("auto", activePlaybackQualityId(menu, " ")) + assertEquals("auto", activePlaybackQualityId(menu, "auto")) + } + + @Test + fun aSoleEntryIsActiveForAnyPreferenceButAuto() { + val menu = playbackQualityMenu(listOf(PlaybackAvailableQualityV3("original", preservesSource = true))) + + assertEquals("auto", activePlaybackQualityId(menu, null)) + assertEquals("auto", activePlaybackQualityId(menu, "auto")) + assertEquals("original", activePlaybackQualityId(menu, "720p-low")) + assertEquals("original", activePlaybackQualityId(menu, "1080p")) + assertNull(activePlaybackQualityId(emptyList(), "auto")) + } + + @Test + fun sourceAliasesSelectTheOriginalEntry() { + val menu = playbackQualityMenu(ladder) + + assertEquals("original", activePlaybackQualityId(menu, "max")) + assertEquals("original", activePlaybackQualityId(menu, "source")) + assertNull(activePlaybackQualityId(menu, "unknown")) + } + + @Test + fun aResolutionPreferenceAtOrAboveTheSourceSelectsOriginal() { + val menu = playbackQualityMenu( + listOf( + PlaybackAvailableQualityV3("original", 1080, 8_000, preservesSource = true), + PlaybackAvailableQualityV3("1080p-medium", 1080, 6_000, displayName = "1080p Medium"), + PlaybackAvailableQualityV3("720p-medium", 720, 4_000, displayName = "720p Medium"), + ), + ) + + assertEquals("original", activePlaybackQualityId(menu, "2160p")) + assertEquals("original", activePlaybackQualityId(menu, "1080p")) + assertEquals("720p-medium", activePlaybackQualityId(menu, "720p")) + } + + @Test + fun aBitrateCapMovesAResolutionPreferenceToTheDeliveredClass() { + val menu = playbackQualityMenu( + listOf( + PlaybackAvailableQualityV3("original", 1080, 8_000, preservesSource = true), + PlaybackAvailableQualityV3("1080p-medium", 1080, 6_000), + PlaybackAvailableQualityV3("720p-medium", 720, 4_000), + PlaybackAvailableQualityV3("480p", 480, 1_500), + ), + ) + + assertEquals( + "1080p-medium", + activePlaybackQualityId(menu, "1080p", PlaybackEffectiveRecipeV3(width = 1920, height = 800, bitrateKbps = 6_000)), + ) + assertEquals( + "720p-medium", + activePlaybackQualityId(menu, "1080p", PlaybackEffectiveRecipeV3(width = 1280, height = 688, bitrateKbps = 4_000)), + ) + assertEquals( + "480p", + activePlaybackQualityId(menu, "1080p", PlaybackEffectiveRecipeV3(width = 854, height = 480, bitrateKbps = 1_500)), + ) + assertNull( + activePlaybackQualityId(menu, "1080p", PlaybackEffectiveRecipeV3(width = 960, height = 540, bitrateKbps = 2_000)), + ) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/SubtitleTrackMergeTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/SubtitleTrackMergeTest.kt index bee632d8c3..26bb6604fd 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/SubtitleTrackMergeTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/SubtitleTrackMergeTest.kt @@ -72,6 +72,22 @@ class SubtitleTrackMergeTest { assertEquals(313, second.downloadId) assertEquals("Dune Part Three (subdl)", second.label) assertEquals("/stream/sess-1/subtitles/3.ass", second.url) + + // Stored subtitles the server can retime are named for sync as `stored-{id}`. + assertEquals("stored-312", first.syncKey) + assertEquals("stored-313", second.syncKey) + } + + @Test + fun `a stored subtitle the server cannot retime gets no sync key`() { + val merged = mergeDownloadedSubtitles( + existing = emptyList(), + downloaded = listOf( + downloaded(id = 7, language = "en", format = "pgs", releaseName = "r", provider = "upload"), + ), + sessionId = "sess-1", + ) + assertNull(merged.single().syncKey) } @Test diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/request/RequestDisplayStateTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/request/RequestDisplayStateTest.kt new file mode 100644 index 0000000000..13d42a3acf --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/request/RequestDisplayStateTest.kt @@ -0,0 +1,168 @@ +package org.prairieserver.prairie.model.request + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TestTimeSource +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.repository.RequestDetailCache + +class RequestDisplayStateTest { + + @Test + fun serverStateDecidesBeforeStatus() { + // A finished download the library hasn't picked up yet: `completed` + // alone would read as in library. + assertEquals(RequestDisplayState.OnTheWay, display(status = RequestStatus.Completed, state = RequestUserState.Processing)) + assertEquals(RequestDisplayState.OnTheWay, display(status = RequestStatus.Completed, state = RequestUserState.PartiallyAvailable)) + assertEquals(RequestDisplayState.InLibrary, display(status = RequestStatus.Completed, state = RequestUserState.Available)) + assertEquals( + RequestDisplayState.NeedsAttention(RequestAttention.Declined, null), + display(status = RequestStatus.Pending, state = RequestUserState.Declined), + ) + } + + @Test + fun unknownOrMissingStateFallsBackToStatusAndOutcome() { + assertEquals(RequestDisplayState.InLibrary, display(status = RequestStatus.Completed)) + assertEquals(RequestDisplayState.OnTheWay, display(status = "brand_new_stage", state = "brand_new_state")) + assertEquals( + RequestDisplayState.NeedsAttention(RequestAttention.Failed, "No indexer"), + display(status = RequestStatus.Downloading, outcome = RequestOutcome.Failed, lastError = "No indexer"), + ) + assertTrue(display(status = RequestStatus.Pending, outcome = RequestOutcome.Cancelled) is RequestDisplayState.Unavailable) + } + + @Test + fun cardStateIgnoresTheRequestabilityReasonOnceARequestExists() { + val annotated = RequestState(status = RequestStatus.Approved, requestable = false, reason = RequestReason.AlreadyRequested) + assertEquals(RequestDisplayState.OnTheWay, RequestDisplayState.of(RequestAvailability.Missing, annotated)) + assertNull(RequestDisplayState.of(RequestAvailability.Missing, RequestState(requestable = true))) + // A series in the library with its missing seasons on the way reads as on the way. + val seasons = RequestState(status = RequestStatus.Downloading, state = RequestUserState.Approved) + assertEquals(RequestDisplayState.OnTheWay, RequestDisplayState.of(RequestAvailability.Available, seasons)) + assertNull(RequestDisplayState.OnTheWay.libraryItemToOpen("item-1")) + assertEquals("item-1", RequestDisplayState.InLibrary.libraryItemToOpen("item-1")) + } + + @Test + fun progressPlacesEachStateOnTheTrack() { + val pending = RequestProgress.of(record(status = RequestStatus.Pending)) + assertEquals(1, pending.completedSteps) + assertEquals(RequestStep.Approval, pending.currentStep) + + val queued = RequestProgress.of(record(status = RequestStatus.Queued)) + assertEquals(RequestStep.Download, queued.currentStep) + assertEquals("Queued", queued.shortLabel) + + val adding = RequestProgress.of(record(status = RequestStatus.Completed, state = RequestUserState.Processing)) + assertEquals(RequestStep.Library, adding.currentStep) + assertEquals(3, adding.completedSteps) + assertEquals("Adding to your library", adding.longLabel) + + val landed = RequestProgress.of(record(status = RequestStatus.Completed, state = RequestUserState.Available)) + assertEquals(4, landed.completedSteps) + assertNull(landed.currentStep) + + val failed = RequestProgress.of(record(status = RequestStatus.Downloading, outcome = RequestOutcome.Failed)) + assertEquals(RequestStep.Download, failed.currentStep) + assertEquals(RequestStatusTint.Rose, failed.tint) + } + + @Test + fun bucketsPutRequestsThatNeedTheUserAboveFinishedOnes() { + val buckets = MyRequestsBucket.bucket( + listOf( + record(id = "landed", status = RequestStatus.Completed, state = RequestUserState.Available), + record(id = "old", status = RequestStatus.Pending, createdAt = "2026-09-01T00:00:00Z"), + record(id = "new", status = RequestStatus.Approved, createdAt = "2026-09-02T00:00:00.5Z"), + record(id = "failed", status = RequestStatus.Pending, outcome = RequestOutcome.Failed), + record(id = "gone", status = RequestStatus.Pending, outcome = RequestOutcome.Cancelled), + ), + ) + assertEquals( + listOf(MyRequestsBucket.InMotion, MyRequestsBucket.NeedsAttention, MyRequestsBucket.Landed), + buckets.map { it.first }, + ) + assertEquals(listOf("new", "old"), buckets.first().second.map { it.id }) + } + + @Test + fun theNewestRequestSpeaksForItsTitle() { + val declined = record(id = "declined", outcome = RequestOutcome.Declined, createdAt = "2026-09-01T00:00:00Z") + val cancelled = record(id = "cancelled", outcome = RequestOutcome.Cancelled, createdAt = "2026-09-02T00:00:00Z") + // Cancelling a newer request doesn't bring back an older decline. + assertNull(RequestDetailCache.currentRecord(listOf(declined, cancelled))) + val active = record(id = "active", createdAt = "2026-08-01T00:00:00Z") + assertEquals("active", RequestDetailCache.currentRecord(listOf(declined, cancelled, active))?.id) + } + + @Test + fun aRequestActionHoldEndsOnlyWhenTheRequestChangesLeavesOrLapses() { + val clock = TestTimeSource() + val request = record(id = "r1", updatedAt = "2026-09-01T00:00:00Z") + val hold = RequestActionHold(request, clock, lifetime = 60.seconds) + + assertFalse(hold.isSettled(request)) + assertTrue(hold.isSettled(request.copy(updatedAt = "2026-09-01T00:01:00Z"))) + assertTrue(hold.isSettled(null)) + clock += 60.seconds + assertTrue(hold.isSettled(request)) + } + + @Test + fun aProxyGatewayErrorIsUncertainButTheServersOwnProblemIsNot() { + assertTrue(RequestMutationFailure.isUncertain(ApiResult.Error(504, "", ""))) + assertTrue(RequestMutationFailure.isUncertain(ApiResult.Error(502, "", ""))) + assertFalse(RequestMutationFailure.isUncertain(ApiResult.Error(503, "capability_unavailable", "Requests are unavailable."))) + assertFalse(RequestMutationFailure.isUncertain(ApiResult.Error(500, "", ""))) + } + + @Test + fun aDeclineExplainsItselfWithTheOutcomeReasonNotTheDownloadError() { + val declined = record(state = RequestUserState.Declined, lastError = "Radarr unreachable").copy(outcomeReason = "Already on disc") + assertEquals(RequestDisplayState.NeedsAttention(RequestAttention.Declined, "Already on disc"), RequestDisplayState.of(declined)) + val failed = record(outcome = RequestOutcome.Failed, lastError = "Radarr unreachable") + assertEquals(RequestDisplayState.NeedsAttention(RequestAttention.Failed, "Radarr unreachable"), RequestDisplayState.of(failed)) + } + + @Test + fun reasonCopyHidesUnknownCodesButKeepsServerSentences() { + assertEquals("Request limit reached", requestReasonCopy(RequestReason.QuotaExceeded)) + assertEquals("One or more fulfillment targets failed", requestReasonCopy("one or more fulfillment targets failed")) + assertNull(requestReasonCopy("some_new_code")) + assertEquals("Request failed · No indexer", RequestDisplayState.NeedsAttention(RequestAttention.Failed, "No indexer").detailTitle) + } + + private fun display( + status: String, + outcome: String = RequestOutcome.Active, + state: String? = null, + lastError: String = "", + ): RequestDisplayState = RequestDisplayState.of(record(status = status, outcome = outcome, state = state, lastError = lastError)) +} + +internal fun record( + id: String = "r1", + tmdbId: Int = 1, + status: String = RequestStatus.Pending, + outcome: String = RequestOutcome.Active, + state: String? = null, + lastError: String = "", + createdAt: String = "2026-09-01T00:00:00Z", + updatedAt: String = createdAt, +): MediaRequest = MediaRequest( + id = id, + mediaType = RequestMediaType.Movie, + tmdbId = tmdbId, + title = "Film", + status = status, + outcome = outcome, + state = state, + lastError = lastError, + createdAt = createdAt, + updatedAt = updatedAt, +) diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/request/RequestPresentationTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/request/RequestPresentationTest.kt index 052ee18f98..60955ae545 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/request/RequestPresentationTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/request/RequestPresentationTest.kt @@ -28,80 +28,15 @@ class RequestPresentationTest { } @Test - fun `badge status prefers availability then status then requestable then reason`() { - assertEquals(RequestAvailability.Available, result(availability = RequestAvailability.Available).badgeStatus()) - assertEquals(RequestStatus.Pending, result(requestStatus = RequestStatus.Pending).badgeStatus()) - assertEquals("request", result(requestable = true).badgeStatus()) - assertEquals("not allowed", result(reason = "not allowed").badgeStatus()) - assertEquals(RequestAvailability.Missing, result().badgeStatus()) - } - - @Test - fun `display label maps known tokens and title-cases the rest`() { - assertEquals("In Library", RequestAvailability.Available.requestDisplayLabel()) - assertEquals("Missing", RequestAvailability.Missing.requestDisplayLabel()) - assertEquals("Request", "request".requestDisplayLabel()) - assertEquals("Movie", RequestMediaType.Movie.requestDisplayLabel()) - assertEquals("Series", RequestMediaType.Series.requestDisplayLabel()) - assertEquals("All", RequestMediaType.All.requestDisplayLabel()) - assertEquals("Pending", RequestStatus.Pending.requestDisplayLabel()) - assertEquals("Partially Available", "partially_available".requestDisplayLabel()) - } - - @Test - fun `reason message reads each server reason as a sentence`() { - assertEquals( - "This title is already in your library.", - RequestState(reason = RequestReason.AlreadyAvailable).reasonMessage(), - ) - assertEquals( - "Requests are disabled on this server.", - RequestState(reason = RequestReason.RequestsDisabled).reasonMessage(), - ) - assertEquals( - "Your account is blocked from making requests.", - RequestState(reason = RequestReason.Blocked).reasonMessage(), - ) - assertEquals( - "You've reached your request limit.", - RequestState(reason = RequestReason.QuotaExceeded).reasonMessage(), - ) - } - - @Test - fun `reason message is empty for an existing request, an unknown code, or no reason`() { - assertNull( - RequestState(status = RequestStatus.Downloading, reason = RequestReason.AlreadyRequested).reasonMessage(), - ) - assertNull(RequestState(reason = "some_future_reason").reasonMessage()) - assertNull(RequestState(requestable = true).reasonMessage()) - } - - @Test - fun `reason message keeps readable reason text from the server`() { - assertEquals("Already requested", RequestState(reason = "Already requested").reasonMessage()) - } - - @Test - fun `can cancel only while active and pending`() { - assertTrue(request(status = RequestStatus.Pending, outcome = RequestOutcome.Active).canCancel()) - assertFalse(request(status = RequestStatus.Downloading, outcome = RequestOutcome.Active).canCancel()) - assertFalse(request(status = RequestStatus.Pending, outcome = RequestOutcome.Cancelled).canCancel()) - } - - @Test - fun `target summary joins fields and truncates with ellipsis after two targets`() { - assertNull(request().targetSummary()) - - val summary = request( - targets = listOf( - target(id = 1, instanceName = "Radarr 4K", quality = "2160p", status = "queued"), - target(id = 2, instanceName = "Radarr", quality = "1080p", status = "queued"), - target(id = 3, instanceName = "Backup", quality = "720p", status = "queued"), - ), - ).targetSummary() - - assertEquals("Radarr 4K • 2160p • queued, Radarr • 1080p • queued, …", summary) + fun `cards open the library only when their status reads in library`() { + val inLibrary = result(availability = RequestAvailability.Available).copy(libraryContentId = "item-1") + assertEquals("item-1", inLibrary.libraryItemToOpen()) + // A series in the library with missing seasons on the way opens the request. + val seasonsComing = inLibrary.copy(request = RequestState(status = RequestStatus.Downloading, state = RequestUserState.Approved)) + assertNull(seasonsComing.libraryItemToOpen()) + val landed = request(status = RequestStatus.Completed).copy(state = RequestUserState.Available, libraryContentId = "item-2") + assertEquals("item-2", landed.libraryItemToOpen()) + assertNull(landed.copy(state = RequestUserState.Processing).libraryItemToOpen()) } private fun result( @@ -132,19 +67,4 @@ class RequestPresentationTest { createdAt = "2026-06-12T00:00:00Z", updatedAt = "2026-06-12T00:00:00Z", ) - - private fun target( - id: Long, - instanceName: String, - quality: String, - status: String, - ): RequestTarget = RequestTarget( - id = id.toString(), - requestId = "request-1", - instanceName = instanceName, - quality = quality, - status = status, - createdAt = "2026-06-12T00:00:00Z", - updatedAt = "2026-06-12T00:00:00Z", - ) } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/request/RequestTargetSummaryTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/request/RequestTargetSummaryTest.kt new file mode 100644 index 0000000000..2b2a96293b --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/request/RequestTargetSummaryTest.kt @@ -0,0 +1,54 @@ +package org.prairieserver.prairie.model.request + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class RequestTargetSummaryTest { + private fun target(quality: String, status: String) = RequestTarget( + id = quality + status, + requestId = "r", + quality = quality, + status = status, + createdAt = "2026-10-01T00:00:00Z", + updatedAt = "2026-10-01T00:00:00Z", + ) + + @Test + fun textIsNullForSingleTargetRequests() { + assertNull(RequestTargetSummary.text(emptyList())) + assertNull(RequestTargetSummary.text(listOf(target("1080p", RequestStatus.Downloading)))) + } + + @Test + fun textNamesEachQualityState() { + val targets = listOf( + target("1080p", RequestStatus.Downloading), + target("4K", RequestStatus.Queued), + target("720p", RequestStatus.Approved), + target("480p", RequestStatus.Pending), + target("360p", RequestStatus.Completed), + target("240p", RequestStatus.Failed), + ) + assertEquals( + "1080p downloading · 4K queued · 720p queued · 480p pending · 360p done · 240p failed", + RequestTargetSummary.text(targets), + ) + } + + @Test + fun textSkipsBlankQualitiesAndUnknownStatuses() { + assertEquals( + "1080p done", + RequestTargetSummary.text(listOf(target("1080p", RequestStatus.Completed), target("", RequestStatus.Completed), target("4K", "weird"))), + ) + assertNull(RequestTargetSummary.text(listOf(target("", RequestStatus.Completed), target("4K", "weird")))) + } + + @Test + fun qualitiesListsNonBlankQualities() { + assertEquals("1080p · 4K", RequestTargetSummary.qualities(listOf(target("1080p", "x"), target("", "x"), target("4K", "x")))) + assertNull(RequestTargetSummary.qualities(listOf(target("", "x")))) + assertNull(RequestTargetSummary.qualities(emptyList())) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/EpisodeSpoilersTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/EpisodeSpoilersTest.kt new file mode 100644 index 0000000000..03489e6861 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/EpisodeSpoilersTest.kt @@ -0,0 +1,117 @@ +package org.prairieserver.prairie.model.settings + +import org.prairieserver.prairie.model.catalog.BrowseItem +import org.prairieserver.prairie.model.catalog.withWatched +import org.prairieserver.prairie.model.catalog.LeafItemUserData +import org.prairieserver.prairie.model.catalog.MediaItemUserState +import org.prairieserver.prairie.model.section.SectionItem +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class EpisodeSpoilersTest { + + @Test + fun browsePostersAndDetailHandoffsUseSelectedArtworkProvenance() { + val prefs = EpisodeSpoilerPrefs(hideImages = true) + val episode = BrowseItem("episode", "episode", "Episode", posterUrl = "still", + posterIsEpisodeStill = true, backdropUrl = "series", backdropIsEpisodeStill = false) + assertTrue(EpisodeSpoilers.hidesBrowseArtwork(episode, prefs)) + assertFalse(EpisodeSpoilers.hidesBrowseArtwork(episode, prefs, selectBackdrop = true)) + assertTrue(EpisodeSpoilers.hidesBrowseArtwork(episode.copy(backdropIsEpisodeStill = true), prefs, selectBackdrop = true)) + assertTrue(EpisodeSpoilers.hidesBrowseArtwork(episode.copy(posterIsEpisodeStill = null), prefs)) + assertFalse(EpisodeSpoilers.hidesBrowseArtwork(episode.copy(posterIsEpisodeStill = false), prefs)) + assertFalse(EpisodeSpoilers.hidesBrowseArtwork(episode, prefs, played = true)) + assertFalse(EpisodeSpoilers.hidesBrowseArtwork(episode.copy(positionSeconds = 2.0), prefs)) + assertFalse(EpisodeSpoilers.hidesBrowseArtwork(episode.copy(type = "series"), prefs)) + assertFalse(EpisodeSpoilers.hidesBrowseArtwork(episode, EpisodeSpoilerPrefs.NONE)) + } + + @Test + fun optimisticUnwatchedResetProtectsStartedBrowseArtwork() { + val prefs = EpisodeSpoilerPrefs(hideImages = true) + val original = BrowseItem("episode", "episode", "Episode", posterIsEpisodeStill = true, + backdropUrl = "still", backdropIsEpisodeStill = true, positionSeconds = 20.0) + assertFalse(EpisodeSpoilers.hidesBrowseArtwork(original, prefs)) + val reset = original.withWatched(false) + assertTrue(EpisodeSpoilers.hidesBrowseArtwork(original, prefs, played = reset.userState?.played, + positionSeconds = reset.positionSeconds)) + assertTrue(EpisodeSpoilers.hidesBrowseArtwork(original, prefs, played = reset.userState?.played, + selectBackdrop = true, positionSeconds = reset.positionSeconds)) + assertFalse(EpisodeSpoilers.hidesBrowseArtwork(original, prefs)) + assertFalse(EpisodeSpoilers.hidesBrowseArtwork(original.withWatched(true), prefs)) + } + + @Test + fun browseWireProgressKeepsStartedEpisodeArtworkVisible() { + val item = Json.decodeFromString<BrowseItem>("""{"content_id":"episode","type":"episode","title":"Episode","position_seconds":5,"poster_is_episode_still":true}""") + assertFalse(EpisodeSpoilers.hidesBrowseArtwork(item, EpisodeSpoilerPrefs(hideImages = true))) + } + + @Test + fun missingWatchStateCountsAsUnwatched() { + assertTrue(EpisodeSpoilers.isUnwatched(userData = null)) + assertTrue(EpisodeSpoilers.isUnwatched(LeafItemUserData())) + assertTrue(EpisodeSpoilers.isUnwatched(LeafItemUserData(positionSeconds = 0.0))) + } + + @Test + fun playedOrStartedEpisodesAreWatched() { + assertFalse(EpisodeSpoilers.isUnwatched(LeafItemUserData(played = true))) + assertFalse(EpisodeSpoilers.isUnwatched(LeafItemUserData(isInProgress = true))) + assertFalse(EpisodeSpoilers.isUnwatched(LeafItemUserData(positionSeconds = 12.0))) + // A played episode whose position was reset stays revealed. + assertFalse( + EpisodeSpoilers.isUnwatched(LeafItemUserData(played = true, positionSeconds = 0.0)), + ) + } + + @Test + fun sectionItemsUsePlayedAndPosition() { + assertTrue(EpisodeSpoilers.isUnwatched(sectionItem())) + assertTrue(EpisodeSpoilers.isUnwatched(sectionItem(userState = MediaItemUserState()))) + assertFalse(EpisodeSpoilers.isUnwatched(sectionItem(positionSeconds = 30.0))) + assertFalse( + EpisodeSpoilers.isUnwatched(sectionItem(userState = MediaItemUserState(played = true))), + ) + } + + @Test + fun supportNeedsRevision16AndBatchedEffective() { + fun caps(revision: Int, batched: Boolean = true, apiVersion: Int = 1) = + SettingsContractCapabilities( + apiVersion = apiVersion, + manifestRevision = revision, + supportsBatchedEffective = batched, + ) + assertTrue(EpisodeSpoilers.isSupported(caps(17))) + assertTrue(EpisodeSpoilers.isSupported(caps(18))) + assertFalse(EpisodeSpoilers.isSupported(caps(16))) + assertFalse(EpisodeSpoilers.isSupported(caps(17, batched = false))) + assertFalse(EpisodeSpoilers.isSupported(caps(17, apiVersion = 2))) + } + + @Test + fun imageProvenanceFollowsFallbackAndPreservesSeriesArt() { + val prefs = EpisodeSpoilerPrefs(hideImages = true) + val still = sectionItem().copy(backdropUrl = "https://example.invalid/still", backdropIsEpisodeStill = true) + assertTrue(prefs.hidesImage(EpisodeSpoilers.isUnwatched(still), EpisodeSpoilers.selectedImageIsStill(still))) + val seriesArt = still.copy(backdropIsEpisodeStill = false) + assertFalse(prefs.hidesImage(EpisodeSpoilers.isUnwatched(seriesArt), EpisodeSpoilers.selectedImageIsStill(seriesArt))) + val fallback = still.copy(backdropUrl = "", posterUrl = "https://example.invalid/poster", posterIsEpisodeStill = false) + assertFalse(prefs.hidesImage(true, EpisodeSpoilers.selectedImageIsStill(fallback))) + assertTrue(prefs.hidesImage(true, null)) + } + + private fun sectionItem( + userState: MediaItemUserState? = null, + positionSeconds: Double? = null, + ) = SectionItem( + contentId = "ep-1", + type = "episode", + title = "Pilot", + positionSeconds = positionSeconds, + userState = userState, + ) +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearanceProjectionTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearanceProjectionTest.kt index dc3825e1d5..2cae66e45e 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearanceProjectionTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearanceProjectionTest.kt @@ -21,6 +21,7 @@ class SubtitleAppearanceProjectionTest { PlaybackSettingsKeys.SubtitleBackgroundColor to "#101010", PlaybackSettingsKeys.SubtitleBackgroundStyle to "box", PlaybackSettingsKeys.SubtitleBackgroundOpacity to "40", + PlaybackSettingsKeys.SubtitleTextOpacity to "65", PlaybackSettingsKeys.SubtitleTextOutline to "true", PlaybackSettingsKeys.SubtitleTextOutlineColor to "#001122", PlaybackSettingsKeys.SubtitlePosition to "lower-third", @@ -33,6 +34,7 @@ class SubtitleAppearanceProjectionTest { assertEquals("#101010", projected.backgroundColor) assertEquals(SubtitleBackgroundStylePreset.Box, projected.backgroundStyle) assertEquals(40, projected.backgroundOpacity) + assertEquals(65, projected.textOpacity) assertEquals(true, projected.textOutline) assertEquals("#001122", projected.textOutlineColor) assertEquals(SubtitlePositionPreset.LowerThird, projected.position) @@ -99,6 +101,30 @@ class SubtitleAppearanceProjectionTest { ) } + @Test + fun `text opacity floors at 1 rather than 0`() { + // Unlike backgroundOpacity (0-100), textOpacity's floor is 1: fully + // invisible text isn't a state this field can express. + assertEquals( + 100, + SubtitleAppearanceProjection.project( + mapOf(PlaybackSettingsKeys.SubtitleTextOpacity to "180"), + ).textOpacity, + ) + assertEquals( + 1, + SubtitleAppearanceProjection.project( + mapOf(PlaybackSettingsKeys.SubtitleTextOpacity to "0"), + ).textOpacity, + ) + assertEquals( + 1, + SubtitleAppearanceProjection.project( + mapOf(PlaybackSettingsKeys.SubtitleTextOpacity to "-5"), + ).textOpacity, + ) + } + @Test fun `the legacy numeric position maps onto the enum`() { fun positionFor(raw: String) = SubtitleAppearanceProjection.project( diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearanceTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearanceTest.kt index ce60f1dc0d..454df280ef 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearanceTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearanceTest.kt @@ -1,7 +1,13 @@ package org.prairieserver.prairie.model.settings +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue class SubtitleAppearanceTest { @@ -49,4 +55,27 @@ class SubtitleAppearanceTest { assertEquals(SubtitleBackgroundStylePreset.Shadow, decoded.backgroundStyle) } + + @Test + fun textOpacityIsRemovedFromTheWireObjectBelowRevision14() { + val wire = wireObject(SubtitleAppearance.DEFAULT.copy(textOpacity = 40)) + + val forOldServer = SubtitleAppearance.wireObjectForRevision(wire, manifestRevision = 13) + + assertFalse(SubtitleAppearance.TEXT_OPACITY_FIELD in forOldServer) + // Every other field still reaches the server. + assertEquals(wire - SubtitleAppearance.TEXT_OPACITY_FIELD, forOldServer.toMap()) + } + + @Test + fun textOpacityIsKeptInTheWireObjectFromRevision14() { + val wire = wireObject(SubtitleAppearance.DEFAULT.copy(textOpacity = 40)) + + assertSame(wire, SubtitleAppearance.wireObjectForRevision(wire, manifestRevision = 14)) + assertTrue(SubtitleAppearance.supportsTextOpacity(14)) + assertFalse(SubtitleAppearance.supportsTextOpacity(13)) + } + + private fun wireObject(appearance: SubtitleAppearance): JsonObject = + Json.parseToJsonElement(appearance.toJsonString()).jsonObject } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/subtitles/SubtitleSyncLabelTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/subtitles/SubtitleSyncLabelTest.kt new file mode 100644 index 0000000000..11886ac4ee --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/subtitles/SubtitleSyncLabelTest.kt @@ -0,0 +1,62 @@ +package org.prairieserver.prairie.model.subtitles + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class SubtitleSyncLabelTest { + private val identity = SubtitleTiming() + + private fun job(status: String, result: SubtitleTiming? = null) = SubtitleSyncJob(status = status, result = result) + + @Test + fun formatsOffsetsWithOneDecimal() { + assertEquals("−3.2 s", formatSyncOffset(-3_200)) + assertEquals("+1.4 s", formatSyncOffset(1_400)) + assertEquals("+2.0 s", formatSyncOffset(2_000)) + assertEquals("−6.2 s", formatSyncOffset(-6_192)) + assertEquals("+0.0 s", formatSyncOffset(0)) + } + + @Test + fun namesFrameRateConversionsAndSpeedFactors() { + assertEquals("25→23.976 fps", describeSyncScale(25.0 / 23.976)) + assertEquals("×1.0008 speed", describeSyncScale(1.0008)) + assertEquals("×0.9991 speed", describeSyncScale(0.9991277785047734)) + assertEquals("×1.1 speed", describeSyncScale(1.1)) + assertEquals("×1 speed", describeSyncScale(1.00001)) + assertNull(describeSyncScale(1.0)) + } + + @Test + fun matchesTheWebPlayerWording() { + assertEquals("Syncing…", subtitleSyncStatusLabel(identity, job(SubtitleSyncJob.PENDING))) + assertEquals("Syncing…", subtitleSyncStatusLabel(identity, job(SubtitleSyncJob.RUNNING))) + assertEquals("Doesn't match this video", subtitleSyncStatusLabel(identity, job(SubtitleSyncJob.NO_MATCH))) + assertEquals("Sync failed", subtitleSyncStatusLabel(identity, job(SubtitleSyncJob.FAILED))) + assertEquals("Already in sync", subtitleSyncStatusLabel(identity, job(SubtitleSyncJob.ALREADY_SYNCED))) + + val offset = SubtitleTiming(offsetMs = -3_200) + assertEquals("Synced −3.2 s", subtitleSyncStatusLabel(offset, job(SubtitleSyncJob.SYNCED, offset))) + val drift = SubtitleTiming(offsetMs = -23_700, scale = 1.0008) + assertEquals("Synced −23.7 s · ×1.0008 speed", subtitleSyncStatusLabel(drift, job(SubtitleSyncJob.SYNCED, drift))) + val frameRate = SubtitleTiming(offsetMs = 1_400, scale = 25.0 / 23.976) + assertEquals( + "Synced +1.4 s · 25→23.976 fps", + subtitleSyncStatusLabel(frameRate, job(SubtitleSyncJob.SYNCED, frameRate)), + ) + } + + @Test + fun reportsResetsAndManualChanges() { + // A synced subtitle that was reset afterwards plays its original timing. + val found = SubtitleTiming(offsetMs = -3_200) + assertEquals("Original timing", subtitleSyncStatusLabel(identity, job(SubtitleSyncJob.SYNCED, found))) + // Timing that differs from the job's result was set some other way. + assertEquals( + "Timing adjusted +0.5 s", + subtitleSyncStatusLabel(SubtitleTiming(offsetMs = 500), job(SubtitleSyncJob.SYNCED, found)), + ) + assertNull(subtitleSyncStatusLabel(identity, null)) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/subtitles/SubtitleSyncStateTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/subtitles/SubtitleSyncStateTest.kt new file mode 100644 index 0000000000..9323b11d70 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/subtitles/SubtitleSyncStateTest.kt @@ -0,0 +1,117 @@ +package org.prairieserver.prairie.model.subtitles + +import org.prairieserver.prairie.network.PrairieJson +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SubtitleSyncStateTest { + private val key = "external-1b12a8ccaa6122106f36824eb19e86f7a6dbee64b733031838a285229dbed67b" + + @Test + fun decodesARunningSidecarSync() { + val state = PrairieJson.decodeFromString( + SubtitleSyncState.serializer(), + """{"key":"$key","media_file_id":"1","source":"external","language":"en","format":"srt", + "label":"Night Train (2024).en.srt","timing":{"offset_ms":0,"scale":1}, + "sync":{"id":"2","status":"running","trigger":"manual","phase":"analyzing","progress":0.4, + "confidence":null,"created_at":"2026-10-04T02:37:35.235Z","finished_at":null}}""", + ) + assertTrue(state.isSidecar) + assertNull(state.storedSubtitleId) + val job = state.sync!! + assertTrue(job.inProgress) + assertEquals("2", job.id) + assertEquals("manual", job.trigger) + assertEquals(40, syncProgressPercent(job)) + assertEquals("Listening to the audio…", syncPhaseLabel(job)) + assertEquals("Syncing… 40%", subtitleSyncStatusLabel(state.timing, job)) + } + + @Test + fun decodesAFinishedStoredSyncAndTheCapability() { + val state = PrairieJson.decodeFromString( + SubtitleSyncState.serializer(), + """{"key":"stored-9","media_file_id":"1","source":"downloaded","stored_subtitle_id":"9","language":"en", + "format":"srt","label":"release","timing":{"offset_ms":-3010,"scale":1}, + "sync":{"id":"5","status":"synced","trigger":"auto","confidence":1,"result":{"offset_ms":-3010,"scale":1}, + "created_at":"2026-10-04T02:37:35.235Z","finished_at":"2026-10-04T02:37:35.351Z"}}""", + ) + assertFalse(state.isSidecar) + assertEquals("9", state.storedSubtitleId) + assertNull(syncProgressPercent(state.sync)) + assertEquals("Synced −3.0 s", subtitleSyncStatusLabel(state.timing, state.sync)) + assertEquals( + SubtitleSyncResultLine("Synced to the audio: −3.0 s"), + subtitleSyncResultLine(state.timing, state.sync), + ) + + val capability = PrairieJson.decodeFromString( + SubtitleSyncCapability.serializer(), + """{"revision":"r","state":"available","allowed":true,"auto_sync":true,"external":true}""", + ) + assertTrue(capability.available) + assertTrue(capability.external) + // A server that predates sidecar sync omits `external`. + assertFalse( + PrairieJson.decodeFromString( + SubtitleSyncCapability.serializer(), + """{"revision":"r","state":"available","allowed":true,"auto_sync":true}""", + ).external, + ) + } + + @Test + fun aStoredDownloadCarriesItsAutomaticJob() { + val subtitle = DownloadedSubtitle( + id = 9, mediaFileId = 1, provider = "upload", language = "en", format = "srt", releaseName = "", + sync = SubtitleSyncJob(id = "7", status = SubtitleSyncJob.PENDING, trigger = "auto"), + ) + val state = subtitle.syncState() + assertEquals("stored-9", state.key) + assertEquals("1", state.mediaFileId) + assertEquals("upload", state.label) + assertEquals("7", state.sync?.id) + } + + @Test + fun describesProgressPhasesAndFailures() { + fun running(phase: String?, progress: Double?) = + SubtitleSyncJob(id = "1", status = SubtitleSyncJob.RUNNING, phase = phase, progress = progress) + assertEquals("Waiting to start…", syncPhaseLabel(running("queued", 0.0))) + assertEquals("Matching lines to speech…", syncPhaseLabel(running("matching", 0.9))) + assertEquals("Syncing…", subtitleSyncStatusLabel(SubtitleTiming(), running("queued", 0.0))) + assertEquals(100, syncProgressPercent(running("matching", 1.4))) + assertNull(syncPhaseLabel(SubtitleSyncJob(status = SubtitleSyncJob.FAILED))) + + assertEquals("The subtitle changed while it was syncing. Try again.", syncFailureMessage("subtitle_changed")) + assertEquals("This video has no audio Prairie can read.", syncFailureMessage("no_audio")) + assertEquals("The server is busy. Try again in a few minutes.", syncFailureMessage("unavailable")) + assertEquals("Sync failed. Try again.", syncFailureMessage("error")) + assertEquals("Sync failed. Try again.", syncFailureMessage(null)) + } + + @Test + fun resultLinesMatchTheWebPlayer() { + val identity = SubtitleTiming() + assertEquals( + SubtitleSyncResultLine("Doesn't match this video's audio; probably for another release.", warning = true), + subtitleSyncResultLine(identity, SubtitleSyncJob(status = SubtitleSyncJob.NO_MATCH)), + ) + assertEquals( + SubtitleSyncResultLine("This video has no audio Prairie can read.", warning = true), + subtitleSyncResultLine(identity, SubtitleSyncJob(status = SubtitleSyncJob.FAILED, failure = "no_audio")), + ) + assertEquals( + SubtitleSyncResultLine("Already matches the audio."), + subtitleSyncResultLine(identity, SubtitleSyncJob(status = SubtitleSyncJob.ALREADY_SYNCED)), + ) + // A synced subtitle that was reset afterwards has no result to show. + assertNull(subtitleSyncResultLine(identity, SubtitleSyncJob(status = SubtitleSyncJob.SYNCED))) + assertNull(subtitleSyncResultLine(identity, null)) + assertEquals("+0.5 s · 25→23.976 fps", describeSyncTiming(SubtitleTiming(500, 25.0 / 23.976))) + assertEquals("25→23.976 fps", describeSyncTiming(SubtitleTiming(0, 25.0 / 23.976))) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/watchtogether/WatchPartyFixtures.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/watchtogether/WatchPartyFixtures.kt new file mode 100644 index 0000000000..98c93df979 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/watchtogether/WatchPartyFixtures.kt @@ -0,0 +1,7 @@ +package org.prairieserver.prairie.model.watchtogether + +/** Reads one file under `watchtogether/v2/` from the test resources. See its SOURCE file. */ +expect fun readWatchPartyFixture(name: String): String + +/** Class-loader anchor for the Android actual. */ +internal object WatchPartyFixtureAnchor diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/watchtogether/WatchTogetherModelsSerializationTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/watchtogether/WatchTogetherModelsSerializationTest.kt index 1e24c6c68f..74bb1f6f11 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/watchtogether/WatchTogetherModelsSerializationTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/watchtogether/WatchTogetherModelsSerializationTest.kt @@ -231,12 +231,18 @@ class WatchTogetherModelsSerializationTest { // ---- Request models (encode → correct wire keys, omit nulls) -------------- @Test - fun `create request encodes selection_mode`() { + fun `create request encodes caller room_id and selection_mode`() { val out = json.encodeToString( CreateRoomRequest.serializer(), - CreateRoomRequest(selectionMode = "vote"), + CreateRoomRequest(roomId = "7b1b0a3c-0000-4000-8000-000000000001", selectionMode = "vote"), ) - assertEquals(setOf("selection_mode"), json.parseToJsonElement(out).jsonObject.keys) + val body = json.parseToJsonElement(out).jsonObject + assertEquals(setOf("room_id", "selection_mode"), body.keys) + } + + @Test + fun `create request defaults to host picks`() { + assertEquals("host_pick", CreateRoomRequest(roomId = "r").selectionMode) } @Test @@ -272,10 +278,10 @@ class WatchTogetherModelsSerializationTest { fun `add suggestion request encodes required fields and omits null optionals`() { val out = json.encodeToString( AddSuggestionRequest.serializer(), - AddSuggestionRequest(contentId = "c", contentType = "movie", title = "T"), + AddSuggestionRequest(suggestionId = "s-1", contentId = "c", contentType = "movie", title = "T"), ) assertEquals( - setOf("content_id", "content_type", "title"), + setOf("suggestion_id", "content_id", "content_type", "title"), json.parseToJsonElement(out).jsonObject.keys, ) } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/AccessChangeCursorTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/AccessChangeCursorTest.kt new file mode 100644 index 0000000000..f86bb9fe1c --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/AccessChangeCursorTest.kt @@ -0,0 +1,87 @@ +package org.prairieserver.prairie.network + +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * A ViewModel's cursor carries access changes across the times its screen is + * out of composition, so returning to the screen applies a change reported + * while another destination was in front. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class AccessChangeCursorTest { + + @Test + fun `the first collection does not refresh`() = runTest { + val signals = AccessChangeSignals { testScheduler.currentTime } + signals.reportAccessChanged() + val cursor = AccessChangeCursor() + + assertEquals(0, collectWhileVisible(signals, cursor) {}) + } + + @Test + fun `a change reported while away refreshes on return`() = runTest { + val signals = AccessChangeSignals { testScheduler.currentTime } + val cursor = AccessChangeCursor() + collectWhileVisible(signals, cursor) {} + + // The screen is out of composition (detail in front) when it arrives. + signals.reportAccessChanged() + + assertEquals(1, collectWhileVisible(signals, cursor) {}) + assertEquals(0, collectWhileVisible(signals, cursor) {}, "an applied change does not refresh twice") + } + + @Test + fun `the second socket's report of an applied change does not refresh on return`() = runTest { + val signals = AccessChangeSignals { testScheduler.currentTime } + val cursor = AccessChangeCursor() + val whileVisible = collectWhileVisible(signals, cursor) { + signals.reportAccessChanged() + } + assertEquals(1, whileVisible) + + // The other events socket reports the same change seconds later, while + // the screen is away. + testScheduler.advanceTimeBy(12_000L) + signals.reportAccessChanged() + + assertEquals(0, collectWhileVisible(signals, cursor) {}) + } + + @Test + fun `a later change while away refreshes on return`() = runTest { + val signals = AccessChangeSignals { testScheduler.currentTime } + val cursor = AccessChangeCursor() + collectWhileVisible(signals, cursor) { signals.reportAccessChanged() } + + testScheduler.advanceTimeBy(AccessChangeSignals.ACCESS_CHANGE_COALESCE_MS) + signals.reportAccessChanged() + + assertEquals(1, collectWhileVisible(signals, cursor) {}) + } + + /** Collects with [cursor] like a composed screen, runs [whileVisible], then leaves. */ + private fun TestScope.collectWhileVisible( + signals: AccessChangeSignals, + cursor: AccessChangeCursor, + whileVisible: () -> Unit, + ): Int { + var refreshes = 0 + val job = backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + signals.changes(cursor).collect { refreshes++ } + } + testScheduler.runCurrent() + whileVisible() + testScheduler.runCurrent() + job.cancel() + testScheduler.runCurrent() + return refreshes + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/PrairieAuthPluginFreshActiveRefreshTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/PrairieAuthPluginFreshActiveRefreshTest.kt new file mode 100644 index 0000000000..9b90f4dab3 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/PrairieAuthPluginFreshActiveRefreshTest.kt @@ -0,0 +1,71 @@ +package org.prairieserver.prairie.network + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.post +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * The active session's side of [freshPrairieAuth]: a TV approval from a token + * link, or from the TV's own pair screen, goes out on the active session + * rather than a pinned scope. When its expired token's refresh is refused + * with 503 `provider_unavailable`, the approval isn't sent with a bearer that + * would come back 401 and read as "sign in again"; it fails with the reason. + */ +class PrairieAuthPluginFreshActiveRefreshTest { + private suspend fun expiredSession() = TokenManagerImpl().apply { + setServerUrl("https://prairie.example") + saveTokens(accessToken = "stale", refreshToken = "refresh-1", expiresIn = 0) + } + + private fun client(tokens: TokenManager, paths: MutableList<String>) = HttpClient( + MockEngine { request -> + paths += request.url.encodedPath + if (request.url.encodedPath.endsWith("/auth/refresh")) { + respond( + """{"type":"https://prairieserver.org/docs/api/v2/problems/provider_unavailable","title":"Provider unavailable",""" + + """"status":503,"detail":"Try again later; the session stays valid."}""", + HttpStatusCode.ServiceUnavailable, + headersOf(HttpHeaders.ContentType, "application/problem+json"), + ) + } else { + respond("{}", HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + } + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + install(PrairieAuthPlugin) { tokenManager = tokens } + } + + @Test + fun aProviderOutageFailsAFreshRequestWithItsReason() = runTest { + val tokens = expiredSession() + val paths = mutableListOf<String>() + val thrown = assertFailsWith<PrairieAuthUnavailableException> { + client(tokens, paths).post("/api/v2/auth/device/approve") { freshPrairieAuth() } + } + assertTrue(PrairieAuthUnavailableException.isProviderUnavailable(thrown)) + assertEquals(listOf("/api/v2/auth/refresh"), paths, "the approval never went out") + assertEquals("stale", tokens.getAccessToken(), "the session is kept") + assertEquals("refresh-1", tokens.getRefreshToken()) + } + + @Test + fun anUnmarkedRequestStillSpendsTheBearer() = runTest { + val tokens = expiredSession() + val paths = mutableListOf<String>() + client(tokens, paths).post("/api/v2/progress") + assertEquals(listOf("/api/v2/auth/refresh", "/api/v2/progress"), paths) + assertEquals("stale", tokens.getAccessToken()) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/PrairieAuthPluginFreshPinnedRefreshTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/PrairieAuthPluginFreshPinnedRefreshTest.kt new file mode 100644 index 0000000000..2d65d8a4dd --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/PrairieAuthPluginFreshPinnedRefreshTest.kt @@ -0,0 +1,251 @@ +package org.prairieserver.prairie.network + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.post +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * A pinned request marked [freshPrairieAuth] (a phone approving a TV on a saved + * server, active or not) renews that scope's access token first when it is + * expiring, through the same refresh the 401 path uses. Other pinned requests + * keep the reactive behaviour. + */ +class PrairieAuthPluginFreshPinnedRefreshTest { + private val scope = AuthScopeSnapshot.profileless("cabin", "https://cabin.example", identityGeneration = 0L) + + private class Recorded(val path: String, val authorization: String?) + + private fun client(tokens: ScopeTokens, calls: MutableList<Recorded>) = HttpClient( + MockEngine { request -> + calls += Recorded(request.url.encodedPath, request.headers[HttpHeaders.Authorization]) + val json = headersOf(HttpHeaders.ContentType, "application/json") + if (request.url.encodedPath.endsWith("/auth/refresh")) { + respond("""{"access_token":"fresh","refresh_token":"refresh-2","expires_in":3600}""", HttpStatusCode.OK, json) + } else { + respond("{}", HttpStatusCode.OK, json) + } + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + install(PrairieAuthPlugin) { tokenManager = tokens } + } + + @Test + fun anExpiringScopeIsRenewedBeforeTheApproval() = runTest { + val tokens = ScopeTokens(expiring = true) + val calls = mutableListOf<Recorded>() + client(tokens, calls).post("/api/v2/auth/device/approve") { + authScope(scope) + freshPrairieAuth() + } + assertEquals(listOf("/api/v2/auth/refresh", "/api/v2/auth/device/approve"), calls.map { it.path }) + assertEquals(null, calls[0].authorization, "the refresh carries only its refresh token") + assertEquals("Bearer fresh", calls[1].authorization) + assertEquals("refresh-2", tokens.refresh, "the rotated pair is kept for that server") + assertEquals(0, tokens.activeWrites, "the active server's slot is untouched") + } + + @Test + fun aFreshScopeGoesStraightOut() = runTest { + val tokens = ScopeTokens(expiring = false) + val calls = mutableListOf<Recorded>() + client(tokens, calls).post("/api/v2/auth/device/approve") { + authScope(scope) + freshPrairieAuth() + } + assertEquals(listOf("/api/v2/auth/device/approve"), calls.map { it.path }) + assertEquals("Bearer stale", calls[0].authorization) + } + + @Test + fun aSingleAttemptRequestIsRenewedFirstAndSentOnce() = runTest { + val tokens = ScopeTokens(expiring = true) + val calls = mutableListOf<Recorded>() + client(tokens, calls).post("/api/v2/account/identities/link-complete") { + authScope(scope) + freshPrairieAuth() + singleAttempt() + } + assertEquals( + listOf("/api/v2/auth/refresh", "/api/v2/account/identities/link-complete"), + calls.map { it.path }, + ) + assertEquals("Bearer fresh", calls[1].authorization) + } + + @Test + fun aSingleAttemptRequestIsNotReplayedAfterA401() = runTest { + val tokens = ScopeTokens(expiring = false) + val calls = mutableListOf<Recorded>() + val client = HttpClient( + MockEngine { request -> + calls += Recorded(request.url.encodedPath, request.headers[HttpHeaders.Authorization]) + respond("{}", HttpStatusCode.Unauthorized, headersOf(HttpHeaders.ContentType, "application/json")) + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + install(PrairieAuthPlugin) { tokenManager = tokens } + } + client.post("/api/v2/account/identities/link-complete") { + authScope(scope) + freshPrairieAuth() + singleAttempt() + } + assertEquals(listOf("/api/v2/account/identities/link-complete"), calls.map { it.path }) + } + + /** + * The approver's refresh is refused with 503 `provider_unavailable` (the + * provider couldn't re-check the session under `fail_closed`) and its + * token has expired: the approval isn't sent with a bearer that would + * come back 401 and read as "sign in again". The session is kept. + */ + @Test + fun aProviderOutageOnTheApproversRefreshFailsWithItsReason() = runTest { + val tokens = ScopeTokens(expiring = true) + val calls = mutableListOf<Recorded>() + val client = HttpClient( + MockEngine { request -> + calls += Recorded(request.url.encodedPath, request.headers[HttpHeaders.Authorization]) + if (request.url.encodedPath.endsWith("/auth/refresh")) { + respond( + """{"type":"https://prairieserver.org/docs/api/v2/problems/provider_unavailable","title":"Provider unavailable",""" + + """"status":503,"detail":"Try again later; the session stays valid."}""", + HttpStatusCode.ServiceUnavailable, + headersOf(HttpHeaders.ContentType, "application/problem+json"), + ) + } else { + respond("{}", HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + } + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + install(PrairieAuthPlugin) { tokenManager = tokens } + } + val thrown = assertFailsWith<PrairieAuthUnavailableException> { + client.post("/api/v2/auth/device/approve") { + authScope(scope) + freshPrairieAuth() + } + } + assertTrue(PrairieAuthUnavailableException.isProviderUnavailable(thrown)) + assertEquals(listOf("/api/v2/auth/refresh"), calls.map { it.path }, "the approval never went out") + assertEquals("stale", tokens.access, "the session is kept") + assertEquals("refresh-1", tokens.refresh) + } + + @Test + fun anotherRefreshFailureStillSpendsTheBearer() = runTest { + val tokens = ScopeTokens(expiring = true) + val calls = mutableListOf<Recorded>() + val client = HttpClient( + MockEngine { request -> + calls += Recorded(request.url.encodedPath, request.headers[HttpHeaders.Authorization]) + if (request.url.encodedPath.endsWith("/auth/refresh")) { + respond("bad gateway", HttpStatusCode.BadGateway) + } else { + respond("{}", HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + } + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + install(PrairieAuthPlugin) { tokenManager = tokens } + } + client.post("/api/v2/auth/device/approve") { + authScope(scope) + freshPrairieAuth() + } + assertEquals(listOf("/api/v2/auth/refresh", "/api/v2/auth/device/approve"), calls.map { it.path }) + } + + /** + * The scope is signed out while its refresh is in flight, and the refresh + * fails: the approval isn't sent with the bearer captured before, which + * may still be valid for the account that left. + */ + @Test + fun aScopeSignedOutDuringItsRefreshSendsNothing() = runTest { + val tokens = ScopeTokens(expiring = true) + val calls = mutableListOf<Recorded>() + val client = HttpClient( + MockEngine { request -> + calls += Recorded(request.url.encodedPath, request.headers[HttpHeaders.Authorization]) + if (request.url.encodedPath.endsWith("/auth/refresh")) { + tokens.access = null + respond("bad gateway", HttpStatusCode.BadGateway) + } else { + respond("{}", HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + } + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + install(PrairieAuthPlugin) { tokenManager = tokens } + } + assertFailsWith<PrairieAuthUnavailableException> { + client.post("/api/v2/auth/device/approve") { + authScope(scope) + freshPrairieAuth() + } + } + assertEquals(listOf("/api/v2/auth/refresh"), calls.map { it.path }, "the approval never went out") + } + + @Test + fun unmarkedPinnedRequestsStayReactive() = runTest { + val tokens = ScopeTokens(expiring = true) + val calls = mutableListOf<Recorded>() + client(tokens, calls).post("/api/v2/progress") { authScope(scope) } + assertEquals(listOf("/api/v2/progress"), calls.map { it.path }) + } + + /** One saved, non-active server ("cabin") whose token is or isn't expiring. */ + private class ScopeTokens(private val expiring: Boolean) : TokenManager { + var access: String? = "stale" + var refresh = "refresh-1" + var activeWrites = 0 + + override suspend fun accessTokenExpiresWithin(scope: AuthScopeSnapshot, marginMs: Long) = + expiring && access == "stale" + override suspend fun getAccessTokenForScope(scope: AuthScopeSnapshot): String? = access + override suspend fun getRefreshTokenForScope(scope: AuthScopeSnapshot): String = refresh + override suspend fun saveTokensForScope( + scope: AuthScopeSnapshot, + accessToken: String, + refreshToken: String, + expiresIn: Long, + ) { + access = accessToken + refresh = refreshToken + } + + override val sessionExpired = MutableSharedFlow<Unit>() + override suspend fun getAccessToken(): String = "home-access" + override suspend fun getRefreshToken(): String = "home-refresh" + override suspend fun saveTokens(accessToken: String, refreshToken: String, expiresIn: Long) { + activeWrites++ + } + override suspend fun clearTokens() = Unit + override suspend fun invalidateSession() = Unit + override suspend fun getProfileId(): String? = null + override suspend fun setProfileId(profileId: String?) = Unit + override suspend fun getProfileToken(): String? = null + override suspend fun setProfileToken(token: String?) = Unit + override suspend fun getServerUrl(): String = "https://home.example" + override suspend fun setServerUrl(url: String) = Unit + override suspend fun getCurrentServerId(): String = "home" + override suspend fun switchActiveServer(serverId: String?) = Unit + override suspend fun signOutCurrentServer() = Unit + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/PrairieAuthPluginRefreshFailureTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/PrairieAuthPluginRefreshFailureTest.kt index 45757d35a5..13e4d69093 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/PrairieAuthPluginRefreshFailureTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/PrairieAuthPluginRefreshFailureTest.kt @@ -36,6 +36,69 @@ class PrairieAuthPluginRefreshFailureTest { assertEquals("refresh-token", tokenManager.getRefreshToken()) } + /** + * External sign-in (B1): a refresh the provider couldn't re-check answers + * 503 `provider_unavailable`. The session stays, and the next request's + * refresh asks again instead of signing the person out. + */ + @Test + fun providerUnavailableRefreshKeepsSessionAndRetriesLater() = runTest { + val tokenManager = activeTokenManager() + val paths = mutableListOf<String>() + var refreshes = 0 + val client = HttpClient( + MockEngine { request -> + paths += request.url.encodedPath + when { + request.url.encodedPath.endsWith("/auth/refresh") -> { + refreshes += 1 + if (refreshes == 1) { + respond( + content = """{"type":"https://prairieserver.org/docs/api/v2/problems/provider_unavailable",""" + + """"title":"Provider unavailable","status":503,"detail":"Try again later; the session stays valid."}""", + status = HttpStatusCode.ServiceUnavailable, + headers = headersOf(HttpHeaders.ContentType, "application/problem+json"), + ) + } else { + respond( + content = """{"access_token":"fresh-access","refresh_token":"fresh-refresh","expires_in":3600}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + } + request.headers[HttpHeaders.Authorization] == "Bearer fresh-access" -> + respond("{}", HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + else -> respond( + content = """{"error":"unauthorized","message":"expired"}""", + status = HttpStatusCode.Unauthorized, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + install(PrairieAuthPlugin) { this.tokenManager = tokenManager } + } + + val first = client.get("/api/v2/account/me") + assertEquals(HttpStatusCode.Unauthorized, first.status) + assertEquals("expired-access", tokenManager.getAccessToken(), "the session is kept") + assertEquals("refresh-token", tokenManager.getRefreshToken()) + + val second = client.get("/api/v2/account/me") + assertEquals(HttpStatusCode.OK, second.status) + assertEquals(2, refreshes, "the next request refreshes again") + assertEquals("fresh-access", tokenManager.getAccessToken()) + assertEquals( + listOf( + "/api/v2/account/me", "/api/v2/auth/refresh", + "/api/v2/account/me", "/api/v2/auth/refresh", "/api/v2/account/me", + ), + paths, + ) + } + @Test fun unauthorizedRefreshFailureInvalidatesActiveSession() = runTest { val tokenManager = activeTokenManager() diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/PrairieAuthPluginTokenRefreshRequiredTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/PrairieAuthPluginTokenRefreshRequiredTest.kt new file mode 100644 index 0000000000..1f79e95e13 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/PrairieAuthPluginTokenRefreshRequiredTest.kt @@ -0,0 +1,124 @@ +package org.prairieserver.prairie.network + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.get +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * After an administrator changes an account's role, the server rejects access + * tokens minted before the change with 401 `token_refresh_required`. The refresh + * token still works, so the client must refresh quietly and retry once — never + * end the session. + */ +class PrairieAuthPluginTokenRefreshRequiredTest { + + private val tokenRefreshRequired = + """{"type":"https://prairieserver.org/problems/token_refresh_required","title":"Unauthorized","status":401,"detail":"Refresh the access token."}""" + + @Test + fun `a role change refreshes and retries without signing out`() = runTest { + val tokens = TokenManagerImpl().apply { + setServerUrl("https://prairie.example") + saveTokens("pre-change-access", "refresh-token", 3_600) + setProfileIdentity("kid", "pin-token") + } + var sessionExpiredCount = 0 + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + tokens.sessionExpired.collect { sessionExpiredCount++ } + } + val sent = mutableListOf<Pair<String, String?>>() + val client = HttpClient( + MockEngine { request -> + sent += request.url.encodedPath to request.headers[HttpHeaders.Authorization] + when { + request.url.encodedPath.endsWith("/auth/refresh") -> respond( + content = """{"access_token":"post-change-access","refresh_token":"rotated-refresh","expires_in":3600}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + request.headers[HttpHeaders.Authorization] == "Bearer pre-change-access" -> respond( + content = tokenRefreshRequired, + status = HttpStatusCode.Unauthorized, + headers = headersOf(HttpHeaders.ContentType, "application/problem+json"), + ) + else -> respond( + content = "{}", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + install(PrairieAuthPlugin) { tokenManager = tokens } + } + + val response = client.get("/api/v2/home/sections") + + assertEquals(HttpStatusCode.OK, response.status) + assertEquals( + listOf( + "/api/v2/home/sections" to "Bearer pre-change-access", + "/api/v2/auth/refresh" to null, + "/api/v2/home/sections" to "Bearer post-change-access", + ), + sent, + ) + assertEquals("post-change-access", tokens.getAccessToken()) + assertEquals("rotated-refresh", tokens.getRefreshToken()) + assertEquals("kid", tokens.getProfileId(), "the profile selection survives") + assertEquals(0, sessionExpiredCount, "the user must not be sent to sign-in") + } + + @Test + fun `a pinned background request refreshes its own scope on a role change`() = runTest { + val scope = AuthScopeSnapshot( + serverId = "server-1", + profileId = "kid", + serverUrl = "https://prairie.example", + profileToken = "pin-token", + ) + val tokens = TokenManagerImpl().apply { + setServerUrl("https://prairie.example") + saveTokens("pre-change-access", "refresh-token", 3_600) + } + val client = HttpClient( + MockEngine { request -> + when { + request.url.encodedPath.endsWith("/auth/refresh") -> respond( + content = """{"access_token":"post-change-access","refresh_token":"rotated-refresh","expires_in":3600}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + request.headers[HttpHeaders.Authorization] == "Bearer pre-change-access" -> respond( + content = tokenRefreshRequired, + status = HttpStatusCode.Unauthorized, + headers = headersOf(HttpHeaders.ContentType, "application/problem+json"), + ) + else -> respond("{}", HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + } + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + install(PrairieAuthPlugin) { tokenManager = tokens } + } + + val response = client.get("https://prairie.example/api/v2/playback/progress") { authScope(scope) } + + assertEquals(HttpStatusCode.OK, response.status) + assertEquals("post-change-access", tokens.getAccessToken()) + assertTrue(!tokens.getRefreshToken().isNullOrBlank()) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/RoomFrameDecoderTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/RoomFrameDecoderTest.kt index d650dcf975..223b62393d 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/RoomFrameDecoderTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/RoomFrameDecoderTest.kt @@ -97,13 +97,40 @@ class RoomFrameDecoderTest { } @Test - fun `snapshot with malformed room payload returns null`() { - // room present but missing required room_id → decode fails → null, no throw. - assertNull(decodeRoomFrame(json, """{"type":"snapshot","room":{"phase":"lobby"}}""")) + fun `snapshot with malformed room payload is reported, not thrown`() { + // room present but missing required room_id: the owner reconciles by reading the room. + assertEquals( + RoomRealtimeEvent.Malformed("snapshot"), + decodeRoomFrame(json, """{"type":"snapshot","room":{"phase":"lobby"}}"""), + ) } @Test - fun `transport_command missing command returns null`() { - assertNull(decodeRoomFrame(json, """{"type":"transport_command"}""")) + fun `transport_command with an unreadable execute_at is reported as malformed`() { + // Running it at once would skip the room's schedule; reconciling is safe. + val raw = """{"type":"transport_command","command":{"command_id":"cmd-1","action":"seek", + "position_seconds":42.0,"execute_at":"soon","playback_state":"playing"}}""" + assertEquals(RoomRealtimeEvent.Malformed("transport_command"), decodeRoomFrame(json, raw)) + } + + @Test + fun `transport_command with an unknown action is reported as malformed`() { + // The binding would treat it as advancing playback; reconciling is safe. + val raw = """{"type":"transport_command","command":{"command_id":"cmd-1","action":"rewind", + "position_seconds":42.0,"execute_at":"2026-06-12T09:30:00Z","playback_state":"playing"}}""" + assertEquals(RoomRealtimeEvent.Malformed("transport_command"), decodeRoomFrame(json, raw)) + } + + @Test + fun `transport_command without a playback_state is reported as malformed`() { + // Play and seek decide whether to run or hold from this state. + val raw = """{"type":"transport_command","command":{"command_id":"cmd-1","action":"seek", + "position_seconds":42.0,"execute_at":"2026-06-12T09:30:00Z"}}""" + assertEquals(RoomRealtimeEvent.Malformed("transport_command"), decodeRoomFrame(json, raw)) + } + + @Test + fun `transport_command missing command is reported as malformed`() { + assertEquals(RoomRealtimeEvent.Malformed("transport_command"), decodeRoomFrame(json, """{"type":"transport_command"}""")) } } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/StaleProfileDetectionTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/StaleProfileDetectionTest.kt new file mode 100644 index 0000000000..b4511ab500 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/StaleProfileDetectionTest.kt @@ -0,0 +1,147 @@ +package org.prairieserver.prairie.network + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.request.get +import io.ktor.client.request.patch +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.prairieserver.prairie.network.apiv2.ApiV2Gate +import org.prairieserver.prairie.network.apiv2.safeApiV2Call +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * The v2 error decoder reports a 403 `profile_verification_required` together + * with the profile identity the refused request presented, so the recovery can + * tell a stale token on the active profile from everything else. + */ +class StaleProfileDetectionTest { + + private fun problem(code: String) = + """{"type":"https://prairieserver.org/problems/$code","title":"Forbidden","status":403,"detail":"x"}""" + + private suspend fun tokens() = TokenManagerImpl().apply { + setServerUrl("https://prairie.example") + saveTokens("access", "refresh", 3_600) + setProfileIdentity("kid", "pin-token") + } + + private fun client( + tokens: TokenManager, + signals: AccessChangeSignals?, + code: String = PROFILE_VERIFICATION_REQUIRED, + ) = HttpClient( + MockEngine { + respond( + content = problem(code), + status = HttpStatusCode.Forbidden, + headers = headersOf(HttpHeaders.ContentType, "application/problem+json"), + ) + }, + ) { + install(PrairieAuthPlugin) { + tokenManager = tokens + accessChangeSignals = signals + } + } + + @Test + fun `a stale profile refusal is reported with the presented identity`() = runTest { + val signals = AccessChangeSignals() + val reports = mutableListOf<StaleProfileReport>() + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + signals.staleProfileReports.collect { reports += it } + } + val client = client(tokens(), signals) + + val result = safeApiV2Call<Unit>(ApiV2Gate.Unrestricted) { client.get("/api/v2/home/sections") } + testScheduler.runCurrent() + + val error = assertIs<ApiResult.Error>(result) + assertEquals(403, error.code) + assertEquals(PROFILE_VERIFICATION_REQUIRED, error.error, "callers still see the error") + val report = reports.single() + assertEquals("kid", report.profileId) + assertEquals("pin-token", report.profileToken) + assertTrue(report.requestUrl.startsWith("https://prairie.example/")) + } + + @Test + fun `other refusals and household management are not reported`() = runTest { + val signals = AccessChangeSignals() + val reports = mutableListOf<StaleProfileReport>() + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + signals.staleProfileReports.collect { reports += it } + } + val tokens = tokens() + + val denied = client(tokens, signals, code = "permission_denied") + safeApiV2Call<Unit>(ApiV2Gate.Unrestricted) { denied.get("/api/v2/home/sections") } + // A non-primary profile editing the household gets the same problem + // type; that is a missing permission, not a stale token. + val stale = client(tokens, signals) + safeApiV2Call<Unit>(ApiV2Gate.Unrestricted) { stale.patch("/api/v2/profiles/parent") } + testScheduler.runCurrent() + + assertTrue(reports.isEmpty()) + } + + @Test + fun `a request without a profile is not reported`() = runTest { + val signals = AccessChangeSignals() + val reports = mutableListOf<StaleProfileReport>() + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + signals.staleProfileReports.collect { reports += it } + } + val tokens = tokens().apply { setProfileIdentity(null, null) } + + safeApiV2Call<Unit>(ApiV2Gate.Unrestricted) { client(tokens, signals).get("/api/v2/home/sections") } + testScheduler.runCurrent() + + assertTrue(reports.isEmpty()) + } + + @Test + fun `a client without signals keeps the plain error`() = runTest { + val result = safeApiV2Call<Unit>(ApiV2Gate.Unrestricted) { + client(tokens(), signals = null).get("/api/v2/home/sections") + } + + assertEquals(PROFILE_VERIFICATION_REQUIRED, assertIs<ApiResult.Error>(result).error) + } + + @Test + fun `household management is told apart from profile reads`() { + assertTrue(isHouseholdManagementRequest(HttpMethod.Patch, "/api/v2/profiles/p1")) + assertTrue(isHouseholdManagementRequest(HttpMethod.Post, "/api/v2/profiles")) + assertTrue(isHouseholdManagementRequest(HttpMethod.Delete, "/api/v2/profiles/p1/avatar")) + assertTrue(isHouseholdManagementRequest(HttpMethod.Get, "/api/v2/profiles/household/sessions")) + // The picker's list and PIN entry fail on a stale token and must recover. + assertFalse(isHouseholdManagementRequest(HttpMethod.Get, "/api/v2/profiles")) + assertFalse(isHouseholdManagementRequest(HttpMethod.Post, "/api/v2/profiles/p1/verify-pin")) + assertFalse(isHouseholdManagementRequest(HttpMethod.Get, "/api/v2/home/sections")) + } + + @Test + fun `only an authoritative refusal counts as lost access`() { + assertTrue(ApiResult.Error(404, "not_found", "").isAccessRefusal()) + assertTrue(ApiResult.Error(410, "gone", "").isAccessRefusal()) + assertTrue(ApiResult.Error(403, "forbidden", "").isAccessRefusal()) + // A stale profile proof is recovered by re-verifying, not by hiding the title. + assertFalse(ApiResult.Error(403, PROFILE_VERIFICATION_REQUIRED, "").isAccessRefusal()) + assertFalse(ApiResult.Error(401, "unauthorized", "").isAccessRefusal()) + assertFalse(ApiResult.Error(503, "unavailable", "").isAccessRefusal()) + assertFalse(ApiResult.Error(0, "identity_changed", "").isAccessRefusal()) + assertFalse(ApiResult.NetworkError(RuntimeException("offline")).isAccessRefusal()) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/WatchTogetherRealtimeClientTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/WatchTogetherRealtimeClientTest.kt index c2ac90d79b..0c9022d622 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/WatchTogetherRealtimeClientTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/WatchTogetherRealtimeClientTest.kt @@ -1,6 +1,7 @@ package org.prairieserver.prairie.network import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.isActive import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.cancelAndJoin @@ -178,6 +179,12 @@ class WatchTogetherRealtimeClientTest { connection.sendFailure = IllegalStateException("closed") assertFalse(client.ping("2026-07-27T10:00:01Z")) + // A socket that dies under a pending send cancels its outgoing + // channel. That is a failed send, not the caller's cancellation. + connection.sendFailure = CancellationException("outgoing channel cancelled") + assertFalse(client.stateReport("session-1", 16.5, false)) + assertFalse(client.transportRequestOnConnection(client.currentConnectionId() ?: -1L, "play", null, false)) + assertTrue(coroutineContext.isActive) job.cancelAndJoin() assertFalse(client.attachSession("session-after-close")) } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/ExternalSignInApiTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/ExternalSignInApiTest.kt new file mode 100644 index 0000000000..bb1efa41e7 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/ExternalSignInApiTest.kt @@ -0,0 +1,556 @@ +package org.prairieserver.prairie.network.api + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.MockRequestHandleScope +import io.ktor.client.engine.mock.respond +import io.ktor.client.request.HttpRequestData +import io.ktor.client.request.HttpResponseData +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.content.TextContent +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.prairieserver.prairie.model.auth.AccountIdentities +import org.prairieserver.prairie.model.auth.AccountIdentity +import org.prairieserver.prairie.model.auth.LoginResponse +import org.prairieserver.prairie.model.auth.NetworkIdentity +import org.prairieserver.prairie.model.auth.NetworkSignInFailure +import org.prairieserver.prairie.model.auth.OAuthHandshakeCapabilities +import org.prairieserver.prairie.model.auth.PasswordLoginFailure +import org.prairieserver.prairie.model.auth.SignInOptions +import org.prairieserver.prairie.model.auth.SignInProvider +import org.prairieserver.prairie.model.auth.SignInProviders +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeAttributeKey +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.FreshPrairieAuthAttributeKey +import org.prairieserver.prairie.network.PrairieJson +import org.prairieserver.prairie.network.SingleAttemptAttributeKey +import org.prairieserver.prairie.network.SkipPrairieAuthAttributeKey +import org.prairieserver.prairie.network.apiv2.ApiV2Gate +import org.prairieserver.prairie.repository.ExternalSignInRepository +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * External sign-in on the wire: what the apps send to prairie-server's + * `listAuthProviders`, `completeOAuthLogin`, `signInWithNetworkIdentity`, + * the identity operations and the two password-confirmed links + * (`linkAccountIdentityWithCredentials`, `linkAccountIdentityWithNetwork`), + * and how each refusal comes back. + */ +class ExternalSignInApiTest { + private val scope = AuthScopeSnapshot("server-a", "p1", "https://prairie.example.test", null, identityGeneration = 1) + + private fun api(handler: suspend MockRequestHandleScope.(HttpRequestData) -> HttpResponseData): Pair<HttpClient, ExternalSignInApi> { + val client = HttpClient(MockEngine(handler)) { install(ContentNegotiation) { json(PrairieJson) } } + return client to DefaultExternalSignInApi(client, ApiV2Gate.Unrestricted) + } + + private fun MockRequestHandleScope.json(body: String, status: HttpStatusCode = HttpStatusCode.OK) = + respond(body, status, headersOf(HttpHeaders.ContentType, "application/json")) + + private fun MockRequestHandleScope.problem(code: String, status: Int, location: String? = null) = respond( + buildString { + append("""{"type":"https://prairieserver.org/docs/api/v2/problems/$code","title":"x","status":$status,"detail":"d"""") + if (location != null) append(""","errors":[{"location":"$location","code":"invalid","detail":"x"}]""") + append("}") + }, + HttpStatusCode.fromValue(status), + headersOf(HttpHeaders.ContentType, "application/problem+json"), + ) + + private fun HttpRequestData.jsonBody() = Json.parseToJsonElement((body as TextContent).text).jsonObject + + @Test + fun providersAndCapabilitiesAreReadUnauthenticatedAtTheGivenServer() = runTest { + val paths = mutableListOf<String>() + val (client, api) = api { request -> + paths += request.url.toString() + assertEquals(true, request.attributes.getOrNull(SkipPrairieAuthAttributeKey)) + when (request.url.encodedPath) { + "/api/v2/auth/providers" -> json("""{"items":[{"id":"local","display_name":"Prairie","mode":"credentials","default":true}],"password_login":true}""") + else -> json("""{"state":"available","available":true,"native":true,"linking":true,"provider_logout":false,"revision":"r"}""") + } + } + try { + val providers = assertIs<ApiResult.Success<SignInProviders>>(api.listProviders("https://lan.example:8096/")) + assertEquals("local", providers.data.providers.single().id) + val handshake = assertIs<ApiResult.Success<OAuthHandshakeCapabilities>>(api.oauthCapabilities("https://lan.example:8096")) + assertTrue(handshake.data.native) + assertEquals( + listOf("https://lan.example:8096/api/v2/auth/providers", "https://lan.example:8096/api/v2/auth/oauth/capabilities"), + paths, + ) + } finally { client.close() } + } + + @Test + fun nativeCompletionSendsTheVerifierOnceAndReturnsTheUser() = runTest { + val (client, api) = api { request -> + assertEquals(HttpMethod.Post, request.method) + assertEquals("https://prairie.example.test/api/v2/auth/oauth/complete", request.url.toString()) + assertEquals(true, request.attributes.getOrNull(SkipPrairieAuthAttributeKey)) + assertEquals(true, request.attributes.getOrNull(SingleAttemptAttributeKey)) + val body = request.jsonBody() + assertEquals("one-time", body["code"]?.jsonPrimitive?.content) + assertEquals("v".repeat(43), body["code_verifier"]?.jsonPrimitive?.content) + json( + """{"access_token":"acc","refresh_token":"ref","expires_in":3600,"next":"/", + "user":{"id":"1","username":"alice","email":"a@example.test","role":"user"}}""", + ) + } + try { + val login = assertIs<ApiResult.Success<*>>(api.completeOAuthLogin("https://prairie.example.test", "one-time", "v".repeat(43))) + assertEquals("alice", (login.data as org.prairieserver.prairie.model.auth.LoginResponse).user.username) + } finally { client.close() } + } + + @Test + fun nativeCompletionRefusalsKeepTheirProblemCodes() = runTest { + for ((code, status) in listOf("invalid_grant" to 400, "invalid_token" to 401)) { + val (client, api) = api { problem(code, status) } + try { + val error = assertIs<ApiResult.Error>(api.completeOAuthLogin("https://prairie.example.test", "c", "v".repeat(43))) + assertEquals(status, error.code) + assertEquals(code, error.error) + } finally { client.close() } + } + } + + /** A network provider is listed only over its own network, with the owner of the asking device. */ + @Test + fun aNetworkProviderIsReadWithItsSignInPathAndTheDevicesOwner() = runTest { + val (client, api) = api { + json( + """{"items":[{"id":"local","display_name":"Prairie account","mode":"credentials","default":true}, + {"id":"plugin:5:tailscale","display_name":"Tailscale","mode":"network","default":false, + "installation_id":"5","network_sign_in_path":"/api/v2/auth/network/5/sign-in", + "network_identity":{"display_name":"Alice Example","username":"alice@example.test"}}], + "password_login":true}""", + ) + } + try { + val listed = assertIs<ApiResult.Success<SignInProviders>>(api.listProviders("https://prairie.tailnet.ts.net")).data + val network = listed.providers[1] + assertEquals(SignInProvider.Mode.Network, network.mode) + assertEquals("Tailscale", network.displayName) + assertEquals("5", network.installationId) + assertEquals("/api/v2/auth/network/5/sign-in", network.networkSignInPath) + assertEquals(NetworkIdentity("Alice Example", "alice@example.test"), network.networkIdentity) + assertEquals("Alice Example", network.networkIdentity?.label) + assertFalse(network.isDefault) + assertNull(network.nativeStartPath) + + val options = SignInOptions.of(listed, OAuthHandshakeCapabilities.None) + assertEquals(network, options.networkProvider) + assertTrue(options.showPasswordForm, "the network provider leaves the password form alone") + assertNull(options.directoryProvider, "a network provider is not a directory") + assertTrue(options.oauthProviders.isEmpty()) + } finally { client.close() } + } + + @Test + fun theOwnersLabelFallsBackToTheUsernameAndThenToNothing() { + assertEquals("alice@example.test", NetworkIdentity("", "alice@example.test").label) + assertEquals("alice@example.test", NetworkIdentity(" ", "alice@example.test").label) + assertNull(NetworkIdentity("", "").label, "the button then names the provider instead") + } + + /** + * The sign-in posts `{}` as JSON (the server answers 415 without it) to + * the listed path on the app's own saved base, which may carry a reverse + * proxy's prefix, and lands the same token pair as `login`. + */ + @Test + fun networkSignInPostsAnEmptyJsonObjectBelowTheSavedBase() = runTest { + val (client, api) = api { request -> + assertEquals(HttpMethod.Post, request.method) + assertEquals("https://prairie.tailnet.ts.net/prairie/api/v2/auth/network/5/sign-in", request.url.toString()) + assertEquals(true, request.attributes.getOrNull(SkipPrairieAuthAttributeKey), "no bearer: this is a sign-in") + assertEquals(true, request.attributes.getOrNull(SingleAttemptAttributeKey), "never retried") + assertNull(request.headers[HttpHeaders.Authorization]) + assertEquals(ContentType.Application.Json, request.body.contentType?.withoutParameters()) + assertEquals("{}", (request.body as TextContent).text) + json( + """{"access_token":"acc","refresh_token":"ref","expires_in":3600, + "user":{"id":"1","username":"laura","email":"laura@example.test","role":"user"}}""", + ) + } + try { + val login = assertIs<ApiResult.Success<LoginResponse>>( + api.signInWithNetworkIdentity("https://prairie.tailnet.ts.net/prairie/", "/api/v2/auth/network/5/sign-in"), + ).data + assertEquals("acc", login.accessToken) + assertEquals("ref", login.refreshToken) + assertEquals(3600L, login.expiresIn) + assertEquals("laura", login.user.username) + } finally { client.close() } + } + + /** Discovery keeps only the listed route, so the sign-in posts nowhere else. */ + @Test + fun networkSignInGoesNowhereButTheListedRoute() { + listOf( + "//evil.example.test/api/v2/auth/network/5/sign-in", + "https://evil.example.test/api/v2/auth/network/5/sign-in", + "/api/v2/auth/login", + "/api/v2/auth/network/5/sign-in/../../login", + "", + null, + ).forEach { path -> assertNull(networkSignInPath(path), path) } + assertEquals("/api/v2/auth/network/5/sign-in", networkSignInPath("/prairie/api/v2/auth/network/5/sign-in")) + } + + @Test + fun networkSignInRefusalsKeepTheirProblemCodes() = runTest { + val cases = listOf( + "network_identity_required" to 403 to NetworkSignInFailure.NetworkIdentityRequired, + "not_permitted" to 403 to NetworkSignInFailure.NotPermitted, + "account_required" to 403 to NetworkSignInFailure.AccountRequired, + "permission_denied" to 403 to NetworkSignInFailure.AccountDisabled, + "email_in_use" to 409 to NetworkSignInFailure.EmailInUse, + "identity_linked_elsewhere" to 409 to NetworkSignInFailure.IdentityLinkedElsewhere, + "not_found" to 404 to NetworkSignInFailure.NotFound, + "provider_unavailable" to 503 to NetworkSignInFailure.ProviderUnavailable, + "rate_limited" to 429 to NetworkSignInFailure.RateLimited, + ) + for ((problem, failure) in cases) { + val (code, status) = problem + val (client, api) = api { problem(code, status) } + try { + val error = assertIs<ApiResult.Error>(api.signInWithNetworkIdentity("https://prairie.example.test", "/api/v2/auth/network/5/sign-in")) + assertEquals(status, error.code, code) + assertEquals(code, error.error, code) + assertEquals(failure, NetworkSignInFailure.of(error.code, error.error), code) + } finally { client.close() } + } + // By status when the problem code is unknown. + assertEquals(NetworkSignInFailure.RateLimited, NetworkSignInFailure.of(429, "")) + assertEquals(NetworkSignInFailure.ProviderUnavailable, NetworkSignInFailure.of(503, "")) + assertEquals(NetworkSignInFailure.NotFound, NetworkSignInFailure.of(404, "")) + assertEquals(NetworkSignInFailure.Other, NetworkSignInFailure.of(403, "")) + assertEquals(NetworkSignInFailure.Other, NetworkSignInFailure.of(500, "internal")) + } + + @Test + fun linkTicketConfirmsThePasswordThroughTheAccountScope() = runTest { + val (client, api) = api { request -> + assertEquals("/api/v2/account/identities/link-ticket", request.url.encodedPath) + assertEquals(scope, request.attributes.getOrNull(AuthScopeAttributeKey)) + assertEquals(true, request.attributes.getOrNull(FreshPrairieAuthAttributeKey)) + assertEquals(true, request.attributes.getOrNull(SingleAttemptAttributeKey)) + val body = request.jsonBody() + assertEquals("3", body["installation_id"]?.jsonPrimitive?.content) + if (body["password"]?.jsonPrimitive?.content == "right") { + json("""{"ticket":"t-123","expires_at":"2026-01-02T03:09:05.678Z"}""") + } else { + problem("validation_failed", 422, "body.password") + } + } + try { + val ticket = assertIs<ApiResult.Success<*>>(api.createLinkTicket(scope, "3", "right")) + assertFalse(ticket.data.toString().contains("t-123"), "the ticket stays out of logs") + val wrong = assertIs<ApiResult.Error>(api.createLinkTicket(scope, "3", "wrong")) + assertEquals(DefaultExternalSignInApi.WRONG_PASSWORD, wrong.error) + } finally { client.close() } + } + + @Test + fun linkWithCredentialsSendsAllFourMembersAndReadsTheCreatedIdentity() = runTest { + val (client, api) = api { request -> + assertEquals(HttpMethod.Post, request.method) + assertEquals("/api/v2/account/identities/link-credentials", request.url.encodedPath) + assertEquals(scope, request.attributes.getOrNull(AuthScopeAttributeKey)) + assertEquals(true, request.attributes.getOrNull(SingleAttemptAttributeKey)) + val body = request.jsonBody() + assertEquals(setOf("installation_id", "password", "username", "directory_password"), body.keys) + assertEquals("6", body["installation_id"]?.jsonPrimitive?.content) + assertEquals("local-pw", body["password"]?.jsonPrimitive?.content) + assertEquals("alice", body["username"]?.jsonPrimitive?.content) + assertEquals("dir-pw", body["directory_password"]?.jsonPrimitive?.content) + respond( + """{"id":"9","installation_id":"6","provider_id":"plugin:6:ldap","provider_name":"Directory", + "username":"alice","email":"","display_name":"Alice","linked_at":"2026-01-02T03:04:05.678Z", + "last_sign_in_at":null}""", + HttpStatusCode.Created, + headersOf(HttpHeaders.ContentType to listOf("application/json"), HttpHeaders.Location to listOf("/api/v2/account/identities")), + ) + } + try { + val linked = assertIs<ApiResult.Success<AccountIdentity>>(api.linkWithCredentials(scope, "6", "local-pw", "alice", "dir-pw")) + assertEquals("Directory", linked.data.providerName) + assertNull(linked.data.lastSignInAt) + } finally { client.close() } + } + + @Test + fun linkWithCredentialsRefusalsAreDistinguishable() = runTest { + val cases = listOf( + Triple("validation_failed", 422, "body.password") to DefaultExternalSignInApi.WRONG_PASSWORD, + Triple("validation_failed", 422, "body.directory_password") to DefaultExternalSignInApi.DIRECTORY_REFUSED, + Triple("local_password_required", 409, null) to "local_password_required", + Triple("not_permitted", 403, null) to "not_permitted", + Triple("account_disabled", 403, null) to "account_disabled", + Triple("password_expired", 403, null) to "password_expired", + Triple("permission_denied", 403, null) to "permission_denied", + Triple("identity_linked_elsewhere", 409, null) to "identity_linked_elsewhere", + Triple("already_linked", 409, null) to "already_linked", + Triple("not_found", 404, null) to "not_found", + Triple("provider_unavailable", 503, null) to "provider_unavailable", + Triple("rate_limited", 429, null) to "rate_limited", + ) + for ((problem, expected) in cases) { + val (code, status, location) = problem + val (client, api) = api { problem(code, status, location) } + try { + val error = assertIs<ApiResult.Error>(api.linkWithCredentials(scope, "6", "a", "b", "c")) + assertEquals(status, error.code, code) + assertEquals(expected, error.error, code) + } finally { client.close() } + } + // A success other than 201 is not an identity. + val (client, api) = api { json("{}") } + try { + assertEquals("invalid_response", assertIs<ApiResult.Error>(api.linkWithCredentials(scope, "6", "a", "b", "c")).error) + } finally { client.close() } + } + + /** Network linking asks only for the account's own password: the device says who the person is. */ + @Test + fun linkWithNetworkSendsTheInstallationAndPasswordOnly() = runTest { + val (client, api) = api { request -> + assertEquals(HttpMethod.Post, request.method) + assertEquals("/api/v2/account/identities/link-network", request.url.encodedPath) + assertEquals(scope, request.attributes.getOrNull(AuthScopeAttributeKey)) + assertEquals(true, request.attributes.getOrNull(FreshPrairieAuthAttributeKey)) + assertEquals(true, request.attributes.getOrNull(SingleAttemptAttributeKey)) + val body = request.jsonBody() + assertEquals(setOf("installation_id", "password"), body.keys) + assertEquals("5", body["installation_id"]?.jsonPrimitive?.content) + if (body["password"]?.jsonPrimitive?.content != "local-pw") return@api problem("validation_failed", 422, "body.password") + respond( + """{"id":"8","installation_id":"5","provider_id":"plugin:5:tailscale","provider_name":"Tailscale", + "username":"alice@example.test","email":"alice@example.test","display_name":"Alice Example", + "linked_at":"2026-01-02T03:04:05.000Z","last_sign_in_at":null,"last_checked_at":"2026-01-03T16:07:08.000Z"}""", + HttpStatusCode.Created, + headersOf(HttpHeaders.ContentType to listOf("application/json"), HttpHeaders.Location to listOf("/api/v2/account/identities")), + ) + } + try { + val linked = assertIs<ApiResult.Success<AccountIdentity>>(api.linkWithNetwork(scope, "5", "local-pw")).data + assertEquals("Tailscale", linked.providerName) + assertEquals("alice@example.test", linked.accountLabel) + val wrong = assertIs<ApiResult.Error>(api.linkWithNetwork(scope, "5", "wrong")) + assertEquals(422, wrong.code) + assertEquals(DefaultExternalSignInApi.WRONG_PASSWORD, wrong.error) + } finally { client.close() } + } + + @Test + fun linkWithNetworkRefusalsAreDistinguishable() = runTest { + val cases = listOf( + Triple("network_identity_required", 403, null) to "network_identity_required", + Triple("validation_failed", 422, "body.password") to DefaultExternalSignInApi.WRONG_PASSWORD, + Triple("local_password_required", 409, null) to "local_password_required", + Triple("not_permitted", 403, null) to "not_permitted", + Triple("permission_denied", 403, null) to "permission_denied", + Triple("identity_linked_elsewhere", 409, null) to "identity_linked_elsewhere", + Triple("already_linked", 409, null) to "already_linked", + Triple("not_found", 404, null) to "not_found", + Triple("provider_unavailable", 503, null) to "provider_unavailable", + Triple("rate_limited", 429, null) to "rate_limited", + ) + for ((problem, expected) in cases) { + val (code, status, location) = problem + val (client, api) = api { problem(code, status, location) } + try { + val error = assertIs<ApiResult.Error>(api.linkWithNetwork(scope, "5", "pw")) + assertEquals(status, error.code, code) + assertEquals(expected, error.error, code) + } finally { client.close() } + } + // A success other than 201 is not an identity. + val (client, api) = api { json("{}") } + try { + assertEquals("invalid_response", assertIs<ApiResult.Error>(api.linkWithNetwork(scope, "5", "pw")).error) + } finally { client.close() } + } + + @Test + fun identitiesAreListedAndDisconnectedThroughTheAccountScope() = runTest { + val calls = mutableListOf<String>() + val (client, api) = api { request -> + calls += "${request.method.value} ${request.url.encodedPath}" + assertEquals(scope, request.attributes.getOrNull(AuthScopeAttributeKey)) + when (request.method) { + HttpMethod.Get -> json( + """{"items":[{"id":"4","installation_id":"3","provider_id":"plugin:3:oidc","provider_name":"SSO", + "username":"","email":"alice@example.test","display_name":"Alice","linked_at":"2026-01-02T03:04:05.000Z", + "last_sign_in_at":null}],"can_unlink":false}""", + ) + else -> problem("last_sign_in_method", 409) + } + } + try { + val listed = assertIs<ApiResult.Success<AccountIdentities>>(api.listIdentities(scope)).data + assertEquals(false, listed.canUnlink) + val identity = listed.items.single() + assertEquals("alice@example.test", identity.accountLabel, "the email stands in for an empty username") + val refused = assertIs<ApiResult.Error>(api.deleteIdentity(scope, "4")) + assertEquals("last_sign_in_method", refused.error) + assertEquals(listOf("GET /api/v2/account/identities", "DELETE /api/v2/account/identities/4"), calls) + } finally { client.close() } + } + + @Test + fun wireModelsNeverPrintSecrets() { + val strings = listOf( + CompleteOAuthLoginV2(code = "secret-code", codeVerifier = "secret-verifier").toString(), + LinkTicketRequestV2(installationId = "3", password = "secret-pw").toString(), + LinkCredentialsRequestV2("6", "secret-pw", "secret-user", "secret-dir").toString(), + LinkNetworkRequestV2(installationId = "5", password = "secret-pw").toString(), + ) + strings.forEach { assertFalse(it.contains("secret"), it) } + } +} + +class SignInOptionsTest { + private val local = SignInProvider("local", "Prairie account", SignInProvider.Mode.Credentials, true, null, null, null) + private val ldap = SignInProvider("plugin:6:ldap", "Directory", SignInProvider.Mode.Credentials, false, null, "6", null) + private val oidc = SignInProvider( + "plugin:5:oidc", "Keycloak", SignInProvider.Mode.OAuth, false, null, "5", + "/api/v2/auth/oauth/5/native/start", + ) + private val native = OAuthHandshakeCapabilities(available = true, native = true, linking = true) + + @Test + fun oidcOnlyServerWithLocalPasswordsOffHidesThePasswordForm() { + val options = SignInOptions.of(SignInProviders(listOf(oidc), passwordLogin = false), native) + assertFalse(options.showPasswordForm) + assertEquals(listOf(oidc), options.oauthProviders) + } + + @Test + fun ldapKeepsThePasswordFormWithoutAProviderField() { + // Local sign-in off, directory on: the password form stays, and it + // is the server that routes a password login to the directory. + val options = SignInOptions.of(SignInProviders(listOf(ldap), passwordLogin = true), native) + assertTrue(options.showPasswordForm) + assertEquals(ldap, options.directoryProvider) + // Even if a server said password_login=false, a listed credentials provider takes a password. + assertTrue(SignInOptions.of(SignInProviders(listOf(ldap), passwordLogin = false), native).showPasswordForm) + } + + @Test + fun localAndOidcShowsBoth() { + val options = SignInOptions.of(SignInProviders(listOf(local, oidc), passwordLogin = true), native) + assertTrue(options.showPasswordForm) + assertEquals(listOf(oidc), options.oauthProviders) + assertNull(options.directoryProvider) + } + + @Test + fun providerButtonsNeedTheNativeHandoff() { + val noNative = OAuthHandshakeCapabilities(available = true, native = false, linking = false) + assertTrue(SignInOptions.of(SignInProviders(listOf(local, oidc), true), noNative).oauthProviders.isEmpty()) + assertTrue(SignInOptions.of(SignInProviders(listOf(local, oidc), true), OAuthHandshakeCapabilities.None).oauthProviders.isEmpty()) + val noStart = oidc.copy(nativeStartPath = null) + assertTrue(SignInOptions.of(SignInProviders(listOf(noStart), false), native).oauthProviders.isEmpty()) + } + + @Test + fun passwordLoginFailuresMapByProblemCodeFirst() { + assertEquals(PasswordLoginFailure.LocalLoginDisabled, PasswordLoginFailure.of(403, "local_login_disabled")) + assertEquals(PasswordLoginFailure.NotPermitted, PasswordLoginFailure.of(403, "not_permitted")) + assertEquals(PasswordLoginFailure.PasswordExpired, PasswordLoginFailure.of(403, "password_expired")) + assertEquals(PasswordLoginFailure.ProviderUnavailable, PasswordLoginFailure.of(503, "provider_unavailable")) + assertEquals(PasswordLoginFailure.InvalidCredentials, PasswordLoginFailure.of(401, "invalid_credentials")) + assertEquals(PasswordLoginFailure.AccountDisabled, PasswordLoginFailure.of(403, "account_disabled")) + assertEquals(PasswordLoginFailure.ProviderUnavailable, PasswordLoginFailure.of(503, "")) + assertEquals(PasswordLoginFailure.Other, PasswordLoginFailure.of(500, "internal")) + assertEquals(PasswordLoginFailure.EmailInUse, PasswordLoginFailure.of(409, "email_in_use")) + assertEquals(PasswordLoginFailure.IdentityLinkedElsewhere, PasswordLoginFailure.of(409, "identity_linked_elsewhere")) + assertEquals(PasswordLoginFailure.RateLimited, PasswordLoginFailure.of(429, "rate_limited")) + assertEquals(PasswordLoginFailure.RateLimited, PasswordLoginFailure.of(429, "")) + } + + @Test + fun aNetworkProviderNeedsItsSignInPathAndChangesNothingElse() { + val network = SignInProvider( + "plugin:7:tailscale", "Tailscale", SignInProvider.Mode.Network, false, null, "7", null, + networkSignInPath = "/api/v2/auth/network/7/sign-in", + networkIdentity = NetworkIdentity("Alice", "alice"), + ) + val withNetwork = SignInOptions.of(SignInProviders(listOf(oidc, network), passwordLogin = false), native) + assertEquals(network, withNetwork.networkProvider) + assertFalse(withNetwork.showPasswordForm, "a network provider never takes a password") + assertEquals(listOf(oidc), withNetwork.oauthProviders) + assertNull(SignInOptions.of(SignInProviders(listOf(network.copy(networkSignInPath = null)), true), native).networkProvider) + assertNull(SignInOptions.PasswordOnly.networkProvider) + } + + @Test + fun selectAccountNeedsAProviderButton() { + val choosing = native.copy(selectAccount = true) + assertTrue(SignInOptions.of(SignInProviders(listOf(oidc), false), choosing).selectAccount) + assertFalse(SignInOptions.of(SignInProviders(listOf(oidc), false), native).selectAccount) + assertFalse(SignInOptions.of(SignInProviders(listOf(local), true), choosing).selectAccount) + } + + @Test + fun optionsAreUnknownOnlyWhenTheServerCouldNotBeAsked() = runTest { + suspend fun optionsWith( + providers: ApiResult<SignInProviders>, + handshake: ApiResult<OAuthHandshakeCapabilities>, + ): SignInOptions? = ExternalSignInRepository(StubDiscovery(providers, handshake)).signInOptions("https://prairie.example.test") + + val listed = ApiResult.Success(SignInProviders(listOf(local, oidc), passwordLogin = true)) + val offline = ApiResult.NetworkError(RuntimeException("offline")) + assertNull(optionsWith(offline, ApiResult.Success(native)), "a network failure leaves the options unknown") + assertNull(optionsWith(listed, offline)) + assertEquals(SignInOptions.PasswordOnly, optionsWith(ApiResult.Error(404, "not_found", ""), ApiResult.Success(native))) + assertEquals( + SignInOptions.PasswordOnly, + optionsWith(ApiResult.Error(0, ApiV2Gate.UPDATE_REQUIRED_ERROR, ""), ApiResult.Success(native)), + ) + val noHandshake = optionsWith(listed, ApiResult.Error(404, "not_found", "")) + assertTrue(noHandshake!!.oauthProviders.isEmpty(), "a server without the handshake shows no provider buttons") + assertEquals(listOf(oidc), optionsWith(listed, ApiResult.Success(native))!!.oauthProviders) + } + + private class StubDiscovery( + private val providers: ApiResult<SignInProviders>, + private val handshake: ApiResult<OAuthHandshakeCapabilities>, + ) : ExternalSignInApi { + override suspend fun listProviders(serverUrl: String) = providers + override suspend fun oauthCapabilities(serverUrl: String) = handshake + override suspend fun externalSignInCapabilities(scope: AuthScopeSnapshot) = TODO() + override suspend fun completeOAuthLogin(serverUrl: String, code: String, codeVerifier: String) = TODO() + override suspend fun signInWithNetworkIdentity(serverUrl: String, signInPath: String) = TODO() + override suspend fun listIdentities(scope: AuthScopeSnapshot) = TODO() + override suspend fun deleteIdentity(scope: AuthScopeSnapshot, identityId: String) = TODO() + override suspend fun createLinkTicket(scope: AuthScopeSnapshot, installationId: String, password: String) = TODO() + override suspend fun completeLink(scope: AuthScopeSnapshot, code: String, codeVerifier: String) = TODO() + override suspend fun linkWithCredentials( + scope: AuthScopeSnapshot, + installationId: String, + password: String, + username: String, + directoryPassword: String, + ) = TODO() + override suspend fun linkWithNetwork(scope: AuthScopeSnapshot, installationId: String, password: String) = TODO() + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/SectionApiCollectionItemsTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/SectionApiCollectionItemsTest.kt index da57de9a15..d8a286afa2 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/SectionApiCollectionItemsTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/SectionApiCollectionItemsTest.kt @@ -22,6 +22,23 @@ import kotlin.test.assertTrue class SectionApiCollectionItemsTest { + @Test + fun mixedCollectionTypeRemainsRequiredWithMatchAny() = runTest { + val requests = mutableListOf<Map<String, String?>>() + val api = SectionApi(clientFor(requests, """{"total":0,"total_exact":true,"window_cursor":"w","page":{"has_more":false},"items":[]}""")) + val result = api.getLibraryCollectionItems( + "c1", mediaType = "series", libraryId = 7, source = "user_collection", match = "any", + queryGroups = listOf(CatalogQueryGroup(rules = listOf(CatalogQueryRule("genre", "contains", "Drama")))), + ) + assertTrue(result is ApiResult.Success) + val body = PrairieJson.parseToJsonElement(requests.single().getValue("body")!!).jsonObject + assertEquals("series", body["type"]?.jsonPrimitive?.content) + assertEquals("7", body["library_id"]?.jsonPrimitive?.content) + assertEquals("user_collection", body["source"]?.jsonPrimitive?.content) + assertEquals("any", body["match"]?.jsonPrimitive?.content) + assertEquals("genre", body["groups"]!!.jsonArray.single().jsonObject["rules"]!!.jsonArray.single().jsonObject["field"]?.jsonPrimitive?.content) + } + /** * The collection's own order is expressed by sending no sort at all, so a * null sort must not leak an `order` param either. diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/WatchTogetherApiTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/WatchTogetherApiTest.kt index c918e1815b..3d9f3ac996 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/WatchTogetherApiTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/WatchTogetherApiTest.kt @@ -3,28 +3,38 @@ package org.prairieserver.prairie.network.api import org.prairieserver.prairie.model.watchtogether.AddSuggestionRequest import org.prairieserver.prairie.model.watchtogether.CreateRoomRequest import org.prairieserver.prairie.model.watchtogether.JoinRoomRequest +import org.prairieserver.prairie.model.watchtogether.MemberStateRequest import org.prairieserver.prairie.model.watchtogether.PromoteSuggestionRequest +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.SelectionModeRequest import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest +import org.prairieserver.prairie.model.watchtogether.SourceFallbackRequest import org.prairieserver.prairie.model.watchtogether.UpdatePolicyRequest +import org.prairieserver.prairie.model.watchtogether.readWatchPartyFixture import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeAttributeKey import org.prairieserver.prairie.network.AuthScopeSnapshot import org.prairieserver.prairie.network.PrairieJson +import org.prairieserver.prairie.network.SingleAttemptAttributeKey +import org.prairieserver.prairie.network.apiv2.ApiV2Gate import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respond import io.ktor.client.engine.mock.toByteArray import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.serialization.kotlinx.json.json import io.ktor.http.HttpHeaders import io.ktor.http.HttpMethod import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf -import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertIs +import kotlin.test.assertNull import kotlin.test.assertTrue class WatchTogetherApiTest { @@ -34,271 +44,292 @@ class WatchTogetherApiTest { serverUrl = "https://prairie.example", profileToken = "profile-token", ) - - private class Captured { - val calls = mutableListOf<Call>() - val last: Call get() = calls.last() - val first: Call get() = calls.first() - } + private val roomId = "9f3c1a52-3c4e-4f7b-9a0e-6b2d8c1e7a10" + private val roomBody = readWatchPartyFixture("room_http.json") private data class Call( val method: HttpMethod, val path: String, val query: Map<String, String?>, val roomToken: String?, + val singleAttempt: Boolean, + val pinnedScope: AuthScopeSnapshot?, val body: String, ) - private val roomJson = """ - {"room_id":"room-1","phase":"lobby","playback_state":"idle","selection_mode":"host_pick", - "selection_revision":0,"code":"ABCD1234","guest_control_policy":"host_only", - "is_paused":false,"anchor_position_seconds":0.0,"anchor_updated_at":"2026-06-12T09:00:00Z", - "generation":1,"member_count":1,"host_connected":true,"self_role":"host", - "self_can_control_transport":true,"self_can_manage_room":true,"self_ignore_wait":false} - """.trimIndent() - - private val suggestionsJson = """ - {"items":[{"id":"sug-5","room_id":"room-1","suggester_user_id":"7","suggester_profile_id":"p", - "content_id":"c","content_type":"movie","title":"T","subtitle":"","poster_url":"","note":"", - "vote_count":2,"voted_by_me":true,"created_at":"2026-06-12T08:00:00Z"}], - "page":{"has_more":false}} - """.trimIndent() - - private val problem = """{"type":"https://prairie.example/problems/conflict","title":"Conflict","status":409,"detail":"Already voted"}""" - - /** Routes by method: the first response answers the mutation, a suggestions GET always answers with the list. */ - private fun api( - status: HttpStatusCode = HttpStatusCode.OK, - responseBody: String = "{}", - captured: Captured = Captured(), - ): Pair<WatchTogetherApi, Captured> { + private class Server(var status: HttpStatusCode = HttpStatusCode.OK, var body: String = "{}") { + val calls = mutableListOf<Call>() + val last: Call get() = calls.last() + } + + private fun api(server: Server): WatchTogetherApi { val client = HttpClient( MockEngine { request -> - captured.calls += Call( + server.calls += Call( method = request.method, path = request.url.encodedPath, query = request.url.parameters.names().associateWith { request.url.parameters[it] }, roomToken = request.headers["X-Room-Token"], + singleAttempt = request.attributes.getOrNull(SingleAttemptAttributeKey) == true, + pinnedScope = request.attributes.getOrNull(AuthScopeAttributeKey), body = request.body.toByteArray().decodeToString(), ) - val followUpList = captured.calls.size > 1 && - request.method == HttpMethod.Get && request.url.encodedPath.endsWith("/suggestions") - if (followUpList) { - respond(suggestionsJson, HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) - } else { - respond( - content = responseBody, - status = status, - headers = headersOf(HttpHeaders.ContentType, "application/problem+json"), - ) - } + val type = if (server.status.value >= 400) "application/problem+json" else "application/json" + respond(server.body, server.status, headersOf(HttpHeaders.ContentType, type)) }, ) { install(ContentNegotiation) { json(PrairieJson) } } - return DefaultWatchTogetherApi(client, org.prairieserver.prairie.network.apiv2.ApiV2Gate.Unrestricted) to captured + return DefaultWatchTogetherApi(client, ApiV2Gate.Unrestricted) + } + + private fun sent(call: Call) = PrairieJson.parseToJsonElement(call.body).jsonObject + + private fun assertInvalidResponse(result: ApiResult<*>) { + assertIs<ApiResult.Error>(result) + assertEquals("invalid_response", result.error) } + // ---- create / join / read -------------------------------------------------- + @Test - fun `createRoom posts client room_id and selection_mode and decodes room + token`() = runTest { - val (api, captured) = api( - status = HttpStatusCode.Created, - responseBody = """{"room":$roomJson,"room_access_token":"jwt-1"}""", - ) - val r = api.createRoom(CreateRoomRequest(selectionMode = "vote"), scope) - assertEquals(HttpMethod.Post, captured.last.method) - assertEquals("/api/v2/watch-together/rooms", captured.last.path) - val sent = PrairieJson.parseToJsonElement(captured.last.body).jsonObject - assertEquals(setOf("room_id", "selection_mode"), sent.keys) - assertEquals("vote", sent.getValue("selection_mode").jsonPrimitive.content) - assertTrue(sent.getValue("room_id").jsonPrimitive.content.length == 36) - assertIs<ApiResult.Success<*>>(r) - assertEquals("jwt-1", (r as ApiResult.Success).data.roomAccessToken) + fun `create sends the caller room identity and requires 201`() = runTest { + val server = Server(HttpStatusCode.Created, roomBody) + val result = api(server).createRoom(CreateRoomRequest(roomId = roomId, selectionMode = "vote"), scope) + + assertIs<ApiResult.Success<*>>(result) + assertEquals(HttpMethod.Post, server.last.method) + assertEquals("/api/v2/watch-together/rooms", server.last.path) + assertEquals(setOf("room_id", "selection_mode"), sent(server.last).keys) + assertEquals(roomId, sent(server.last).getValue("room_id").jsonPrimitive.content) + assertEquals(scope, server.last.pinnedScope) + assertNull(server.last.roomToken) + assertFalse(server.last.singleAttempt) } @Test - fun `createRoom defaults selection_mode to host_pick`() = runTest { - val (api, captured) = api( - status = HttpStatusCode.Created, - responseBody = """{"room":$roomJson,"room_access_token":"jwt-1"}""", - ) - api.createRoom(CreateRoomRequest(), scope) - val sent = PrairieJson.parseToJsonElement(captured.last.body).jsonObject - assertEquals("host_pick", sent.getValue("selection_mode").jsonPrimitive.content) + fun `create answered with the wrong success status is an invalid response`() = runTest { + val result = api(Server(HttpStatusCode.OK, roomBody)).createRoom(CreateRoomRequest(roomId = roomId), scope) + assertInvalidResponse(result) } @Test - fun `joinRoom posts code and decodes room`() = runTest { - val (api, captured) = api(responseBody = """{"room":$roomJson,"room_access_token":"jwt-2"}""") - val r = api.joinRoom(JoinRoomRequest(code = "ABCD1234"), scope) - assertEquals(HttpMethod.Post, captured.last.method) - assertEquals("/api/v2/watch-together/join", captured.last.path) - assertIs<ApiResult.Success<*>>(r) - assertEquals("room-1", (r as ApiResult.Success).data.room.roomId) + fun `create response naming another room is an invalid response`() = runTest { + val result = api(Server(HttpStatusCode.Created, roomBody)).createRoom(CreateRoomRequest(roomId = "other"), scope) + assertInvalidResponse(result) } @Test - fun `getRoom passes X-Room-Token header and no query`() = runTest { - val (api, captured) = api(responseBody = """{"room":$roomJson,"room_access_token":"jwt-room"}""") - api.getRoom("room-1", "jwt-room", scope) - assertEquals(HttpMethod.Get, captured.last.method) - assertEquals("/api/v2/watch-together/rooms/room-1", captured.last.path) - assertEquals("jwt-room", captured.last.roomToken) - assertTrue(captured.last.query.isEmpty()) + fun `join decodes the v2 snapshot with string ids and members`() = runTest { + val server = Server(body = roomBody) + val result = api(server).joinRoom(JoinRoomRequest(code = "K7PQ2M4X"), scope) + + assertIs<ApiResult.Success<org.prairieserver.prairie.model.watchtogether.RoomResponse>>(result) + val room = result.data.room + assertEquals(RoomPhase.Playing, room.phase) + assertEquals(42, room.selectedFileId) + assertEquals(7, room.selectedLibraryId) + assertEquals(2, room.members.size) + assertEquals("12", room.selfMember?.userId) + assertTrue(room.members[1].isSyncing) + assertEquals("/rooms/join?token=Zq8vN3kR5tW1yB6cD9fG2hJ4", room.invitePath) + assertEquals(mapOf("code" to "K7PQ2M4X"), sent(server.last).mapValues { it.value.jsonPrimitive.content }) } @Test - fun `setSelection puts content_id with string file_id`() = runTest { - val (api, captured) = api(responseBody = """{"room":$roomJson,"room_access_token":"jwt-room"}""") - api.setSelection("room-1", "jwt-room", SetSelectionRequest(contentId = "tt-9", fileId = 3), scope) - assertEquals(HttpMethod.Put, captured.last.method) - assertEquals("/api/v2/watch-together/rooms/room-1/selection", captured.last.path) - assertEquals("jwt-room", captured.last.roomToken) - val sent = PrairieJson.parseToJsonElement(captured.last.body).jsonObject - assertEquals("tt-9", sent.getValue("content_id").jsonPrimitive.content) - assertEquals("3", sent.getValue("file_id").jsonPrimitive.content) - assertTrue(sent.getValue("file_id").jsonPrimitive.isString) - assertTrue("library_id" !in sent) + fun `a room response without proof is an invalid response`() = runTest { + val body = roomBody.replace(Regex("\"room_access_token\": \"[^\"]+\""), "\"room_access_token\": \"\"") + assertInvalidResponse(api(Server(body = body)).joinRoom(JoinRoomRequest(code = "K7PQ2M4X"), scope)) } @Test - fun `updatePolicy patches policy with X-Room-Token`() = runTest { - val (api, captured) = api(responseBody = """{"room":$roomJson,"room_access_token":"jwt-room"}""") - api.updatePolicy( - "room-1", - "jwt-room", - UpdatePolicyRequest(guestControlPolicy = "guest_play_pause"), - scope, + fun `room read sends the proof and rejects another room`() = runTest { + val server = Server(body = roomBody) + assertIs<ApiResult.Success<*>>(api(server).getRoom(roomId, "proof-1", scope)) + assertEquals("proof-1", server.last.roomToken) + assertEquals("/api/v2/watch-together/rooms/$roomId", server.last.path) + + assertInvalidResponse(api(server).getRoom("another-room", "proof-1", scope)) + } + + @Test + fun `malformed success body is an invalid response, not a network error`() = runTest { + assertInvalidResponse(api(Server(body = """{"room":{}}""")).getRoom(roomId, "proof-1", scope)) + } + + // ---- host operations -------------------------------------------------------- + + @Test + fun `host operations authorize by identity and send no proof`() = runTest { + val server = Server(body = roomBody) + val api = api(server) + api.updatePolicy(roomId, UpdatePolicyRequest("guest_play_pause"), scope) + api.stageSelection(roomId, SetSelectionRequest("movie:heat-1995", fileId = 42, libraryId = 7), scope) + api.startPlayback(roomId, scope) + api.stopPlayback(roomId, scope) + api.setSelectionMode(roomId, SelectionModeRequest("vote"), scope) + api.setSelection(roomId, SetSelectionRequest("movie:heat-1995"), scope) + + assertEquals( + listOf( + HttpMethod.Patch to "/policy", + HttpMethod.Put to "/staged-selection", + HttpMethod.Post to "/playback/start", + HttpMethod.Post to "/playback/stop", + HttpMethod.Patch to "/selection-mode", + HttpMethod.Put to "/selection", + ), + server.calls.map { it.method to it.path.removePrefix("/api/v2/watch-together/rooms/$roomId") }, ) - assertEquals(HttpMethod.Patch, captured.last.method) - assertEquals("/api/v2/watch-together/rooms/room-1/policy", captured.last.path) - assertEquals("jwt-room", captured.last.roomToken) + assertTrue(server.calls.all { it.roomToken == null && it.pinnedScope == scope }) } @Test - fun `closeRoom deletes and maps 204 to Unit`() = runTest { - val (api, captured) = api(status = HttpStatusCode.NoContent, responseBody = "") - val r = api.closeRoom("room-1", "jwt-room", scope) - assertEquals(HttpMethod.Delete, captured.last.method) - assertEquals("/api/v2/watch-together/rooms/room-1", captured.last.path) - assertEquals("jwt-room", captured.last.roomToken) - assertEquals(ApiResult.Success(Unit), r) + fun `stage sends string file and library ids`() = runTest { + val server = Server(body = roomBody) + api(server).stageSelection(roomId, SetSelectionRequest("movie:heat-1995", fileId = 42, libraryId = 7), scope) + val body = sent(server.last) + assertEquals("42", body.getValue("file_id").jsonPrimitive.content) + assertTrue(body.getValue("file_id").jsonPrimitive.isString) + assertEquals("7", body.getValue("library_id").jsonPrimitive.content) } @Test - fun `listSuggestions decodes v2 items envelope`() = runTest { - val (api, captured) = api(responseBody = suggestionsJson) - val r = api.listSuggestions("room-1", "jwt-room", scope) - assertEquals(HttpMethod.Get, captured.last.method) - assertEquals("/api/v2/watch-together/rooms/room-1/suggestions", captured.last.path) - assertEquals("jwt-room", captured.last.roomToken) - val list = assertIs<ApiResult.Success<*>>(r).data as org.prairieserver.prairie.model.watchtogether.SuggestionsResponse - assertEquals("sug-5", list.suggestions.single().id) - assertEquals("7", list.suggestions.single().suggesterUserId) + fun `only non-retryable operations skip the authentication replay`() = runTest { + val server = Server(body = roomBody) + val api = api(server) + api.startPlayback(roomId, scope) + api.setSelection(roomId, SetSelectionRequest("movie:heat-1995"), scope) + api.promoteSuggestion(roomId, "proof-1", PromoteSuggestionRequest("b2a1"), scope) + api.stopPlayback(roomId, scope) + api.stageSelection(roomId, SetSelectionRequest("movie:heat-1995"), scope) + api.getRoom(roomId, "proof-1", scope) + + assertEquals(listOf(true, true, true, false, false, false), server.calls.map { it.singleAttempt }) } @Test - fun `addSuggestion posts client suggestion_id then reads the list`() = runTest { - val (api, captured) = api( - status = HttpStatusCode.Created, - responseBody = """{"suggestion_id":"generated"}""", - ) - val r = api.addSuggestion( - "room-1", "jwt-room", - AddSuggestionRequest(contentId = "c", contentType = "movie", title = "T"), + fun `end requires 204`() = runTest { + val server = Server(HttpStatusCode.NoContent, "") + assertIs<ApiResult.Success<Unit>>(api(server).closeRoom(roomId, scope)) + assertEquals(HttpMethod.Delete, server.last.method) + assertNull(server.last.roomToken) + } + + @Test + fun `source fallback sends the fence and reason with proof`() = runTest { + val server = Server(body = roomBody) + api(server).sourceFallback( + roomId, + "proof-1", + SourceFallbackRequest(selectionRevision = 3, failedFileId = "42", reason = "hdr_transcode_unsupported"), scope, ) - assertEquals(2, captured.calls.size) - assertEquals(HttpMethod.Post, captured.first.method) - assertEquals("/api/v2/watch-together/rooms/room-1/suggestions", captured.first.path) - assertEquals("jwt-room", captured.first.roomToken) - val sent = PrairieJson.parseToJsonElement(captured.first.body).jsonObject - assertEquals(setOf("content_id", "content_type", "title", "suggestion_id"), sent.keys) - assertEquals(HttpMethod.Get, captured.last.method) - assertEquals("/api/v2/watch-together/rooms/room-1/suggestions", captured.last.path) - assertEquals(1, assertIs<ApiResult.Success<*>>(r).let { (it.data as org.prairieserver.prairie.model.watchtogether.SuggestionsResponse).suggestions.size }) + assertEquals("proof-1", server.last.roomToken) + assertEquals("/api/v2/watch-together/rooms/$roomId/source-fallback", server.last.path) + val body = sent(server.last) + assertEquals("3", body.getValue("selection_revision").jsonPrimitive.content) + assertEquals("42", body.getValue("failed_file_id").jsonPrimitive.content) + assertEquals("hdr_transcode_unsupported", body.getValue("reason").jsonPrimitive.content) } @Test - fun `deleteSuggestion deletes then reads the list`() = runTest { - val (api, captured) = api(status = HttpStatusCode.NoContent, responseBody = "") - val r = api.deleteSuggestion("room-1", "jwt-room", "sug-5", scope) - assertEquals(HttpMethod.Delete, captured.first.method) - assertEquals("/api/v2/watch-together/rooms/room-1/suggestions/sug-5", captured.first.path) - assertEquals("jwt-room", captured.first.roomToken) - assertEquals(HttpMethod.Get, captured.last.method) - assertIs<ApiResult.Success<*>>(r) + fun `problem responses keep status and code`() = runTest { + val problem = """{"type":"https://prairie.example/problems/conflict","title":"Conflict","status":409,"detail":"The room is closed."}""" + val result = api(Server(HttpStatusCode.Conflict, problem)).startPlayback(roomId, scope) + assertIs<ApiResult.Error>(result) + assertEquals(409, result.code) + assertEquals("conflict", result.error) } - @Test - fun `room and suggestion identifiers are encoded as path segments`() = runTest { - val (api, captured) = api(status = HttpStatusCode.NoContent, responseBody = "") + // ---- suggestions ------------------------------------------------------------- - api.deleteSuggestion("room/with ?#", "jwt-room", "suggestion/with ?#", scope) + @Test + fun `suggestion page passes limit and cursor and decodes page metadata`() = runTest { + val server = Server(body = readWatchPartyFixture("suggestions_page.json")) + val result = api(server).listSuggestions(roomId, "proof-1", scope, cursor = "abc") - assertEquals( - "/api/v2/watch-together/rooms/room%2Fwith%20%3F%23/suggestions/" + - "suggestion%2Fwith%20%3F%23", - captured.first.path, - ) + assertIs<ApiResult.Success<org.prairieserver.prairie.model.watchtogether.SuggestionsResponse>>(result) + assertEquals(mapOf("limit" to "50", "cursor" to "abc"), server.last.query) + assertEquals("proof-1", server.last.roomToken) + assertEquals(true, result.data.page?.hasMore) + assertEquals("c2VlZDoy", result.data.page?.nextCursor) + assertTrue(result.data.suggestions.single().votedByMe) } @Test - fun `vote posts vote path then reads the list`() = runTest { - val (api, captured) = api(status = HttpStatusCode.NoContent, responseBody = "") - api.vote("room-1", "jwt-room", "sug-5", scope) - assertEquals(HttpMethod.Post, captured.first.method) - assertEquals("/api/v2/watch-together/rooms/room-1/suggestions/sug-5/vote", captured.first.path) - assertEquals("jwt-room", captured.first.roomToken) - assertEquals(HttpMethod.Get, captured.last.method) + fun `a page with more items but no cursor is an invalid response`() = runTest { + val body = """{"items":[],"page":{"has_more":true}}""" + assertInvalidResponse(api(Server(body = body)).listSuggestions(roomId, "proof-1", scope)) } @Test - fun `unvote deletes vote path then reads the list`() = runTest { - val (api, captured) = api(status = HttpStatusCode.NoContent, responseBody = "") - api.unvote("room-1", "jwt-room", "sug-5", scope) - assertEquals(HttpMethod.Delete, captured.first.method) - assertEquals("/api/v2/watch-together/rooms/room-1/suggestions/sug-5/vote", captured.first.path) - assertEquals("jwt-room", captured.first.roomToken) - assertEquals(HttpMethod.Get, captured.last.method) + fun `add suggestion sends the caller id and checks the receipt`() = runTest { + val request = AddSuggestionRequest(suggestionId = "s-1", contentId = "movie:heat-1995", contentType = "movie", title = "Heat") + val server = Server(HttpStatusCode.Created, """{"suggestion_id":"s-1"}""") + assertIs<ApiResult.Success<*>>(api(server).addSuggestion(roomId, "proof-1", request, scope)) + assertEquals("s-1", sent(server.last).getValue("suggestion_id").jsonPrimitive.content) + assertEquals(1, server.calls.size, "a mutation must not read the list itself") + + server.body = """{"suggestion_id":"s-2"}""" + assertInvalidResponse(api(server).addSuggestion(roomId, "proof-1", request, scope)) } @Test - fun `promote posts suggestion_id with X-Room-Token and decodes room`() = runTest { - val (api, captured) = api(responseBody = """{"room":$roomJson,"room_access_token":"jwt-room"}""") - api.promoteSuggestion( - "room-1", - "jwt-room", - PromoteSuggestionRequest(suggestionId = "sug-5"), - scope, + fun `votes return an empty receipt and never read the list`() = runTest { + val server = Server(HttpStatusCode.NoContent, "") + val api = api(server) + assertIs<ApiResult.Success<Unit>>(api.vote(roomId, "proof-1", "b2a1", scope)) + assertIs<ApiResult.Success<Unit>>(api.unvote(roomId, "proof-1", "b2a1", scope)) + assertIs<ApiResult.Success<Unit>>(api.deleteSuggestion(roomId, "proof-1", "b2a1", scope)) + assertEquals( + listOf(HttpMethod.Post, HttpMethod.Delete, HttpMethod.Delete), + server.calls.map { it.method }, ) - assertEquals(HttpMethod.Post, captured.last.method) - assertEquals("/api/v2/watch-together/rooms/room-1/suggestions/promote", captured.last.path) - assertEquals("jwt-room", captured.last.roomToken) - val sent = PrairieJson.parseToJsonElement(captured.last.body).jsonObject - assertEquals("sug-5", sent.getValue("suggestion_id").jsonPrimitive.content) + assertEquals("/api/v2/watch-together/rooms/$roomId/suggestions/b2a1/vote", server.calls[0].path) } + // ---- shared browsing --------------------------------------------------------- + @Test - fun `vote problem surfaces as ApiResult Error without a follow-up read`() = runTest { - val (api, captured) = api(status = HttpStatusCode.Conflict, responseBody = problem) - val r = api.vote("room-1", "jwt-room", "sug-5", scope) - assertIs<ApiResult.Error>(r) - assertEquals(409, r.code) - assertEquals("conflict", r.error) - assertEquals("Already voted", r.message) - assertEquals(1, captured.calls.size) + fun `member state rejects an empty id list before sending`() = runTest { + val server = Server(body = readWatchPartyFixture("member_state.json")) + val empty = api(server).memberState(roomId, "proof-1", MemberStateRequest(emptyList()), scope) + assertIs<ApiResult.Error>(empty) + assertEquals(422, empty.code) + assertTrue(server.calls.isEmpty()) } @Test - fun `join 410 problem surfaces as ApiResult Error`() = runTest { - val (api, _) = api( - status = HttpStatusCode.Gone, - responseBody = """{"type":"https://prairie.example/problems/gone","title":"Gone","status":410,"detail":"Room is no longer active"}""", - ) - val r = api.joinRoom(JoinRoomRequest(code = "DEAD0000"), scope) - assertIs<ApiResult.Error>(r) - assertEquals(410, r.code) - assertEquals("Room is no longer active", r.message) + fun `member state decodes and rejects unrequested items`() = runTest { + val server = Server(body = readWatchPartyFixture("member_state.json")) + val result = api(server).memberState(roomId, "proof-1", MemberStateRequest(listOf("movie:heat-1995")), scope) + assertIs<ApiResult.Success<org.prairieserver.prairie.model.watchtogether.MemberStateResponse>>(result) + assertEquals("in_progress", result.data.items.single().members.single().state) + + assertInvalidResponse(api(server).memberState(roomId, "proof-1", MemberStateRequest(listOf("movie:other")), scope)) + } + + @Test + fun `picker decodes shared rows with next up`() = runTest { + val server = Server(body = readWatchPartyFixture("picker.json")) + val result = api(server).picker(roomId, "proof-1", scope) + assertIs<ApiResult.Success<org.prairieserver.prairie.model.watchtogether.PickerResponse>>(result) + assertTrue(result.data.continueTogether.isEmpty()) + val row = result.data.watchlistUnion.single() + assertEquals("series:the-bear", row.item.contentId) + assertEquals("episode:the-bear-s02e06", row.nextUp?.contentId) + assertEquals("proof-1", server.last.roomToken) + } + + @Test + fun `capabilities decode the vendored server fixture`() = runTest { + val server = Server(body = readWatchPartyFixture("capabilities.json")) + val result = api(server).capabilities(scope) + assertIs<ApiResult.Success<org.prairieserver.prairie.model.watchtogether.WatchTogetherCapabilitiesV2>>(result) + assertEquals("prairie.room.v2", result.data.socketProtocol) + assertEquals(200, result.data.maxMemberStateIds) + assertEquals(false, result.data.allowed) + assertEquals("/api/v2/watch-together/capabilities", server.last.path) } } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/ApiV2ContractTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/ApiV2ContractTest.kt index 735c0d208e..38ef99e535 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/ApiV2ContractTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/ApiV2ContractTest.kt @@ -8,9 +8,30 @@ import kotlin.test.assertNull import kotlin.test.assertTrue import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import org.prairieserver.prairie.network.apiv2.ApiV2Fixtures.plusUnknown import org.prairieserver.prairie.network.apiv2.ApiV2Fixtures.with +import org.prairieserver.prairie.model.auth.DeviceLoginCancelResponse +import org.prairieserver.prairie.model.auth.DeviceLoginLookupResponse +import org.prairieserver.prairie.model.auth.DeviceLoginStartResponse +import org.prairieserver.prairie.network.api.DeviceCapabilityV2 +import org.prairieserver.prairie.network.api.DevicePollV2 +import org.prairieserver.prairie.network.api.ServerConnectionsV2 +import org.prairieserver.prairie.network.api.ServerIdentityV2 +import org.prairieserver.prairie.network.api.AccountIdentityCollectionV2 +import org.prairieserver.prairie.network.api.AccountIdentityV2 +import org.prairieserver.prairie.network.api.AuthProviderCollectionV2 +import org.prairieserver.prairie.network.api.DefaultExternalSignInApi +import org.prairieserver.prairie.network.api.ExternalSignInCapabilitiesV2 +import org.prairieserver.prairie.network.api.LinkTicketV2 +import org.prairieserver.prairie.network.api.OAuthHandshakeCapabilitiesV2 +import org.prairieserver.prairie.network.api.TokenPairV2 +import org.prairieserver.prairie.model.auth.OAuthHandshakeCapabilities +import org.prairieserver.prairie.model.auth.SignInOptions +import org.prairieserver.prairie.model.auth.SignInProvider +import org.prairieserver.prairie.model.auth.SignInProviders +import org.prairieserver.prairie.pairing.PairingEndpoint /** * Decodes every vendored API v2 fixture with the production `PrairieJson` and @@ -39,13 +60,28 @@ class ApiV2ContractTest { assertEquals(entry.expectedStatus, problem.status, entry.name) assertTrue(problem.code.isNotBlank(), entry.name) } else { - assertEquals(200, entry.expectedStatus, entry.name) + assertTrue(entry.expectedStatus in 200..299, entry.name) when (entry.operationId) { "getSetupStatus" -> ApiV2Fixtures.decode<SetupStatus>(body) "getCurrentUser" -> ApiV2Fixtures.decode<Account>(body) "listProgress" -> Unit // Android does not read /api/v2/progress. "updateProfile" -> ApiV2Fixtures.decode<ProfileV2>(body) "getSystemInfo" -> ApiV2Fixtures.decode<SystemInfo>(body) + "startDeviceLogin" -> ApiV2Fixtures.decode<DeviceLoginStartResponse>(body) + "pollDeviceLogin" -> ApiV2Fixtures.decode<DevicePollV2>(body) + "cancelDeviceLogin" -> ApiV2Fixtures.decode<DeviceLoginCancelResponse>(body) + "getDeviceLogin" -> ApiV2Fixtures.decode<DeviceLoginLookupResponse>(body) + "getDeviceLoginCapability" -> ApiV2Fixtures.decode<DeviceCapabilityV2>(body) + "getServerIdentity" -> ApiV2Fixtures.decode<ServerIdentityV2>(body) + "getServerConnections" -> ApiV2Fixtures.decode<ServerConnectionsV2>(body) + "listAuthProviders" -> ApiV2Fixtures.decode<AuthProviderCollectionV2>(body) + "getOAuthHandshakeCapabilities" -> ApiV2Fixtures.decode<OAuthHandshakeCapabilitiesV2>(body) + "getExternalSignInCapabilities" -> ApiV2Fixtures.decode<ExternalSignInCapabilitiesV2>(body) + "completeOAuthLogin" -> ApiV2Fixtures.decode<TokenPairV2>(body) + "listAccountIdentities" -> ApiV2Fixtures.decode<AccountIdentityCollectionV2>(body) + "createAccountIdentityLinkTicket" -> ApiV2Fixtures.decode<LinkTicketV2>(body) + "signInWithNetworkIdentity" -> ApiV2Fixtures.decode<TokenPairV2>(body) + "linkAccountIdentityWithNetwork" -> ApiV2Fixtures.decode<AccountIdentityV2>(body) else -> error("unhandled success fixture ${entry.name} (${entry.operationId})") } } @@ -253,4 +289,339 @@ class ApiV2ContractTest { assertEquals(emptyList(), problem.errors) assertEquals("x", problem.code) } + + // --- Device sign-in (TV) --- + + @Test + fun deviceLoginStartFixtureCarriesTheDigitCodeAndActivateLink() { + val start = ApiV2Fixtures.decode<DeviceLoginStartResponse>(ApiV2Fixtures.bodyObject("start_device_login_ok").plusUnknown()) + assertEquals("4821-7730", start.userCode) + assertEquals("https://prairie.example.test/activate", start.verificationUri) + assertEquals("https://prairie.example.test/activate?code=48217730", start.verificationUriComplete) + assertEquals(900, start.expiresIn) + } + + @Test + fun deviceLoginPollFixturesCarryOpenedAndTokens() { + val opened = ApiV2Fixtures.decode<DevicePollV2>(ApiV2Fixtures.bodyObject("poll_device_login_opened")).domain() + assertEquals("pending", opened.status) + assertTrue(opened.opened) + assertNull(opened.accessToken) + assertEquals("2026-01-02T03:14:05.678Z", opened.expiresAt, "a pending poll carries the current expiry") + + val approved = ApiV2Fixtures.decode<DevicePollV2>(ApiV2Fixtures.bodyObject("poll_device_login_ok")).domain() + assertEquals("approved", approved.status) + assertFalse(approved.opened) + assertEquals("acc", approved.accessToken) + assertEquals("ref", approved.refreshToken) + + // Servers that predate the opened signal omit it. + val legacy = ApiV2Fixtures.decode<DevicePollV2>( + ApiV2Fixtures.bodyObject("poll_device_login_opened").let { body -> + kotlinx.serialization.json.JsonObject(body.filterKeys { it != "opened" }) + }, + ).domain() + assertFalse(legacy.opened) + } + + @Test + fun deviceLoginCancelLookupAndCapabilityFixtures() { + assertEquals("canceled", ApiV2Fixtures.decode<DeviceLoginCancelResponse>(ApiV2Fixtures.bodyObject("cancel_device_login_ok")).status) + + val lookup = ApiV2Fixtures.decode<DeviceLoginLookupResponse>(ApiV2Fixtures.bodyObject("get_device_login_ok")) + assertEquals("3f2a9d5e-6b1c-4c7e-9a0d-2f4b8c1e7a35", lookup.serverId) + assertEquals("2026-01-02T03:04:05.678Z", lookup.requestedAt) + assertEquals("Prairie", lookup.serverName) + assertEquals("4821-7730", lookup.userCode) + + val capability = ApiV2Fixtures.decode<DeviceCapabilityV2>(ApiV2Fixtures.bodyObject("get_device_login_capability_ok")).domain() + assertTrue(capability.deviceLoginAvailable) + assertTrue(capability.cancel) + assertTrue(capability.openedSignal) + + val unconfigured = ApiV2Fixtures.decode<DeviceCapabilityV2>( + with(ApiV2Fixtures.bodyObject("get_device_login_capability_ok"), "state" to JsonPrimitive("not_configured")), + ).domain() + assertFalse(unconfigured.deviceLoginAvailable) + assertFalse(unconfigured.cancel) + } + + @Test + fun serverIdentityAndConnectionsFixtures() { + assertEquals( + "3f2a9d5e-6b1c-4c7e-9a0d-2f4b8c1e7a35", + ApiV2Fixtures.decode<ServerIdentityV2>(ApiV2Fixtures.bodyObject("server_identity_ok")).serverId, + ) + val connections = ApiV2Fixtures.decode<ServerConnectionsV2>(ApiV2Fixtures.bodyObject("server_connections_ok")) + assertTrue(connections.isAvailable) + // The provider without a URL (plugin not running) is not offered. + assertEquals( + listOf( + PairingEndpoint.of("https://prairie.example.test", PairingEndpoint.Kind.Public), + PairingEndpoint.of( + "https://prairie.overlay.example.test", + PairingEndpoint.Kind.Provider, + provider = "stub", + displayName = "Stub Overlay", + ), + ), + connections.domain().endpoints, + ) + } + + // --- External sign-in (listAuthProviders, OAuth handshake, identities) --- + + @Test + fun authProvidersFixtureResolvesTheNativeStartAndIcon() { + val providers = ApiV2Fixtures.decode<AuthProviderCollectionV2>(ApiV2Fixtures.bodyObject("list_auth_providers_ok").plusUnknown()) + .domain("https://prairie.lan:8080") + assertTrue(providers.passwordLogin) + val (local, sso) = providers.providers + assertEquals("local", local.id) + assertEquals(SignInProvider.Mode.Credentials, local.mode) + assertNull(local.installationId) + assertNull(local.nativeStartPath) + assertEquals(SignInProvider.Mode.OAuth, sso.mode) + assertEquals("Example SSO", sso.displayName) + assertEquals("3", sso.installationId) + // Only the path is kept: apps open it on their own saved base, never on the listed origin. + assertEquals("/api/v2/auth/oauth/3/native/start", sso.nativeStartPath) + assertEquals("https://plugins.example.test/icon.svg", sso.iconUrl) + } + + @Test + fun authProviderRelativeIconResolvesAgainstTheListingServer() { + val body = ApiV2Fixtures.json.parseToJsonElement( + """{"items":[{"id":"plugin:3:oidc","display_name":"SSO","mode":"oauth","default":false, + "icon_url":"/api/v2/plugin-content/3/assets/sso.svg","installation_id":"3"}], + "password_login":false}""", + ).jsonObject + val provider = ApiV2Fixtures.decode<AuthProviderCollectionV2>(body).domain("https://prairie.lan:8080/").providers.single() + assertEquals("https://prairie.lan:8080/api/v2/plugin-content/3/assets/sso.svg", provider.iconUrl) + assertNull(provider.nativeStartPath, "no native_start_path offers no native sign-in") + } + + /** + * Whatever path prefix the listing names, apps get the start relative to + * the server base, to resolve against their saved base URL. Only + * `native_start_path` enables native sign-in. + */ + @Test + fun authProviderNativeStartIsReducedToTheBaseRelativePath() { + fun path(fields: String) = ApiV2Fixtures.decode<AuthProviderCollectionV2>( + ApiV2Fixtures.json.parseToJsonElement( + """{"items":[{"id":"plugin:3:oidc","display_name":"SSO","mode":"oauth","installation_id":"3"$fields}]}""", + ).jsonObject, + ).domain("http://192.168.1.10:8096").providers.single().nativeStartPath + val route = "/api/v2/auth/oauth/3/native/start" + assertEquals(route, path(""","native_start_path":"/api/v2/auth/oauth/3/native/start"""")) + assertEquals(route, path(""","native_start_path":"/prairie/api/v2/auth/oauth/3/native/start#x"""")) + assertEquals("$route?x=1", path(""","native_start_path":"/api/v2/auth/oauth/3/native/start?x=1"""")) + listOf( + ""","native_start_path":"/api/v2/auth/oauth/3/other"""", + ""","native_start_path":"//evil.example.test/api/v2/auth/oauth/3/native/start"""", + ""","native_start_path":"https://public.example.test/api/v2/auth/oauth/3/native/start"""", + ""","native_start_path":"/?next=/api/v2/auth/oauth/3/native/start"""", + "", + ).forEach { assertNull(path(it), it) } + } + + @Test + fun oauthHandshakeFixtureServesAppsAndLinking() { + val handshake = ApiV2Fixtures.decode<OAuthHandshakeCapabilitiesV2>( + ApiV2Fixtures.bodyObject("get_oauth_handshake_capabilities_ok").plusUnknown(), + ).domain() + assertTrue(handshake.available) + assertTrue(handshake.native) + assertTrue(handshake.linking) + assertTrue(handshake.selectAccount) + + // An older server's body without the newer members: absent means not served. + val older = ApiV2Fixtures.decode<OAuthHandshakeCapabilitiesV2>( + with(ApiV2Fixtures.bodyObject("get_oauth_handshake_capabilities_ok"), "select_account" to null), + ).domain() + assertTrue(older.native) + assertFalse(older.selectAccount, "absent select_account means sign-in starts don't take it") + + val disabled = ApiV2Fixtures.decode<OAuthHandshakeCapabilitiesV2>( + with(ApiV2Fixtures.bodyObject("get_oauth_handshake_capabilities_ok"), "state" to JsonPrimitive("disabled")), + ).domain() + assertFalse(disabled.native) + assertFalse(disabled.linking) + } + + @Test + fun externalSignInCapabilitiesFixtureServesIdentities() { + val capabilities = ApiV2Fixtures.decode<ExternalSignInCapabilitiesV2>( + ApiV2Fixtures.bodyObject("get_external_sign_in_capabilities_ok").plusUnknown(), + ).domain() + assertTrue(capabilities.identities) + // Directory linking needs no OAuth handshake, so this document reports it. + assertTrue(capabilities.credentialsLinking) + val older = ApiV2Fixtures.decode<ExternalSignInCapabilitiesV2>( + with(ApiV2Fixtures.bodyObject("get_external_sign_in_capabilities_ok"), "credentials_linking" to null), + ).domain() + assertFalse(older.credentialsLinking, "absent credentials_linking means the operation isn't served") + assertTrue(capabilities.networkSignIn) + val beforeNetwork = ApiV2Fixtures.decode<ExternalSignInCapabilitiesV2>( + with(ApiV2Fixtures.bodyObject("get_external_sign_in_capabilities_ok"), "network_sign_in" to null), + ).domain() + assertFalse(beforeNetwork.networkSignIn, "absent network_sign_in means the operations aren't served") + assertTrue(beforeNetwork.credentialsLinking) + val unsupported = ApiV2Fixtures.decode<ExternalSignInCapabilitiesV2>( + with(ApiV2Fixtures.bodyObject("get_external_sign_in_capabilities_ok"), "state" to JsonPrimitive("unsupported")), + ).domain() + assertFalse(unsupported.identities) + assertFalse(unsupported.credentialsLinking) + assertFalse(unsupported.networkSignIn) + } + + @Test + fun networkLinkKeepsPasswordDefaultsToFalseOnOlderServers() { + val fixture = ApiV2Fixtures.bodyObject("get_external_sign_in_capabilities_ok") + val keeps = ApiV2Fixtures.decode<ExternalSignInCapabilitiesV2>( + with(fixture, "network_link_keeps_password" to JsonPrimitive(true)), + ).domain() + assertTrue(keeps.networkLinkKeepsPassword) + val older = ApiV2Fixtures.decode<ExternalSignInCapabilitiesV2>(with(fixture, "network_link_keeps_password" to null)).domain() + assertFalse(older.networkLinkKeepsPassword, "absent network_link_keeps_password means a network link turns the password off") + val notServed = ApiV2Fixtures.decode<ExternalSignInCapabilitiesV2>( + with(fixture, "network_link_keeps_password" to JsonPrimitive(true), "network_sign_in" to JsonPrimitive(false)), + ).domain() + assertFalse(notServed.networkLinkKeepsPassword, "false while network sign-in isn't served") + } + + // --- Network identity (signInWithNetworkIdentity, linkAccountIdentityWithNetwork) --- + + @Test + fun networkSignInFixtureCarriesTheTokenPairLoginAnswers() { + val login = ApiV2Fixtures.decode<TokenPairV2>(ApiV2Fixtures.bodyObject("sign_in_with_network_identity_ok").plusUnknown()).domain() + assertEquals("acc", login.accessToken) + assertEquals("ref", login.refreshToken) + assertEquals(3600L, login.expiresIn) + assertEquals("laura", login.user.username) + // The contract's request is the one the app sends: a POST of an empty JSON object. + val request = ApiV2Fixtures.index.fixtures.single { it.name == "sign_in_with_network_identity_ok" }.request + assertEquals("POST", request.method) + assertEquals("{}", request.body) + assertEquals("/api/v2/auth/network/5/sign-in", org.prairieserver.prairie.network.api.networkSignInPath(request.path)) + } + + @Test + fun networkSignInOffTheOverlayIsNetworkIdentityRequired() { + val problem = ApiV2Fixtures.decode<Problem>(ApiV2Fixtures.bodyObject("sign_in_with_network_identity_off_overlay")) + assertEquals(403, problem.status) + assertEquals("network_identity_required", problem.code) + assertEquals( + org.prairieserver.prairie.model.auth.NetworkSignInFailure.NetworkIdentityRequired, + org.prairieserver.prairie.model.auth.NetworkSignInFailure.of(problem.status, problem.code), + ) + } + + @Test + fun networkLinkFixtureIsTheLinkedIdentity() { + val identity = ApiV2Fixtures.decode<AccountIdentityV2>( + ApiV2Fixtures.bodyObject("link_account_identity_with_network_ok").plusUnknown(), + ).domain() + assertEquals("8", identity.id) + assertEquals("5", identity.installationId) + assertEquals("Tailscale", identity.providerName) + assertEquals("alice@example.test", identity.accountLabel) + assertNull(identity.lastSignInAt) + } + + /** + * A network provider as `listAuthProviders` lists it over its own network + * (the provider list fixture is read off it, so it shows none): apps get + * the sign-in route relative to the server base, like the native start. + */ + @Test + fun networkProviderSignInPathIsReducedToTheBaseRelativeRoute() { + fun provider(fields: String) = ApiV2Fixtures.decode<AuthProviderCollectionV2>( + ApiV2Fixtures.json.parseToJsonElement( + """{"items":[{"id":"plugin:5:tailscale","display_name":"Tailscale","mode":"network","default":false, + "installation_id":"5"$fields}],"password_login":true}""", + ).jsonObject, + ).domain("https://prairie.tailnet.ts.net").providers.single() + val route = "/api/v2/auth/network/5/sign-in" + val listed = provider( + ""","network_sign_in_path":"$route","network_identity":{"display_name":"","username":"alice@example.test"}""", + ) + assertEquals(SignInProvider.Mode.Network, listed.mode) + assertEquals(route, listed.networkSignInPath) + assertEquals("alice@example.test", listed.networkIdentity?.label) + assertEquals(route, provider(""","network_sign_in_path":"/prairie/api/v2/auth/network/5/sign-in"""").networkSignInPath) + assertNull(provider("").networkIdentity) + listOf( + "", + ""","network_sign_in_path":"/api/v2/auth/network/5/other"""", + ""","network_sign_in_path":"//evil.example.test/api/v2/auth/network/5/sign-in"""", + ""","network_sign_in_path":"https://public.example.test/api/v2/auth/network/5/sign-in"""", + ""","network_sign_in_path":"/api/v2/auth/oauth/5/native/start"""", + ).forEach { assertNull(provider(it).networkSignInPath, it) } + // Without its path the provider offers nothing. + assertNull(SignInOptions.of(SignInProviders(listOf(provider("")), true), OAuthHandshakeCapabilities.None).networkProvider) + } + + @Test + fun nativeCompletionFixtureCarriesTokensAndUser() { + val login = ApiV2Fixtures.decode<TokenPairV2>(ApiV2Fixtures.bodyObject("complete_oauth_login_native_ok").plusUnknown()).domain() + assertEquals("acc", login.accessToken) + assertEquals("ref", login.refreshToken) + assertEquals(3600L, login.expiresIn) + assertEquals("laura", login.user.username) + } + + @Test + fun accountIdentitiesAndLinkTicketFixtures() { + val collection = ApiV2Fixtures.decode<AccountIdentityCollectionV2>( + ApiV2Fixtures.bodyObject("list_account_identities_ok").plusUnknown(), + ) + // The only identity and no local password: Disconnect is not offered. + assertEquals(false, collection.canUnlink) + val identity = collection.items.single().domain() + assertEquals("4", identity.id) + assertEquals("3", identity.installationId) + assertEquals("Company SSO", identity.providerName) + assertEquals("alice", identity.accountLabel) + assertEquals("2026-01-03T04:05:06.000Z", identity.lastSignInAt) + + val ticket = ApiV2Fixtures.decode<LinkTicketV2>(ApiV2Fixtures.bodyObject("create_account_identity_link_ticket_ok")) + assertTrue(ticket.ticket.isNotBlank()) + assertFalse(ticket.toString().contains(ticket.ticket), "the ticket never reaches a log line") + } + + @Test + fun externalSignInProblemFixturesMapToTheirCodes() { + fun raw(name: String) = ApiV2Fixtures.bodyObject(name).toString() + val wrongPassword = DefaultExternalSignInApi.locatedError(422, raw("create_account_identity_link_ticket_wrong_password")) + assertEquals(DefaultExternalSignInApi.WRONG_PASSWORD, wrongPassword.error) + assertEquals( + "last_sign_in_method", + DefaultExternalSignInApi.locatedError(409, raw("delete_account_identity_last_sign_in_method")).error, + ) + assertEquals("invalid_grant", ApiV2Fixtures.decode<Problem>(ApiV2Fixtures.bodyObject("complete_oauth_login_invalid_grant")).code) + assertEquals( + "invalid_grant", + ApiV2Fixtures.decode<Problem>(ApiV2Fixtures.bodyObject("complete_account_identity_link_invalid_grant")).code, + ) + assertEquals( + "provider_unavailable", + ApiV2Fixtures.decode<Problem>(ApiV2Fixtures.bodyObject("refresh_session_provider_unavailable")).code, + ) + } + + @Test + fun signInOptionsFromTheProvidersFixture() { + val providers = ApiV2Fixtures.decode<AuthProviderCollectionV2>(ApiV2Fixtures.bodyObject("list_auth_providers_ok")) + .domain("https://prairie.example.test") + val handshake = ApiV2Fixtures.decode<OAuthHandshakeCapabilitiesV2>( + ApiV2Fixtures.bodyObject("get_oauth_handshake_capabilities_ok"), + ).domain() + val options = SignInOptions.of(providers, handshake) + assertTrue(options.showPasswordForm) + assertEquals(listOf("Example SSO"), options.oauthProviders.map { it.displayName }) + assertNull(options.directoryProvider) + assertNull(options.networkProvider, "a listing that didn't come through a network provider offers none") + } } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/ApiV2NoFallbackTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/ApiV2NoFallbackTest.kt index aa71028dec..e3c3d11f75 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/ApiV2NoFallbackTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/ApiV2NoFallbackTest.kt @@ -20,9 +20,11 @@ import org.prairieserver.prairie.model.profile.UpdateProfileRequest import org.prairieserver.prairie.model.server.ServerContract import org.prairieserver.prairie.model.server.ServerEntry import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot import org.prairieserver.prairie.network.ServerRegistry import org.prairieserver.prairie.network.PrairieJson import org.prairieserver.prairie.network.api.AuthApi +import org.prairieserver.prairie.network.api.DefaultDeviceLoginApi import org.prairieserver.prairie.network.api.ProfileApi /** A failed v2 mutation is never replayed against another API major, and the update-server state blocks it outright. */ @@ -102,6 +104,30 @@ class ApiV2NoFallbackTest { assertEquals(emptyList(), recorded) } + @Test + fun aCallScopedToAnotherSavedServerIsGatedOnThatServersVerdict() = runTest { + val cabinCurrent = ServerEntry(id = "cabin", url = "https://cabin.example", contract = ServerContract.V2) + val staleActive = ApiV2Gate(ContractRegistry(ServerContract.UPDATE_REQUIRED, listOf(cabinCurrent))) + assertEquals(null, staleActive.forServer("cabin").blocked(), "the active server's verdict says nothing about cabin") + assertEquals(ApiV2Gate.UPDATE_REQUIRED_ERROR, staleActive.blocked()?.error) + + val cabinOutdated = cabinCurrent.copy(contract = ServerContract.UPDATE_REQUIRED) + val currentActive = ApiV2Gate(ContractRegistry(ServerContract.V2, listOf(cabinOutdated))) + assertEquals(ApiV2Gate.UPDATE_REQUIRED_ERROR, currentActive.forServer("cabin").blocked()?.error) + assertEquals(null, currentActive.blocked()) + + // Through the scoped device-login approval: cabin is reached although + // the active server needs an update. + val recorded = mutableListOf<String>() + val api = DefaultDeviceLoginApi(client(recorded, HttpStatusCode.OK, "{}"), staleActive) + api.lookupDeviceLoginForScope(AuthScopeSnapshot("cabin", null, "https://cabin.example", null), "48217730") + assertEquals(listOf("GET /api/v2/auth/device"), recorded) + val blocked = DefaultDeviceLoginApi(client(recorded, HttpStatusCode.OK, "{}"), currentActive) + .approveDeviceLoginForScope(AuthScopeSnapshot("cabin", null, "https://cabin.example", null), "48217730") + assertEquals(ApiV2Gate.UPDATE_REQUIRED_ERROR, assertIs<ApiResult.Error>(blocked).error) + assertEquals(1, recorded.size, "an outdated target is never called") + } + @Test fun unknownAndV2StatesDoNotBlock() { assertEquals(null, ApiV2Gate(ContractRegistry(ServerContract.UNKNOWN)).blocked()) @@ -110,9 +136,13 @@ class ApiV2NoFallbackTest { } } -private class ContractRegistry(contract: ServerContract) : ServerRegistry { +private class ContractRegistry( + contract: ServerContract, + /** Other saved servers, which scoped calls may target without switching. */ + others: List<ServerEntry> = emptyList(), +) : ServerRegistry { private val entry = ServerEntry(id = "active", url = "https://prairie.example", contract = contract) - override val entries: StateFlow<List<ServerEntry>> = MutableStateFlow(listOf(entry)) + override val entries: StateFlow<List<ServerEntry>> = MutableStateFlow(listOf(entry) + others) override val activeServerId: StateFlow<String?> = MutableStateFlow("active") override val activeEntry: StateFlow<ServerEntry?> = MutableStateFlow(entry) override suspend fun addOrUpdate(url: String, fetchedName: String?): String = "active" diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/CatalogDetailReadV2Test.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/CatalogDetailReadV2Test.kt index 7b6c90bb3b..50c6c604f2 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/CatalogDetailReadV2Test.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/CatalogDetailReadV2Test.kt @@ -15,13 +15,16 @@ class CatalogDetailReadV2Test { private fun detail(fileId: String) = """{"content_id":"m1","type":"movie","title":"Film","cast":[],"crew":[],"subtitles":[],"versions":[{"file_id":$fileId}],"user_data":{"last_file_id":"42"},"intro":{"start":1.5,"end":9.5}}""" @Test fun detailProjectsStringIdsAndCatalogMarkers() = runTest { - val client = client(detail("\"42\"")) + val body = detail("\"42\"").dropLast(1) + ",\"poster_is_episode_still\":true,\"backdrop_is_episode_still\":false}" + val client = client(body) try { val result = assertIs<ApiResult.Success<*>>(CatalogV2Api(client, ApiV2Gate.Unrestricted).itemDetail("m1")).data as org.prairieserver.prairie.model.catalog.ItemDetail assertEquals(42, result.versions.single().fileId) assertEquals(42, result.userData?.lastFileId) assertEquals(1.5, result.intro?.start) assertEquals(9.5, result.intro?.end) + assertEquals(true, result.posterIsEpisodeStill) + assertEquals(false, result.backdropIsEpisodeStill) } finally { client.close() } } @@ -80,7 +83,7 @@ class CatalogDetailReadV2Test { val client = HttpClient(MockEngine { request -> paths += request.url.encodedPath val body = when { - request.url.encodedPath.endsWith("/episodes") -> """{"items":[{"content_id":"e1","season_number":0,"episode_number":1,"files":[{"file_id":"2147483647"}],"user_data":{"last_file_id":"7"}}]}""" + request.url.encodedPath.endsWith("/episodes") -> """{"items":[{"content_id":"e1","season_number":0,"episode_number":1,"still_is_episode_still":false,"files":[{"file_id":"2147483647"}],"user_data":{"last_file_id":"7"}}]}""" request.url.encodedPath.endsWith("/people/7") -> """{"id":"7","name":"Person"}""" else -> { assertEquals("Person", request.url.parameters["q"]) @@ -95,6 +98,7 @@ class CatalogDetailReadV2Test { val episodes = assertIs<ApiResult.Success<org.prairieserver.prairie.model.catalog.EpisodesResponse>>(api.seasonEpisodes("series", 0)).data assertEquals(Int.MAX_VALUE, episodes.episodes.single().files.single().fileId) assertEquals(7, episodes.episodes.single().userData?.lastFileId) + assertEquals(false, episodes.episodes.single().stillIsEpisodeStill) assertEquals(7L, assertIs<ApiResult.Success<org.prairieserver.prairie.model.catalog.Person>>(api.person(7)).data.id) assertEquals(Long.MAX_VALUE, assertIs<ApiResult.Success<List<org.prairieserver.prairie.model.catalog.Person>>>(api.people("Person")).data.single().id) assertEquals(listOf("/api/v2/catalog/series/series/seasons/0/episodes", "/api/v2/catalog/people/7", "/api/v2/catalog/people"), paths) diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/HomeSectionsV2Test.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/HomeSectionsV2Test.kt index ef8a511347..7dff8502eb 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/HomeSectionsV2Test.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/HomeSectionsV2Test.kt @@ -4,10 +4,13 @@ import io.ktor.client.HttpClient import io.ktor.client.engine.mock.* import io.ktor.http.* import kotlinx.coroutines.* +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.prairieserver.prairie.network.* import org.prairieserver.prairie.network.api.SectionApi import org.prairieserver.prairie.repository.SectionRepository +import kotlin.coroutines.CoroutineContext import kotlin.test.* class HomeSectionsV2Test { @@ -37,6 +40,48 @@ class HomeSectionsV2Test { val sent = sends; assertIs<ApiResult.Error>(api.list(original)); assertEquals(sent, sends) } finally { client.close() } } + private class RecordingDispatcher : CoroutineDispatcher() { + var dispatches = 0 + override fun dispatch(context: CoroutineContext, block: Runnable) { dispatches++; Dispatchers.Default.dispatch(context, block) } + } + // With 100-item rows Home is over 1 MB; reading it on the caller's main thread stalled low-end TVs. + @Test fun homeResponsesAreReadAndDecodedOnTheDecodeDispatcher() = runTest { + val row = """{"id":"second","section_type":"continue_watching","title":"Second","items":[{"content_id":"movie:2","type":"movie","title":"Two"}]}""" + val client = HttpClient(MockEngine { + respond(if (it.url.encodedPath.endsWith("/items")) row else good, HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + }) + try { + val decode = RecordingDispatcher() + val api = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted, decodeDispatcher = decode) + api.list(owner).getOrThrow(); val afterList = decode.dispatches; assertTrue(afterList > 0) + assertEquals("second", api.section("second", owner).getOrThrow().section?.id); assertTrue(decode.dispatches > afterList) + } finally { client.close() } + } + @Test fun firstLoadJoinsARunningReadButRefreshesNeverDo() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + var sends = 0; var gate = CompletableDeferred<Unit>() + val client = HttpClient(MockEngine(MockEngineConfig().apply { + this.dispatcher = dispatcher + addHandler { sends++; gate.await(); respond(good, HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) } + })) + try { + val repository = SectionRepository(SectionApi(client, home = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted, decodeDispatcher = dispatcher))) + val warmup = async { repository.getHomeSections(owner) }; runCurrent() + val firstLoad = async { repository.getHomeSectionsJoiningRead(owner) }; runCurrent(); assertEquals(1, sends) + val refresh = async { repository.getHomeSections(owner) }; runCurrent(); assertEquals(2, sends) + gate.complete(Unit) + for (read in listOf(warmup, firstLoad, refresh)) assertEquals(listOf("second", "first"), read.await().getOrThrow().sections.map { it.id }) + assertEquals(2, sends) + // A joined read cancelled with its own caller leaves the first load to read for itself. + gate = CompletableDeferred() + val cancelled = async { repository.getHomeSections(owner) }; runCurrent() + val retried = async { repository.getHomeSectionsJoiningRead(owner) }; runCurrent(); assertEquals(3, sends) + cancelled.cancel(); runCurrent(); assertEquals(4, sends) + gate.complete(Unit); assertIs<ApiResult.Success<*>>(retried.await()) + // Nothing running: the first load reads normally. + assertIs<ApiResult.Success<*>>(repository.getHomeSectionsJoiningRead(owner)); assertEquals(5, sends) + } finally { client.close() } + } @Test fun suspendedAuthorityCannotPublishIntoReplacementPlaybackRun() = runTest { var sends = 0; var checks = 0; var run = 1; var published = false val entered = CompletableDeferred<Unit>(); val release = CompletableDeferred<Unit>() diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/MachineTranslationDecodingTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/MachineTranslationDecodingTest.kt new file mode 100644 index 0000000000..59fbf166cb --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/MachineTranslationDecodingTest.kt @@ -0,0 +1,107 @@ +package org.prairieserver.prairie.network.apiv2 + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import org.prairieserver.prairie.model.catalog.BrowseItem +import org.prairieserver.prairie.model.catalog.Season +import org.prairieserver.prairie.model.catalog.hasMachineTranslatedOverview +import org.prairieserver.prairie.model.catalog.pendingEpisodeTranslationLanguage +import org.prairieserver.prairie.network.PrairieJson +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class MachineTranslationDecodingTest { + private fun obj(json: String): JsonObject = PrairieJson.parseToJsonElement(json).jsonObject + + @Test + fun detailCarriesMachineTranslatedFieldsAndPendingLanguage() { + val detail = PrairieJson.decodeFromString<ItemDetailReadV2>( + """{"content_id":"m1","type":"movie","title":"Film","overview":"Tekst","tagline":"Regel", + "pending_translation_language":"nl","machine_translated_fields":["overview","tagline"], + "cast":[],"crew":[],"versions":[],"subtitles":[]}""", + ).toDomain() + + assertEquals(listOf("overview", "tagline"), detail.machineTranslatedFields) + assertEquals("nl", detail.pendingTranslationLanguage) + assertTrue(hasMachineTranslatedOverview(detail.machineTranslatedFields)) + } + + @Test + fun absentOrNullFieldsDecodeAsNotTranslated() { + val absent = PrairieJson.decodeFromString<ItemDetailReadV2>( + """{"content_id":"m1","type":"movie","title":"Film","cast":[],"crew":[],"versions":[],"subtitles":[]}""", + ).toDomain() + val nulled = PrairieJson.decodeFromString<ItemDetailReadV2>( + """{"content_id":"m1","type":"movie","title":"Film","machine_translated_fields":null, + "cast":[],"crew":[],"versions":[],"subtitles":[]}""", + ).toDomain() + + assertEquals(emptyList(), absent.machineTranslatedFields) + assertEquals(emptyList(), nulled.machineTranslatedFields) + assertFalse(hasMachineTranslatedOverview(absent.machineTranslatedFields)) + } + + @Test + fun episodeRowsCarryBothFields() { + val rows = listOf( + """{"content_id":"e1","season_number":1,"episode_number":1,"overview":"Vertaald","machine_translated_fields":["overview"]}""", + """{"content_id":"e2","season_number":1,"episode_number":2,"overview":"Original","pending_translation_language":"nl"}""", + ).map { PrairieJson.decodeFromString<EpisodeListItemReadV2>(it).toDomain() } + + assertEquals(listOf("overview"), rows[0].machineTranslatedFields) + assertNull(rows[0].pendingTranslationLanguage) + assertEquals("nl", rows[1].pendingTranslationLanguage) + assertEquals(emptyList(), rows[1].machineTranslatedFields) + assertEquals("nl", rows.pendingEpisodeTranslationLanguage()) + assertNull(rows.take(1).pendingEpisodeTranslationLanguage()) + } + + @Test + fun seasonRowsCarryMachineTranslatedFields() { + val season = PrairieJson.decodeFromString<Season>( + """{"content_id":"s1","season_number":1,"overview":"Vertaald","machine_translated_fields":["overview"]}""", + ) + + assertEquals(listOf("overview"), season.machineTranslatedFields) + } + + @Test + fun sectionCardsCarryBothFields() { + val section = decodeSectionV2( + obj( + """{"id":"featured","section_type":"featured","title":"Featured","items":[ + {"content_id":"m1","type":"movie","title":"Film","overview":"Vertaald","machine_translated_fields":["overview"]}, + {"content_id":"m2","type":"movie","title":"Other","overview":"Original","pending_translation_language":"nl"} + ]}""", + ), + ) + + assertEquals(listOf("overview"), section.items[0].machineTranslatedFields) + assertNull(section.items[0].pendingTranslationLanguage) + assertEquals("nl", section.items[1].pendingTranslationLanguage) + } + + @Test + fun catalogCardsCarryBothFields() { + val card = PrairieJson.decodeFromString<BrowseItem>( + """{"content_id":"m1","type":"movie","title":"Film","pending_translation_language":"nl","machine_translated_fields":["tagline"]}""", + ) + + assertEquals("nl", card.pendingTranslationLanguage) + assertEquals(listOf("tagline"), card.machineTranslatedFields) + } + + @Test + fun anAiTaglineAloneDoesNotMarkAProviderOverview() { + val detail = PrairieJson.decodeFromString<ItemDetailReadV2>( + """{"content_id":"m1","type":"movie","title":"Film","overview":"Anbieter: Text","tagline":"[German] Line", + "machine_translated_fields":["tagline"],"cast":[],"crew":[],"versions":[],"subtitles":[]}""", + ).toDomain() + + // Overview-only surfaces (detail heroes, episode rows, marquee, cards). + assertFalse(hasMachineTranslatedOverview(detail.machineTranslatedFields)) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/ProfileHouseholdV2Test.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/ProfileHouseholdV2Test.kt index 9bcad5497a..d9bcd386d7 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/ProfileHouseholdV2Test.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/ProfileHouseholdV2Test.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.* import org.prairieserver.prairie.model.profile.* import org.prairieserver.prairie.network.* +import org.prairieserver.prairie.network.api.HouseholdManager import org.prairieserver.prairie.network.api.ProfileApi import kotlin.test.* @@ -79,4 +80,76 @@ class ProfileHouseholdV2Test { assertFalse(api.deleteProfile("42") is ApiResult.Success) } finally { c.close() } } + + @Test fun managementActsAsPrimaryWithoutChangingSelection() = runTest { + scope = scope.copy(profileId = "kid", profileToken = "kid-proof") + val manager = HouseholdManager("primary", "primary-proof", scope) + var sends = 0 + val c = client { + sends++ + val pinned = it.attributes[AuthScopeAttributeKey] + assertEquals("primary", pinned.profileId) + assertEquals("primary-proof", pinned.profileToken) + if (it.method == HttpMethod.Delete) respond("", HttpStatusCode.NoContent) + else respond(profile, HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + } + try { + val api = ProfileApi(c, ApiV2Gate.Unrestricted, tokens) + assertIs<ApiResult.Success<Profile>>(api.updateProfile("42", UpdateProfileRequest(pin = "1234"), manager)) + assertIs<ApiResult.Success<Unit>>(api.deleteProfile("42", manager)) + assertEquals("kid", tokens.snapshotCurrentScope()?.profileId) + // A proof verified under another identity is never sent. + scope = scope.copy(identityGeneration = 2) + assertEquals("identity_changed", assertIs<ApiResult.Error>(api.deleteProfile("42", manager)).error) + assertEquals(2, sends) + } finally { c.close() } + } + + @Test fun managementWithoutScopeSetsExplicitHeaders() = runTest { + val c = client { + assertFalse(it.attributes.contains(AuthScopeAttributeKey)) + assertEquals("primary", it.headers["X-Profile-Id"]) + assertNull(it.headers["X-Profile-Token"]) + respond(profile, HttpStatusCode.Created, headersOf(HttpHeaders.ContentType, "application/json")) + } + try { + val api = ProfileApi(c, ApiV2Gate.Unrestricted) + assertIs<ApiResult.Success<Profile>>(api.createProfile(CreateProfileRequest("Reader"), HouseholdManager("primary", null, null))) + } finally { c.close() } + } + + @Test fun managerProofSurvivesTheDevicesProfileSwitchButNotASignIn() = runTest { + scope = scope.copy(profileId = "kid", profileToken = "kid-proof", credentialEpoch = 3) + val manager = HouseholdManager("primary", "primary-proof", scope) + var sends = 0 + val c = client { sends++; respond("", HttpStatusCode.NoContent) } + try { + val api = ProfileApi(c, ApiV2Gate.Unrestricted, tokens) + // The picker cleared the deleted, selected profile: same account. + scope = scope.copy(profileId = null, profileToken = null, identityGeneration = scope.identityGeneration + 1) + assertIs<ApiResult.Success<Unit>>(api.deleteProfile("42", manager)) + scope = scope.copy(credentialEpoch = 4) + assertEquals("identity_changed", assertIs<ApiResult.Error>(api.deleteProfile("42", manager)).error) + scope = scope.copy(credentialEpoch = 3, serverId = "other") + assertEquals("identity_changed", assertIs<ApiResult.Error>(api.deleteProfile("42", manager)).error) + assertEquals(1, sends) + } finally { c.close() } + } + + @Test fun verifyPinNeverDeclaresTheSelectedProfile() = runTest { + scope = scope.copy(profileId = "1", profileToken = "stale-proof") + val c = client { + val pinned = it.attributes[AuthScopeAttributeKey] + assertNull(pinned.profileId) + assertNull(pinned.profileToken) + assertEquals(scope.serverId, pinned.serverId) + assertEquals(scope.identityGeneration, pinned.identityGeneration) + respond("""{"valid":true,"profile_token":"fresh","expires_at":null}""", HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + } + try { + val api = ProfileApi(c, ApiV2Gate.Unrestricted, tokens) + assertEquals("fresh", assertIs<ApiResult.Success<VerifyPinResponse>>(api.verifyPin("1", "1234")).data.profileToken) + assertEquals("1", tokens.snapshotCurrentScope()?.profileId) + } finally { c.close() } + } } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/RequestsV2Test.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/RequestsV2Test.kt index 01e1490eb8..f75a8412c9 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/RequestsV2Test.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/RequestsV2Test.kt @@ -68,4 +68,44 @@ class RequestsV2Test { assertEquals(1, calls) client.close() } + + @Test fun adminQueueFiltersByTitleAndWalksPages() = runTest { + var calls = 0 + val client = HttpClient(MockEngine { request -> + assertEquals("/api/v2/admin/requests", request.url.encodedPath) + assertEquals("pending", request.url.parameters["status"]) + assertEquals("active", request.url.parameters["outcome"]) + assertEquals("series", request.url.parameters["media_type"]) + assertEquals("42", request.url.parameters["q"]) + calls++ + respond(if (calls == 1) """{"items":[$record],"page":{"has_more":true,"next_cursor":"next"}}""" + else """{"items":[$record],"page":{"has_more":false}}""", headers = headersOf(HttpHeaders.ContentType, "application/json")) + }) + val result = DefaultRequestsApi(client, ApiV2Gate.Unrestricted) + .adminRequests(status = "pending", outcome = "active", mediaType = "series", tmdbId = 42) + assertEquals(2, assertIs<ApiResult.Success<org.prairieserver.prairie.model.request.RequestsListResponse>>(result).data.requests.size) + assertEquals(2, calls) + client.close() + } + + @Test fun moderationIsOnePostToItsActionPath() = runTest { + var calls = 0 + val client = HttpClient(MockEngine { request -> + calls++ + assertEquals(HttpMethod.Post, request.method) + assertEquals("/api/v2/admin/requests/r1/retry", request.url.encodedPath) + respond(record.replace("\"pending\"", "\"approved\""), headers = headersOf(HttpHeaders.ContentType, "application/json")) + }) { install(ContentNegotiation) { json(PrairieJson) } } + val result = DefaultRequestsApi(client, ApiV2Gate.Unrestricted) + .adminAction("r1", org.prairieserver.prairie.model.request.AdminRequestAction.Retry) + assertEquals("approved", assertIs<ApiResult.Success<MediaRequest>>(result).data.status) + assertEquals(1, calls) + client.close() + } + + @Test fun userFacingStateDecodes() { + val decoded = PrairieJson.decodeFromString<MediaRequest>(record.replace("\"outcome\":\"active\"", "\"outcome\":\"active\",\"state\":\"processing\",\"approved_at\":\"2026-09-06T00:00:00Z\"")) + assertEquals("processing", decoded.state) + assertEquals("2026-09-06T00:00:00Z", decoded.approvedAt) + } } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/ShufflesV2Test.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/ShufflesV2Test.kt new file mode 100644 index 0000000000..5d71161b21 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/ShufflesV2Test.kt @@ -0,0 +1,160 @@ +package org.prairieserver.prairie.network.apiv2 + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.MockRequestHandleScope +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.HttpRequestData +import io.ktor.client.request.HttpResponseData +import io.ktor.http.* +import io.ktor.http.content.TextContent +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.test.runTest +import org.prairieserver.prairie.model.shuffle.ShuffleScopeKind +import org.prairieserver.prairie.network.* +import kotlin.test.* + +internal fun shuffleJson( + id: String = "sh1", + current: String = """{"content_id":"ep-1","type":"episode","title":"Pilot","series_title":"Show","season_number":1,"episode_number":1}""", + next: String = """{"content_id":"mv-2","type":"movie","title":"Film","backdrop_url":"/art/mv-2.jpg"}""", + scope: String = """{"kind":"season","id":"show-S1","title":"Season 1","parent_title":"Show"}""", +) = """{"id":"$id","scope":$scope,"current":$current,"next":$next,"created_at":"2026-10-05T10:00:00.000Z","updated_at":"2026-10-05T10:00:00.000Z"}""" + +class ShufflesV2Test { + private var owner = AuthScopeSnapshot("s", "p", "https://example.invalid", "pin", identityGeneration = 1) + private val tokens = object : TokenManager by TokenManagerImpl() { + override suspend fun snapshotCurrentScope() = owner + } + + private fun client(handler: suspend MockRequestHandleScope.(HttpRequestData) -> HttpResponseData) = + HttpClient(MockEngine(handler)) { install(ContentNegotiation) { json(PrairieJson) } } + + private fun MockRequestHandleScope.json(body: String, status: HttpStatusCode = HttpStatusCode.OK) = + respond(body, status, headersOf(HttpHeaders.ContentType, "application/json")) + + private fun MockRequestHandleScope.problem(status: HttpStatusCode, code: String) = respond( + """{"type":"https://prairieserver.org/docs/api/v2/problems/$code","title":"T","status":${status.value},"detail":"Nothing here can be played."}""", + status, + headersOf(HttpHeaders.ContentType, "application/problem+json"), + ) + + @Test fun capabilityGatesOnAvailableAndListedScopeKinds() = runTest { + var body = """{"state":"available","allowed":true,"revision":"r","scope_kinds":["library","season"]}""" + val c = client { + assertEquals("/api/v2/shuffles/capabilities", it.url.encodedPath) + assertEquals(HttpMethod.Get, it.method) + assertEquals(owner, it.attributes[AuthScopeAttributeKey]) + json(body) + } + try { + val api = ShufflesV2Api(c, tokens, ApiV2Gate.Unrestricted) + val capability = assertIs<ApiResult.Success<org.prairieserver.prairie.model.shuffle.ShuffleCapability>>(api.capability()).data + assertTrue(capability.supports(ShuffleScopeKind.LIBRARY)) + assertTrue(capability.supports(ShuffleScopeKind.SEASON)) + assertFalse(capability.supports(ShuffleScopeKind.SERIES)) + body = """{"state":"disabled","allowed":true,"revision":"r","scope_kinds":["library"]}""" + assertFalse(assertIs<ApiResult.Success<org.prairieserver.prairie.model.shuffle.ShuffleCapability>>(api.capability()).data.supports(ShuffleScopeKind.LIBRARY)) + body = """{"state":"available","allowed":false,"revision":"r","scope_kinds":["library"]}""" + assertFalse(assertIs<ApiResult.Success<org.prairieserver.prairie.model.shuffle.ShuffleCapability>>(api.capability()).data.supports(ShuffleScopeKind.LIBRARY)) + } finally { + c.close() + } + } + + @Test fun createPostsTheScopeOnceAndExpectsCreated() = runTest { + var sends = 0 + var status = HttpStatusCode.Created + val c = client { + sends++ + assertEquals("/api/v2/shuffles", it.url.encodedPath) + assertEquals(HttpMethod.Post, it.method) + assertEquals("large", it.url.parameters["image_size"]) + assertTrue(it.attributes[SingleAttemptAttributeKey]) + assertTrue(it.attributes[RequirePrairieAuthAttributeKey]) + assertEquals("""{"scope":{"kind":"user_collection","id":"c 1"}}""", (it.body as TextContent).text) + json(shuffleJson(), status) + } + try { + val api = ShufflesV2Api(c, tokens, ApiV2Gate.Unrestricted) + val shuffle = assertIs<ApiResult.Success<org.prairieserver.prairie.model.shuffle.Shuffle>>( + api.create(ShuffleScopeKind.USER_COLLECTION, "c 1"), + ).data + assertEquals("sh1", shuffle.id) + assertEquals("ep-1", shuffle.current.contentId) + assertEquals("mv-2", shuffle.next.contentId) + assertEquals("Show", shuffle.scope.parentTitle) + // A 200 is not the contract's 201. + status = HttpStatusCode.OK + assertFalse(api.create(ShuffleScopeKind.USER_COLLECTION, "c 1") is ApiResult.Success) + assertEquals(2, sends) + // A blank scope never reaches the server. + assertEquals(422, assertIs<ApiResult.Error>(api.create(ShuffleScopeKind.LIBRARY, " ")).code) + assertEquals(2, sends) + } finally { + c.close() + } + } + + @Test fun createSurfacesNotVisibleAndNothingPlayable() = runTest { + var status = HttpStatusCode.Conflict + val c = client { problem(status, if (status == HttpStatusCode.Conflict) "conflict" else "not_found") } + try { + val api = ShufflesV2Api(c, tokens, ApiV2Gate.Unrestricted) + val conflict = assertIs<ApiResult.Error>(api.create(ShuffleScopeKind.SEASON, "show-S1")) + assertEquals(409, conflict.code) + assertEquals("conflict", conflict.error) + status = HttpStatusCode.NotFound + assertEquals(404, assertIs<ApiResult.Error>(api.create(ShuffleScopeKind.SEASON, "show-S1")).code) + } finally { + c.close() + } + } + + @Test fun advanceSkipReadAndDeleteNameTheShuffleAndItsItem() = runTest { + val seen = mutableListOf<String>() + val c = client { + val body = (it.body as? TextContent)?.text.orEmpty() + seen += "${it.method.value} ${it.url.encodedPath} $body".trim() + if (it.method != HttpMethod.Get) assertTrue(it.attributes[SingleAttemptAttributeKey]) + if (it.method == HttpMethod.Delete) respond("", HttpStatusCode.NoContent) else json(shuffleJson(id = "a/b")) + } + try { + val api = ShufflesV2Api(c, tokens, ApiV2Gate.Unrestricted) + assertIs<ApiResult.Success<*>>(api.get("a/b")) + assertIs<ApiResult.Success<*>>(api.advance("a/b", "ep-1")) + assertIs<ApiResult.Success<*>>(api.skip("a/b", "mv-2")) + assertIs<ApiResult.Success<Unit>>(api.delete("a/b")) + assertEquals( + listOf( + "GET /api/v2/shuffles/a%2Fb", + """POST /api/v2/shuffles/a%2Fb/advance {"from_content_id":"ep-1"}""", + """POST /api/v2/shuffles/a%2Fb/skip {"next_content_id":"mv-2"}""", + "DELETE /api/v2/shuffles/a%2Fb", + ), + seen, + ) + } finally { + c.close() + } + } + + @Test fun rejectsShufflesWithoutPicksAndStaleOwners() = runTest { + var body = shuffleJson(next = """{"content_id":"","type":"movie","title":"Film"}""") + var late = false + val c = client { + if (late) owner = owner.copy(profileToken = "replacement") + json(body) + } + try { + val api = ShufflesV2Api(c, tokens, ApiV2Gate.Unrestricted) + assertEquals("invalid_response", assertIs<ApiResult.Error>(api.get("sh1")).error) + body = shuffleJson() + late = true + assertEquals("identity_changed", assertIs<ApiResult.Error>(api.get("sh1")).error) + } finally { + c.close() + } + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/SubtitleSyncV2Test.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/SubtitleSyncV2Test.kt new file mode 100644 index 0000000000..7f6ba35a23 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/SubtitleSyncV2Test.kt @@ -0,0 +1,146 @@ +package org.prairieserver.prairie.network.apiv2 + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.* +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.test.runTest +import org.prairieserver.prairie.model.subtitles.* +import org.prairieserver.prairie.network.* +import kotlin.test.* + +class SubtitleSyncV2Test { + private val scope = AuthScopeSnapshot("server", "profile", "https://example.invalid", null, identityGeneration = 1) + private val tokens = object : TokenManager by TokenManagerImpl() { override suspend fun snapshotCurrentScope() = scope } + private val key = "external-" + "0123456789abcdef".repeat(4) + + private fun state(timing: String = """{"offset_ms":0,"scale":1}""", sync: String? = null, file: String = "1", key: String = this.key) = + """{"key":"$key","media_file_id":"$file","source":"external","language":"en","format":"srt","label":"a.en.srt","timing":$timing""" + + (sync?.let { ""","sync":$it""" } ?: "") + "}" + + private val pending = """{"id":"2","status":"pending","trigger":"manual","phase":"queued","progress":0,"confidence":null,"created_at":"2026-10-04T02:37:35.235Z","finished_at":null}""" + + private fun client(handler: suspend MockRequestHandleScope.(io.ktor.client.request.HttpRequestData) -> io.ktor.client.request.HttpResponseData) = + HttpClient(MockEngine(handler)) { install(ContentNegotiation) { json(PrairieJson) } } + + private fun MockRequestHandleScope.reply(body: String, status: HttpStatusCode = HttpStatusCode.OK, etag: String? = null) = + respond( + body, + status, + if (etag == null) headersOf(HttpHeaders.ContentType, "application/json") + else headersOf(HttpHeaders.ContentType to listOf("application/json"), HttpHeaders.ETag to listOf(etag)), + ) + + @Test + fun listsAndStartsBySyncKey() = runTest { + val c = client { request -> + when (request.method to request.url.encodedPath) { + HttpMethod.Get to "/api/v2/subtitles/1/sync" -> reply("""{"subtitles":[${state()}]}""") + HttpMethod.Post to "/api/v2/subtitles/1/sync/$key" -> + reply("""{"subtitle":${state(sync = pending)}}""", HttpStatusCode.Accepted) + else -> error("unexpected ${request.method} ${request.url}") + } + } + try { + val api = SubtitleSyncV2Api(c, tokens, ApiV2Gate.Unrestricted) + val listed = assertIs<ApiResult.Success<List<SubtitleSyncState>>>(api.list(1)).data + assertEquals(listOf(key), listed.map { it.key }) + val started = assertIs<ApiResult.Success<SubtitleSyncState>>(api.start(1, key)).data + assertEquals("pending", started.sync?.status) + assertEquals("queued", started.sync?.phase) + } finally { + c.close() + } + } + + @Test + fun refusesAnswersForAnotherFileOrSubtitle() = runTest { + var body = """{"subtitles":[${state(file = "2")}]}""" + val c = client { reply(body, if (it.method == HttpMethod.Post) HttpStatusCode.Accepted else HttpStatusCode.OK) } + try { + val api = SubtitleSyncV2Api(c, tokens, ApiV2Gate.Unrestricted) + assertIs<ApiResult.Error>(api.list(1)) + body = """{"subtitle":${state(key = "stored-9")}}""" + assertIs<ApiResult.Error>(api.start(1, key)) + } finally { + c.close() + } + } + + @Test + fun neverPutsAMalformedKeyInAUrl() = runTest { + val c = client { error("no request expected: ${it.url}") } + try { + val api = SubtitleSyncV2Api(c, tokens, ApiV2Gate.Unrestricted) + for (bad in listOf("stored-0", "stored-x", "external-ABC", "../stored-9", "external-" + "a".repeat(63))) { + assertEquals("invalid_sync_key", assertIs<ApiResult.Error>(api.start(1, bad)).error) + } + assertTrue(isSubtitleSyncKey("stored-9")) + assertTrue(isSubtitleSyncKey(key)) + } finally { + c.close() + } + } + + @Test + fun resetSendsTheReadValidator() = runTest { + var puts = 0 + val c = client { request -> + when (request.method) { + HttpMethod.Get -> reply("""{"subtitle":${state(timing = """{"offset_ms":-3010,"scale":1}""")}}""", etag = "\"v1\"") + HttpMethod.Put -> { + puts++ + assertEquals("/api/v2/subtitles/1/sync/$key/timing", request.url.encodedPath) + assertEquals("\"v1\"", request.headers[HttpHeaders.IfMatch]) + val sent = PrairieJson.decodeFromString(SubtitleTiming.serializer(), request.body.toByteArray().decodeToString()) + assertEquals(SubtitleTiming(), sent) + reply("""{"subtitle":${state()}}""", etag = "\"v2\"") + } + else -> error("unexpected ${request.method}") + } + } + try { + val api = SubtitleSyncV2Api(c, tokens, ApiV2Gate.Unrestricted) + val reset = assertIs<ApiResult.Success<SubtitleSyncState>>(api.setTiming(1, key, SubtitleTiming())).data + assertTrue(reset.timing.isIdentity) + assertEquals(1, puts) + } finally { + c.close() + } + } + + @Test + fun aStaleValidatorOrADemoRefusalSurfacesItsStatus() = runTest { + var putStatus = HttpStatusCode.PreconditionFailed + val problem = """{"type":"https://prairieserver.org/docs/api/v2/problems/precondition_failed","title":"x","status":412,"detail":"stale"}""" + val c = client { request -> + when (request.method) { + HttpMethod.Get -> reply("""{"subtitle":${state()}}""", etag = "\"v1\"") + else -> reply(problem, putStatus) + } + } + try { + val api = SubtitleSyncV2Api(c, tokens, ApiV2Gate.Unrestricted) + assertEquals(412, assertIs<ApiResult.Error>(api.setTiming(1, key, SubtitleTiming())).code) + putStatus = HttpStatusCode.Forbidden + assertEquals(403, assertIs<ApiResult.Error>(api.start(1, key)).code) + } finally { + c.close() + } + } + + @Test + fun aReadWithoutValidatorDoesNotPut() = runTest { + val c = client { request -> + assertEquals(HttpMethod.Get, request.method) + reply("""{"subtitle":${state()}}""") + } + try { + val api = SubtitleSyncV2Api(c, tokens, ApiV2Gate.Unrestricted) + assertEquals("missing_etag", assertIs<ApiResult.Error>(api.setTiming(1, key, SubtitleTiming())).error) + } finally { + c.close() + } + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/WatchDetailMarkerV2Test.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/WatchDetailMarkerV2Test.kt new file mode 100644 index 0000000000..cd5c177f70 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/WatchDetailMarkerV2Test.kt @@ -0,0 +1,37 @@ +package org.prairieserver.prairie.network.apiv2 + +import kotlinx.serialization.json.jsonObject +import org.prairieserver.prairie.network.PrairieJson +import org.prairieserver.prairie.model.catalog.TimeRange +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class WatchDetailMarkerV2Test { + @Test fun selectedFileRangesSurviveWatchProjection() { + val body = PrairieJson.parseToJsonElement("""{ + "content_id":"m1","type":"movie","title":"Film", + "recap":{"start_seconds":10,"end_seconds":20}, + "versions":[{"file_id":"1","duration_seconds":100,"recap":{"start_seconds":10,"end_seconds":20}}, + {"file_id":"2","duration_seconds":100,"intro":{"start_seconds":1,"end_seconds":9},"recap":{"start_seconds":30,"end_seconds":40},"credits":{"start_seconds":50,"end_seconds":60},"preview":{"start_seconds":70,"end_seconds":80}}] + }""").jsonObject + val detail = decodeWatchDetail(body, "m1") + val selected = detail.versions.last() + assertEquals(TimeRange(10.0, 20.0), detail.recap) + assertEquals(TimeRange(1.0, 9.0), selected.intro) + assertEquals(TimeRange(30.0, 40.0), selected.recap) + assertEquals(TimeRange(50.0, 60.0), selected.credits) + assertEquals(TimeRange(70.0, 80.0), selected.preview) + } + + @Test fun selectedFileClearDoesNotAdoptOtherVersionRange() { + val body = PrairieJson.parseToJsonElement("""{"content_id":"m1","type":"movie","title":"Film", + "recap":{"start_seconds":10,"end_seconds":20}, + "versions":[{"file_id":"2","duration_seconds":100,"marker_segments":[]}]}""").jsonObject + val selected = decodeWatchDetail(body, "m1").versions.single() + assertNull(selected.intro) + assertNull(selected.recap) + assertNull(selected.credits) + assertNull(selected.preview) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/pairing/PairingProtocolGoldenFramesTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/pairing/PairingProtocolGoldenFramesTest.kt new file mode 100644 index 0000000000..b0a808c08b --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/pairing/PairingProtocolGoldenFramesTest.kt @@ -0,0 +1,148 @@ +package org.prairieserver.prairie.pairing + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +/** + * Literal frames shared with prairie-apple (`iosApp/Tests/PairingProtocolTests.swift`, + * `testLiteralWireFramesDecodeAndEncodeExactly`). A round trip passes even when + * a key is renamed on both sides of one codec, which would strand the other + * platform's phones and TVs; these literals fail instead. Frames compare as + * JSON objects because the two encoders order keys differently. + * + * Keep the two lists identical. When a frame changes here, change it in + * prairie-apple in the same release. + */ +class PairingProtocolGoldenFramesTest { + private val url = "https://media.example.com" + + /** Copied verbatim from prairie-apple. */ + private val appleFrames: List<Pair<String, PairingMessage>> = listOf( + """{"type":"hello","v":1,"tvName":"Living Room","tvDeviceId":"ABC-123","state":"setup","supportedVersions":[1]}""" to + PairingMessage.Hello("Living Room", "ABC-123", PairingReceiverState.Setup, listOf(1)), + """{"type":"pushServer","v":1,"serverURL":"https://media.example.com","serverName":"Home"}""" to + PairingMessage.PushServer(serverURL = url, serverName = "Home"), + """{"type":"deviceStarted","v":1,"serverURL":"https://media.example.com","userCode":"WXYZ-12","matchCode":"brave-otter"}""" to + PairingMessage.DeviceStarted(serverURL = url, userCode = "WXYZ-12", matchCode = "brave-otter"), + """{"type":"serverResult","v":1,"serverURL":"https://media.example.com","status":"signedIn"}""" to + PairingMessage.ServerResult(serverURL = url, status = PairingServerStatus.SignedIn, error = null), + """{"type":"serverResult","v":1,"serverURL":"https://media.example.com","status":"failed","error":"unreachable"}""" to + PairingMessage.ServerResult( + serverURL = url, + status = PairingServerStatus.Failed, + error = PairingFailureCode.Unreachable.wire, + ), + """{"type":"done","v":1}""" to PairingMessage.Done, + """{"type":"cancel","v":1,"reason":"user_declined"}""" to PairingMessage.Cancel(reason = "user_declined"), + ) + + /** + * Frames Android adds for the Phase 1 sign-in work. The pushServer frame + * is the shape prairie-apple's encoder writes for `pushServer(serverIdentity: + * endpoints:)`; the hello frame is a signed-out TV (`st=login`). Send these + * to prairie-apple for the same literal check. + */ + private val androidFrames: List<Pair<String, PairingMessage>> = listOf( + """{"type":"hello","v":1,"tvName":"Den","tvDeviceId":"id-9","state":"login","supportedVersions":[1]}""" to + PairingMessage.Hello("Den", "id-9", PairingReceiverState.Login, listOf(1)), + ( + """{"type":"pushServer","v":1,"serverURL":"https://media.example.com","serverName":"Home",""" + + """"serverIdentity":"96c1bd08-b839-4d47-980e-57d4e7a44cfa","endpoints":[""" + + """{"url":"https://media.example.com","kind":"public"},""" + + """{"url":"https://media.overlay.example","kind":"provider","provider":"tailscale","displayName":"Tailscale"}]}""" + ) to PairingMessage.PushServer( + serverURL = url, + serverName = "Home", + serverIdentity = "96c1bd08-b839-4d47-980e-57d4e7a44cfa", + endpoints = listOf( + PairingEndpoint.of(url, PairingEndpoint.Kind.Public), + PairingEndpoint.of( + "https://media.overlay.example", + PairingEndpoint.Kind.Provider, + provider = "tailscale", + displayName = "Tailscale", + ), + ), + ), + """{"type":"serverResult","v":1,"serverURL":"https://media.example.com","status":"failed","error":"identity_mismatch"}""" to + PairingMessage.ServerResult( + serverURL = url, + status = PairingServerStatus.Failed, + error = PairingFailureCode.IdentityMismatch.wire, + ), + ) + + @Test + fun literalFramesDecodeAndEncodeExactly() { + for ((frame, message) in appleFrames + androidFrames) { + assertEquals(message, PairingMessageCodec.decode(frame), frame) + assertEquals(json(frame), json(PairingMessageCodec.encode(message)), frame) + } + } + + @Test + fun pushServerIdentityFieldsAreOptionalOnTheWire() { + val legacy = PairingMessageCodec.decode( + """{"type":"pushServer","v":1,"serverURL":"https://media.example.com","serverName":"Home"}""", + ) as PairingMessage.PushServer + assertNull(legacy.serverIdentity) + assertNull(legacy.endpoints) + + val encoded = json(PairingMessageCodec.encode(PairingMessage.PushServer(url, null))) + assertNull(encoded["serverIdentity"]) + assertNull(encoded["endpoints"]) + + // Endpoint URLs cross the wire without a trailing slash, as prairie-apple normalizes them. + val full = json( + PairingMessageCodec.encode( + PairingMessage.PushServer( + serverURL = url, + serverName = "Home", + serverIdentity = "S", + endpoints = listOf( + PairingEndpoint.of( + "https://media.overlay.example/", + PairingEndpoint.Kind.Provider, + provider = "tailscale", + displayName = "Tailscale", + ), + ), + ), + ), + ) + val endpoint = (full["endpoints"] as kotlinx.serialization.json.JsonArray)[0].jsonObject + assertEquals("\"https://media.overlay.example\"", endpoint["url"].toString()) + assertEquals("\"provider\"", endpoint["kind"].toString()) + } + + @Test + fun explicitNullOptionalFieldsDecodeAsAbsent() { + val push = PairingMessageCodec.decode( + """{"type":"pushServer","v":1,"serverURL":"https://media.example.com","serverName":null,"serverIdentity":null}""", + ) as PairingMessage.PushServer + assertNull(push.serverName) + assertNull(push.serverIdentity) + } + + @Test + fun failureCodesReadUnknownAsGenericFailure() { + assertEquals(PairingFailureCode.Unreachable, PairingFailureCode.fromWire("unreachable")) + assertEquals(PairingFailureCode.IdentityMismatch, PairingFailureCode.fromWire("identity_mismatch")) + assertEquals(PairingFailureCode.AuthFailed, PairingFailureCode.fromWire(null)) + assertEquals(PairingFailureCode.AuthFailed, PairingFailureCode.fromWire("something_new")) + // Older Android TVs sent human text here; it reads as the generic failure. + assertEquals(PairingFailureCode.AuthFailed, PairingFailureCode.fromWire("Sign-in was denied on the other device.")) + } + + @Test + fun unknownTypeFailsToDecode() { + assertFailsWith<PairingMessageException> { PairingMessageCodec.decode("""{"type":"bogus","v":1}""") } + } + + private fun json(text: String): JsonObject = Json.parseToJsonElement(text).jsonObject +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/PlaybackMarkersUpdateTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/PlaybackMarkersUpdateTest.kt index 935302136a..70d2c89f1e 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/PlaybackMarkersUpdateTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/PlaybackMarkersUpdateTest.kt @@ -52,4 +52,10 @@ class PlaybackMarkersUpdateTest { val m = decodeMarkersUpdate(payload("""{"credits":{"start":"10","end":"20"}}""")) assertNull(m.credits) } + + @Test fun keepsTheFileTheRangesBelongTo() { + assertEquals(7, decodeMarkersUpdate(payload("""{"file_id":7}""")).fileId) + assertNull(decodeMarkersUpdate(payload("""{"intro":null}""")).fileId) + assertNull(decodeMarkersUpdate(payload("""{"file_id":"7"}""")).fileId) + } } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/PlaybackPartsTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/PlaybackPartsTest.kt new file mode 100644 index 0000000000..3a0fcbed57 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/PlaybackPartsTest.kt @@ -0,0 +1,52 @@ +package org.prairieserver.prairie.playback + +import org.prairieserver.prairie.model.catalog.FileVersion +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertSame + +class PlaybackPartsTest { + private fun part(id: Int, index: Int?, resolution: String = "1080p", group: String = "Film", edition: String? = null, kind: String = "multipart_movie") = + FileVersion( + fileId = id, + resolution = resolution, + presentationKind = kind, + presentationGroupKey = group, + presentationPartIndex = index, + editionKey = edition, + ) + + @Test fun nextPartIsTheLowestLaterPartOfTheSamePresentation() { + val versions = listOf( + part(1, 1), part(2, 2), part(3, 3), + part(4, 2, resolution = "720p"), + part(5, 2, edition = "extended"), + part(6, 2, group = "Other"), + ) + assertEquals(2, nextPlaybackPartFileId(versions, 1)) + assertEquals(3, nextPlaybackPartFileId(versions, 2)) + assertNull(nextPlaybackPartFileId(versions, 3)) + // The current file's resolution wins when a part has several versions. + assertEquals(4, nextPlaybackPartFileId(listOf(part(7, 1, resolution = "720p"), part(2, 2), part(4, 2, resolution = "720p")), 7)) + } + + @Test fun ordinaryFilesAndSplitEpisodesFollowTheirOwnRules() { + val single = FileVersion(fileId = 9, resolution = "1080p") + assertNull(nextPlaybackPartFileId(listOf(single), 9)) + assertNull(nextPlaybackPartFileId(listOf(single), null)) + val split = listOf(part(1, 1, kind = "split_episode"), part(2, 2, kind = "split_episode")) + assertEquals(2, nextPlaybackPartFileId(split, 1)) + val audiobook = listOf(part(1, 1, kind = "audiobook_part"), part(2, 2, kind = "audiobook_part")) + assertNull(nextPlaybackPartFileId(audiobook, 1)) + } + + @Test fun aStartFromTheBeginningOpensTheFirstPart() { + val versions = listOf(part(2, 2), part(1, 1), part(3, 1, resolution = "720p"), part(4, 2, resolution = "720p")) + assertEquals(1, firstPlaybackPart(versions, versions[0]).fileId) + assertEquals(3, firstPlaybackPart(versions, versions[3]).fileId) + assertSame(versions[1], firstPlaybackPart(versions, versions[1])) + val single = FileVersion(fileId = 9, resolution = "1080p") + assertSame(single, firstPlaybackPart(listOf(single), single)) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/PlaybackSubtitleSyncEventsTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/PlaybackSubtitleSyncEventsTest.kt new file mode 100644 index 0000000000..8227bc24e4 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/PlaybackSubtitleSyncEventsTest.kt @@ -0,0 +1,99 @@ +package org.prairieserver.prairie.playback + +import kotlinx.serialization.json.JsonObject +import org.prairieserver.prairie.model.playback.PlaybackSubtitleInventoryItemV3 +import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo +import org.prairieserver.prairie.model.playback.SubtitleIdentity +import org.prairieserver.prairie.model.subtitles.SubtitleSyncJob +import org.prairieserver.prairie.model.subtitles.SubtitleTiming +import org.prairieserver.prairie.network.PrairieJson +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PlaybackSubtitleSyncEventsTest { + private val sidecarKey = "external-" + "a".repeat(64) + + private fun payload(json: String) = PrairieJson.parseToJsonElement(json) as JsonObject + + @Test + fun decodesSyncProgressAndOutcome() { + val running = decodePlaybackSubtitleSyncUpdated( + payload( + """{"session_id":"s","file_id":1,"sync_key":"$sidecarKey","timing":{"offset_ms":0,"scale":1}, + "job":{"id":"2","status":"running","trigger":"manual","phase":"analyzing","progress":0.3, + "confidence":null,"created_at":"2026-10-04T02:37:35.235Z","finished_at":null}}""", + ), + ) + assertNotNull(running) + assertEquals("s", running.sessionId) + assertEquals(1, running.mediaFileId) + assertEquals(sidecarKey, running.syncKey) + assertNull(running.subtitleId) + assertEquals(0.3, running.job.progress) + assertEquals(SubtitleSyncJob.PHASE_ANALYZING, running.job.phase) + + val synced = decodePlaybackSubtitleSyncUpdated( + payload( + """{"session_id":"s","file_id":1,"sync_key":"stored-9","subtitle_id":9,"timing":{"offset_ms":-3010,"scale":1}, + "job":{"id":"3","status":"synced","trigger":"auto","confidence":1,"result":{"offset_ms":-3010,"scale":1}, + "created_at":"2026-10-04T02:37:35.235Z","finished_at":"2026-10-04T02:37:35.351Z"}}""", + ), + ) + assertNotNull(synced) + assertEquals(9, synced.subtitleId) + assertEquals(SubtitleTiming(-3010, 1.0), synced.timing) + assertEquals(SubtitleTiming(-3010, 1.0), synced.job.result) + } + + @Test + fun dropsAnUnreadableSyncUpdate() { + assertNull(decodePlaybackSubtitleSyncUpdated(payload("""{"session_id":"s","file_id":1,"timing":{},"job":{"status":"running"}}"""))) + assertNull(decodePlaybackSubtitleSyncUpdated(payload("""{"session_id":"s","file_id":1,"sync_key":"stored-9","job":{"status":"running"}}"""))) + assertNull(decodePlaybackSubtitleSyncUpdated(payload("""{"sync_key":"stored-9","timing":{},"job":{"progress":"x"}}"""))) + } + + @Test + fun aSidecarTimingChangeMatchesTheMountedTrackBySyncKey() { + val update = decodePlaybackSubtitleTimingChanged( + payload("""{"session_id":"s","file_id":1,"sync_key":"$sidecarKey"}"""), + ) + assertEquals(sidecarKey, update.syncKey) + assertNull(update.subtitleId) + val sidecar = PlayerSubtitleInfo(index = 0, source = "external", url = "/stream/s/subtitles/0.vtt", syncKey = sidecarKey) + assertTrue(update.affects(listOf(sidecar))) + assertFalse(update.affects(listOf(sidecar.copy(syncKey = "external-" + "b".repeat(64))))) + assertFalse(update.affects(emptyList())) + } + + @Test + fun aStoredKeyFindsARowFromAServerThatPublishesNoKey() { + val row = PlayerSubtitleInfo(index = 3, source = "downloaded", url = "/stream/s/subtitles/3.vtt?downloaded_subtitle_id=9") + assertTrue(listOf(row).includesSyncKey("stored-9")) + assertFalse(listOf(row).includesSyncKey("stored-90")) + // A row with a key is matched by its key only. + assertFalse(listOf(row.copy(syncKey = "stored-10")).includesSyncKey("stored-9")) + } + + @Test + fun subtitleReadyCarriesTheInventorySyncKey() { + val existing = listOf(PlayerSubtitleInfo(index = 0, source = "external", url = "/0.vtt", serverTrackId = "file:1:subtitle:0", syncKey = sidecarKey)) + val track = PrairieJson.decodeFromString( + PlaybackSubtitleInventoryItemV3.serializer(), + """{"track_id":"file:1:subtitle:1","combined_index":1,"source":"downloaded","codec":"srt","language":"en", + "delivery":"sidecar","url":"/stream/s/subtitles/1.vtt?downloaded_subtitle_id=9","sync_key":"stored-9"}""", + ) + val rows = applyAuthoritativeSubtitleReadyTrack(existing, PlaybackSubtitleReady("s", 1, 9, track)) + assertNotNull(rows) + assertEquals(listOf(sidecarKey, "stored-9"), rows.map { it.syncKey }) + assertEquals("stored-9", rows.syncKeyFor(SubtitleIdentity.ServerSidecar(1))) + assertNull(rows.syncKeyFor(SubtitleIdentity.Off)) + // The selected track is the active one only while it is mounted; a + // legacy session mounts every row. + assertEquals("stored-9", rows.activeSyncKey(SubtitleIdentity.ServerSidecar(1), mounted = rows)) + assertNull(rows.activeSyncKey(SubtitleIdentity.ServerSidecar(1), mounted = rows.take(1))) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/PlaybackSubtitleTimingChangedTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/PlaybackSubtitleTimingChangedTest.kt new file mode 100644 index 0000000000..405bf91f2c --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/PlaybackSubtitleTimingChangedTest.kt @@ -0,0 +1,51 @@ +package org.prairieserver.prairie.playback + +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PlaybackSubtitleTimingChangedTest { + private fun sidecar(url: String, downloadId: Int? = null) = + PlayerSubtitleInfo(index = 3, source = "downloaded", url = url, downloadId = downloadId) + + @Test + fun decodesTheServerPayload() { + val update = decodePlaybackSubtitleTimingChanged( + buildJsonObject { + put("session_id", "s") + put("file_id", 42) + put("subtitle_id", 9) + }, + ) + assertEquals(PlaybackSubtitleTimingChanged("s", 42, 9), update) + } + + @Test + fun readsTheStoredIdPinnedOnAV3SidecarUrl() { + assertEquals( + 9, + sidecar("/stream/s/subtitles/3.vtt?file_id=42&downloaded_subtitle_id=9").storedSubtitleId(), + ) + assertEquals( + 9, + sidecar("/stream/s/subtitles/3.vtt?downloaded_subtitle_id=9&file_id=42").storedSubtitleId(), + ) + assertEquals(17, sidecar("/stream/s/subtitles/3.vtt", downloadId = 17).storedSubtitleId()) + assertNull(sidecar("/stream/s/subtitles/3.vtt?file_id=42").storedSubtitleId()) + assertEquals(90, sidecar("/stream/s/subtitles/3.vtt?downloaded_subtitle_id=90").storedSubtitleId()) + } + + @Test + fun affectsOnlyTheMountedStoredSubtitle() { + val update = PlaybackSubtitleTimingChanged("s", 42, 9) + assertTrue(update.affects(listOf(sidecar("/stream/s/subtitles/3.vtt?file_id=42&downloaded_subtitle_id=9")))) + assertFalse(update.affects(listOf(sidecar("/stream/s/subtitles/3.vtt?file_id=42&downloaded_subtitle_id=90")))) + assertFalse(update.affects(emptyList())) + assertFalse(update.copy(subtitleId = null).affects(listOf(sidecar("/x?downloaded_subtitle_id=9")))) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/ShufflePlaybackTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/ShufflePlaybackTest.kt new file mode 100644 index 0000000000..a84af09455 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/ShufflePlaybackTest.kt @@ -0,0 +1,213 @@ +package org.prairieserver.prairie.playback + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.* +import io.ktor.http.content.TextContent +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runTest +import org.prairieserver.prairie.model.shuffle.ShuffleScope +import org.prairieserver.prairie.network.* +import org.prairieserver.prairie.network.apiv2.ApiV2Gate +import org.prairieserver.prairie.network.apiv2.ShufflesV2Api +import org.prairieserver.prairie.network.apiv2.shuffleJson +import kotlin.test.* + +class ShufflePlaybackTest { + private val owner = AuthScopeSnapshot("s", "p", "https://example.invalid", "pin", identityGeneration = 1) + private val tokens = object : TokenManager by TokenManagerImpl() { + override suspend fun snapshotCurrentScope() = owner + } + + private val episodeCard = + """{"content_id":"ep-1","type":"episode","title":"Pilot","series_title":"Show","season_number":0,"episode_number":2,"poster_url":"/still.jpg","backdrop_url":"/backdrop.jpg","runtime":42,"overview":"O"}""" + private val movieCard = + """{"content_id":"mv-2","type":"movie","title":"Film","series_title":"Ignored","poster_url":"/poster.jpg","backdrop_url":"/backdrop.jpg","runtime":95}""" + + /** A session against a fake server whose next answer each test sets. */ + private class FakeServer { + var status: HttpStatusCode = HttpStatusCode.OK + var body: String = shuffleJson() + val requests = mutableListOf<String>() + } + + private fun session(server: FakeServer): Pair<ShufflePlayback, HttpClient> { + val client = HttpClient(MockEngine { + server.requests += "${it.method.value} ${it.url.encodedPath} ${(it.body as? TextContent)?.text.orEmpty()}".trim() + if (server.status.isSuccess()) { + respond(server.body, server.status, headersOf(HttpHeaders.ContentType, "application/json")) + } else { + respond( + """{"type":"https://prairieserver.org/docs/api/v2/problems/x","title":"T","status":${server.status.value},"detail":"d"}""", + server.status, + headersOf(HttpHeaders.ContentType, "application/problem+json"), + ) + } + }) { install(ContentNegotiation) { json(PrairieJson) } } + return ShufflePlayback(ShufflesV2Api(client, tokens, ApiV2Gate.Unrestricted), "sh1") to client + } + + @Test fun scopeLabelNamesASeasonUnderItsSeries() { + assertEquals("Movies", ShuffleScope("library", "1", "Movies").label()) + assertEquals("Breaking Bad · Season 2", ShuffleScope("season", "s", "Season 2", "Breaking Bad").label()) + assertEquals("Specials", ShuffleScope("season", "s", "Specials", " ").label()) + } + + @Test fun nextPickShowsAnEpisodeWithItsStillAndAMovieWithItsTitleAndBackdrop() = runTest { + val server = FakeServer() + val (playback, client) = session(server) + try { + server.body = shuffleJson(current = movieCard, next = episodeCard) + assertIs<ApiResult.Success<*>>(playback.refresh()) + val episode = assertNotNull(playback.nextPickAfter("mv-2")) + assertTrue(episode.isEpisode) + assertEquals("Show", episode.seriesTitle) + // Specials are season 0 and still show their numbers. + assertEquals(0, episode.seasonNumber) + assertEquals(2, episode.episodeNumber) + assertEquals("http://localhost/still.jpg", episode.stillUrl) + assertEquals(42, episode.runtimeMinutes) + assertTrue(episode.isUnwatched) + + server.body = shuffleJson(current = episodeCard, next = movieCard) + playback.refresh() + val movie = assertNotNull(playback.nextPickAfter("ep-1")) + assertFalse(movie.isEpisode) + assertFalse(movie.isUnwatched) + assertEquals("Film", movie.title) + assertNull(movie.seriesTitle) + assertEquals(0, movie.seasonNumber) + assertEquals(0, movie.episodeNumber) + assertEquals("http://localhost/backdrop.jpg", movie.stillUrl) + assertEquals("Show · Season 1", playback.scopeLabel) + } finally { + client.close() + } + } + + @Test fun aScopeWithOnePlayableItemIsFinished() = runTest { + val server = FakeServer().apply { body = shuffleJson(current = movieCard, next = movieCard) } + val (playback, client) = session(server) + try { + playback.refresh() + assertNull(playback.nextPickAfter("mv-2")) + } finally { + client.close() + } + } + + @Test fun conflictMeansFinishedButOtherFailuresKeepTheLastPick() = runTest { + val server = FakeServer() + val (playback, client) = session(server) + try { + playback.refresh() + assertEquals("mv-2", playback.nextPickAfter("ep-1")?.contentId) + + server.status = HttpStatusCode.InternalServerError + assertIs<ApiResult.Error>(playback.refresh()) + assertFalse(playback.exhausted) + assertEquals("mv-2", playback.nextPickAfter("ep-1")?.contentId) + + server.status = HttpStatusCode.Conflict + assertEquals(409, assertIs<ApiResult.Error>(playback.refresh()).code) + assertTrue(playback.exhausted) + assertNull(playback.nextPickAfter("ep-1")) + + // A later successful read lifts the finished state. + server.status = HttpStatusCode.OK + playback.refresh() + assertFalse(playback.exhausted) + assertEquals("mv-2", playback.nextPickAfter("ep-1")?.contentId) + } finally { + client.close() + } + } + + @Test fun aReadThatPickAnotherOvertookCannotPutBackTheReplacedPick() = runTest { + val readGate = kotlinx.coroutines.CompletableDeferred<Unit>() + var reads = 0 + val client = HttpClient(MockEngine { + val skipped = it.url.encodedPath.endsWith("/skip") + if (!skipped && reads++ == 1) readGate.await() + val next = if (skipped) """{"content_id":"mv-3","type":"movie","title":"Other"}""" else movieCard + respond(shuffleJson(current = episodeCard, next = next), HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + }) { install(ContentNegotiation) { json(PrairieJson) } } + try { + val playback = ShufflePlayback(ShufflesV2Api(client, tokens, ApiV2Gate.Unrestricted), "sh1") + playback.refresh() + val slowRead = async { playback.refresh() } + kotlinx.coroutines.yield() + playback.pickAnother() + assertEquals("mv-3", playback.nextPickAfter("ep-1")?.contentId) + readGate.complete(Unit) + assertIs<ApiResult.Success<*>>(slowRead.await()) + assertEquals("mv-3", playback.nextPickAfter("ep-1")?.contentId) + } finally { + client.close() + } + } + + @Test fun aReadStartedDuringPickAnotherCannotPutBackTheReplacedPick() = runTest { + val skipGate = kotlinx.coroutines.CompletableDeferred<Unit>() + val readGate = kotlinx.coroutines.CompletableDeferred<Unit>() + var reads = 0 + val client = HttpClient(MockEngine { + val skipped = it.url.encodedPath.endsWith("/skip") + if (skipped) skipGate.await() else if (reads++ == 1) readGate.await() + val next = if (skipped) """{"content_id":"mv-3","type":"movie","title":"Other"}""" else movieCard + respond(shuffleJson(current = episodeCard, next = next), HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + }) { install(ContentNegotiation) { json(PrairieJson) } } + try { + val playback = ShufflePlayback(ShufflesV2Api(client, tokens, ApiV2Gate.Unrestricted), "sh1") + playback.refresh() + val skip = async { playback.pickAnother() } + kotlinx.coroutines.yield() + // The read starts while the skip waits for the server, which + // answers it with the pick the skip is replacing. + val slowRead = async { playback.refresh() } + kotlinx.coroutines.yield() + skipGate.complete(Unit) + skip.await() + assertEquals("mv-3", playback.nextPickAfter("ep-1")?.contentId) + readGate.complete(Unit) + assertIs<ApiResult.Success<*>>(slowRead.await()) + assertEquals("mv-3", playback.nextPickAfter("ep-1")?.contentId) + } finally { + client.close() + } + } + + @Test fun pickAnotherSkipsTheAnnouncedPickAndAdvanceNamesTheItemThatPlayed() = runTest { + val server = FakeServer() + val (playback, client) = session(server) + try { + // Nothing announced yet: Pick Another has nothing to replace. + assertIs<ApiResult.Error>(playback.pickAnother()) + assertTrue(server.requests.isEmpty()) + + playback.refresh() + server.body = shuffleJson(next = """{"content_id":"mv-3","type":"movie","title":"Other"}""") + assertIs<ApiResult.Success<*>>(playback.pickAnother()) + assertEquals("mv-3", playback.nextPickAfter("ep-1")?.contentId) + + server.body = shuffleJson(current = """{"content_id":"mv-3","type":"movie","title":"Other"}""", next = episodeCard) + val advanced = assertIs<ApiResult.Success<org.prairieserver.prairie.model.shuffle.Shuffle>>(playback.advance("ep-1")).data + assertEquals("mv-3", advanced.current.contentId) + assertEquals("ep-1", playback.nextPickAfter("mv-3")?.contentId) + + assertEquals( + listOf( + "GET /api/v2/shuffles/sh1", + """POST /api/v2/shuffles/sh1/skip {"next_content_id":"mv-2"}""", + """POST /api/v2/shuffles/sh1/advance {"from_content_id":"ep-1"}""", + ), + server.requests, + ) + } finally { + client.close() + } + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/SubtitleSyncControllerTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/SubtitleSyncControllerTest.kt new file mode 100644 index 0000000000..cdf0d69e6e --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/SubtitleSyncControllerTest.kt @@ -0,0 +1,325 @@ +package org.prairieserver.prairie.playback + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.prairieserver.prairie.model.subtitles.DownloadedSubtitle +import org.prairieserver.prairie.model.subtitles.SubtitleSyncCapability +import org.prairieserver.prairie.model.subtitles.SubtitleSyncJob +import org.prairieserver.prairie.model.subtitles.SubtitleSyncState +import org.prairieserver.prairie.model.subtitles.SubtitleTiming +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.repository.SubtitleSyncSource +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class SubtitleSyncControllerTest { + private val sidecar = "external-" + "a".repeat(64) + private val stored = "stored-9" + private val corrected = SubtitleTiming(offsetMs = -3_010) + + private class FakeSource : SubtitleSyncSource { + var capability = SubtitleSyncCapability(state = "available", autoSync = true, external = true, allowed = true) + val states = linkedMapOf<String, SubtitleSyncState>() + val reads = mutableListOf<String>() + var readError: ApiResult.Error? = null + var startError: ApiResult.Error? = null + /** When set, a start request waits for it before answering. */ + var startGate: CompletableDeferred<Unit>? = null + /** When set, the next read takes its answer, then waits for it before returning. */ + var readGate: CompletableDeferred<Unit>? = null + var jobs = 0 + + override suspend fun syncCapability() = ApiResult.Success(capability) + override suspend fun listSync(mediaFileId: Int) = ApiResult.Success(states.values.toList()) + override suspend fun readSync(mediaFileId: Int, key: String): ApiResult<SubtitleSyncState> { + reads += key + readError?.let { return it } + val response = states[key]?.let { ApiResult.Success(it) } ?: ApiResult.Error(404, "not_found", "") + readGate?.also { readGate = null }?.await() + return response + } + override suspend fun startSync(mediaFileId: Int, key: String): ApiResult<SubtitleSyncState> { + startError?.let { return it } + val job = SubtitleSyncJob(id = "j${++jobs}", status = SubtitleSyncJob.PENDING, trigger = "manual", phase = "queued", progress = 0.0) + val response = states.getValue(key).copy(sync = job).also { states[key] = it } + startGate?.await() + return ApiResult.Success(response) + } + override suspend fun resetTiming(mediaFileId: Int, key: String): ApiResult<SubtitleSyncState> = + ApiResult.Success(states.getValue(key).copy(timing = SubtitleTiming()).also { states[key] = it }) + + fun finish(key: String, status: String, timing: SubtitleTiming = states.getValue(key).timing) { + val state = states.getValue(key) + states[key] = state.copy(timing = timing, sync = state.sync?.copy(status = status, phase = null, progress = null, result = timing)) + } + } + + private fun sidecarState(timing: SubtitleTiming = SubtitleTiming()) = + SubtitleSyncState(key = sidecar, mediaFileId = "1", source = "external", language = "en", format = "srt", label = "a.en.srt", timing = timing) + + private fun storedState() = + SubtitleSyncState(key = stored, mediaFileId = "1", source = "downloaded", storedSubtitleId = "9", language = "fr", format = "srt", label = "release") + + private fun TestScope.controller(source: FakeSource, changes: MutableList<String>) = + SubtitleSyncController(source, backgroundScope, onTimingChanged = { changes += it }, pollIntervalMs = 3_000, pollLimitMs = 9_000) + + @Test + fun loadsTheFileAndOffersSidecarsOnlyWhenTheServerCanSyncThem() = runTest { + val source = FakeSource().apply { states[sidecar] = sidecarState(); states[stored] = storedState() } + val sync = controller(source, mutableListOf()) + sync.bind(1, setOf(sidecar, stored)) + runCurrent() + val state = sync.state.value + assertTrue(state.available && state.externalAvailable) + assertEquals(setOf(sidecar, stored), state.entries.keys) + val actions = assertNotNull(state.timingActionsFor(sidecar)) + assertTrue(actions.canSync) + assertFalse(actions.canReset) + assertEquals(SubtitleTimingActions.SIDECAR_NOTE, actions.note) + assertEquals(SubtitleTimingActions.STORED_NOTE, state.timingActionsFor(stored)?.note) + // No sync key, nothing to show. + assertNull(state.timingActionsFor(null)) + + source.capability = source.capability.copy(external = false) + sync.reload() + runCurrent() + assertNull(sync.state.value.timingActionsFor(sidecar)) + assertTrue(sync.state.value.timingActionsFor(stored)!!.canSync) + } + + @Test + fun followsAStartedSyncByPollingAndReportsTheTimingChangeOnce() = runTest { + val source = FakeSource().apply { states[sidecar] = sidecarState() } + val changes = mutableListOf<String>() + val sync = controller(source, changes) + sync.bind(1, setOf(sidecar)) + runCurrent() + + sync.requestSync(sidecar) + runCurrent() + val entry = sync.state.value.entries.getValue(sidecar) + assertEquals("j1", entry.watchedJobId) + assertTrue(entry.inProgress) + assertEquals(0, sync.state.value.timingActionsFor(sidecar)?.percent) + assertFalse(sync.state.value.timingActionsFor(sidecar)!!.actionsEnabled) + + source.finish(sidecar, SubtitleSyncJob.SYNCED, corrected) + advanceTimeBy(3_001) + val done = sync.state.value.entries.getValue(sidecar) + assertEquals(SubtitleSyncJob.SYNCED, done.state.sync?.status) + assertEquals("Synced −3.0 s", done.statusLabel) + assertEquals(listOf(sidecar), changes) + + // The realtime event that follows reads the same timing: no second reload. + sync.timingChanged(sidecar) + runCurrent() + assertEquals(listOf(sidecar), changes) + + // Reset brings the original timing back, and its cues reload. + sync.resetTiming(sidecar) + runCurrent() + assertEquals(listOf(sidecar, sidecar), changes) + assertEquals("Original timing", sync.state.value.entries.getValue(sidecar).statusLabel) + } + + @Test + fun realtimeUpdatesCarryProgressAndSkipTheNextPoll() = runTest { + val source = FakeSource().apply { states[sidecar] = sidecarState() } + val changes = mutableListOf<String>() + val sync = controller(source, changes) + sync.bind(1, setOf(sidecar)) + runCurrent() + sync.requestSync(sidecar) + runCurrent() + val job = sync.state.value.entries.getValue(sidecar).state.sync!! + + source.reads.clear() + sync.syncUpdated(update(job.copy(status = SubtitleSyncJob.RUNNING, phase = "analyzing", progress = 0.6))) + assertEquals("Syncing… 60%", sync.state.value.entries.getValue(sidecar).statusLabel) + advanceTimeBy(3_001) + assertTrue(source.reads.isEmpty(), "a fresh push makes the poll redundant") + + sync.syncUpdated(update(job.copy(status = SubtitleSyncJob.SYNCED, phase = null, progress = null, result = corrected), corrected)) + assertEquals(listOf(sidecar), changes) + assertEquals("j1", sync.state.value.watchedEntry()?.state?.sync?.id) + } + + @Test + fun aStartResponseThatArrivesAfterTheRealtimeOutcomeDoesNotRewindIt() = runTest { + val source = FakeSource().apply { states[sidecar] = sidecarState() } + val changes = mutableListOf<String>() + val sync = controller(source, changes) + sync.bind(1, setOf(sidecar)) + runCurrent() + + source.startGate = CompletableDeferred() + sync.requestSync(sidecar) + runCurrent() + // The job ran on cached speech: its outcome arrives before the POST answers. + val finished = SubtitleSyncJob(id = "j1", status = SubtitleSyncJob.SYNCED, trigger = "manual", result = corrected) + sync.syncUpdated(update(finished, corrected)) + assertEquals(listOf(sidecar), changes) + + source.startGate!!.complete(Unit) + runCurrent() + val entry = sync.state.value.entries.getValue(sidecar) + assertEquals(SubtitleSyncJob.SYNCED, entry.state.sync?.status) + assertEquals(corrected, entry.state.timing) + assertEquals("j1", entry.watchedJobId) + assertEquals(listOf(sidecar), changes, "no second reload for the stale response") + } + + @Test + fun anOlderJobsSnapshotDoesNotReplaceANewerJob() = runTest { + val older = SubtitleSyncJob(id = "1", status = SubtitleSyncJob.SYNCED, createdAt = "2026-10-04T02:00:00.000Z", result = corrected) + val newer = SubtitleSyncJob(id = "2", status = SubtitleSyncJob.RUNNING, progress = 0.3, createdAt = "2026-10-04T02:05:00.000Z") + val source = FakeSource().apply { states[sidecar] = sidecarState(corrected).copy(sync = older) } + val sync = controller(source, mutableListOf()) + sync.bind(1, setOf(sidecar)) + runCurrent() + + sync.syncUpdated(update(newer, corrected)) + // A read that left before the new job was queued answers with the old one. + sync.timingChanged(sidecar) + runCurrent() + assertEquals("2", sync.state.value.entries.getValue(sidecar).state.sync?.id) + // The same job going back in progress is stale too. + sync.syncUpdated(update(newer.copy(progress = 0.1), corrected)) + assertEquals(0.3, sync.state.value.entries.getValue(sidecar).state.sync?.progress) + } + + @Test + fun aReadSentBeforeANewerOneDoesNotRestoreItsTiming() = runTest { + val source = FakeSource().apply { states[sidecar] = sidecarState() } + val changes = mutableListOf<String>() + val sync = controller(source, changes) + sync.bind(1, setOf(sidecar)) + runCurrent() + + // Timing set to +0.5 s elsewhere; that event's read answers slowly. + source.states[sidecar] = sidecarState(SubtitleTiming(offsetMs = 500)) + val slowRead = CompletableDeferred<Unit>().also { source.readGate = it } + sync.timingChanged(sidecar) + runCurrent() + // Then reset; this event's read answers first. + source.states[sidecar] = sidecarState() + sync.timingChanged(sidecar) + runCurrent() + + slowRead.complete(Unit) + runCurrent() + assertEquals(SubtitleTiming(), sync.state.value.entries.getValue(sidecar).state.timing) + assertEquals(emptyList(), changes, "the cues on screen already have the current timing") + } + + @Test + fun someoneElsesChangeReloadsTheTrack() = runTest { + val source = FakeSource().apply { states[sidecar] = sidecarState() } + val changes = mutableListOf<String>() + val sync = controller(source, changes) + sync.bind(1, setOf(sidecar)) + runCurrent() + + source.states[sidecar] = sidecarState(SubtitleTiming(offsetMs = 500)) + sync.timingChanged(sidecar) + runCurrent() + assertEquals(listOf(sidecar), changes) + assertEquals("Timing adjusted +0.5 s", sync.state.value.entries.getValue(sidecar).statusLabel) + assertNull(sync.state.value.watchedEntry()) + + // A server without the read (or a lost subtitle) still reloads on the event. + source.readError = ApiResult.Error(404, "not_found", "") + sync.timingChanged(stored) + runCurrent() + assertEquals(listOf(sidecar, stored), changes) + } + + @Test + fun aDemoRefusalReplacesTheActionsAndAnUnsupportedFormatHidesSync() = runTest { + val source = FakeSource().apply { states[sidecar] = sidecarState(); states[stored] = storedState() } + val sync = controller(source, mutableListOf()) + sync.bind(1, setOf(sidecar, stored)) + runCurrent() + + source.startError = ApiResult.Error(403, "forbidden", "demo") + sync.requestSync(sidecar) + runCurrent() + val refused = assertNotNull(sync.state.value.timingActionsFor(sidecar)) + assertTrue(refused.forbidden) + assertNull(refused.note) + + source.startError = ApiResult.Error(422, "unsupported", "") + sync.requestSync(stored) + runCurrent() + val unsupported = assertNotNull(sync.state.value.timingActionsFor(stored)) + assertFalse(unsupported.canSync) + assertEquals("This format can't be synced.", unsupported.error) + } + + @Test + fun followsTheAutomaticSyncOfANewDownload() = runTest { + val source = FakeSource() + val sync = controller(source, mutableListOf()) + sync.bind(1, setOf(sidecar)) + runCurrent() + val auto = SubtitleSyncJob(id = "a1", status = SubtitleSyncJob.PENDING, trigger = "auto", createdAt = "2026-10-04T02:00:00.000Z") + sync.remember( + DownloadedSubtitle(id = 9, mediaFileId = 1, provider = "upload", language = "fr", format = "srt", releaseName = "x", sync = auto), + ) + assertEquals("a1", sync.state.value.watchedEntry()?.watchedJobId) + // Another file's download is not this player's business. + sync.remember(DownloadedSubtitle(id = 10, mediaFileId = 2, provider = "upload", language = "fr", format = "srt", releaseName = "x", sync = auto)) + assertFalse("stored-10" in sync.state.value.entries) + // Polling follows it; the inventory gaining its key keeps the watch. + source.states[stored] = storedState().copy(sync = auto.copy(status = SubtitleSyncJob.RUNNING, progress = 0.2)) + sync.bind(1, setOf(sidecar, stored)) + advanceTimeBy(3_001) + assertEquals("a1", sync.state.value.entries.getValue(stored).watchedJobId) + assertEquals("Syncing… 20%", sync.state.value.entries.getValue(stored).statusLabel) + } + + @Test + fun pollingGivesUpAfterItsLimitAndAReloadRearmsIt() = runTest { + val source = FakeSource().apply { states[sidecar] = sidecarState() } + val sync = controller(source, mutableListOf()) + sync.bind(1, setOf(sidecar)) + runCurrent() + sync.requestSync(sidecar) + runCurrent() + advanceTimeBy(12_001) + assertTrue(sync.state.value.entries.getValue(sidecar).pollExpired) + assertNull(sync.state.value.watchedEntry(), "a job nobody can follow leaves the card") + val reads = source.reads.size + advanceTimeBy(6_000) + assertEquals(reads, source.reads.size) + + sync.reload() + runCurrent() + assertFalse(sync.state.value.entries.getValue(sidecar).pollExpired) + advanceTimeBy(3_001) + assertTrue(source.reads.size > reads) + } + + @Test + fun aNewFileDropsLateResults() = runTest { + val source = FakeSource().apply { states[sidecar] = sidecarState() } + val sync = controller(source, mutableListOf()) + sync.bind(1, setOf(sidecar)) + sync.bind(2, emptySet()) + runCurrent() + assertTrue(sync.state.value.entries.isEmpty()) + assertFalse(sync.state.value.available) + } + + private fun update(job: SubtitleSyncJob, timing: SubtitleTiming = SubtitleTiming()) = + PlaybackSubtitleSyncUpdated(sessionId = "s", mediaFileId = 1, syncKey = sidecar, subtitleId = null, timing = timing, job = job) +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/SubtitleSyncFeedbackTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/SubtitleSyncFeedbackTest.kt new file mode 100644 index 0000000000..dae4ffe80f --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/SubtitleSyncFeedbackTest.kt @@ -0,0 +1,143 @@ +package org.prairieserver.prairie.playback + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.prairieserver.prairie.model.subtitles.SubtitleSyncJob +import org.prairieserver.prairie.model.subtitles.SubtitleSyncState +import org.prairieserver.prairie.model.subtitles.SubtitleTiming +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class SubtitleSyncFeedbackTest { + private val key = "external-" + "a".repeat(64) + private val corrected = SubtitleTiming(offsetMs = -3_000) + + private fun job(status: String, progress: Double? = null, phase: String? = null, failure: String? = null) = + SubtitleSyncJob(id = "j1", status = status, trigger = "manual", phase = phase, progress = progress, failure = failure) + + private fun entry(job: SubtitleSyncJob?, timing: SubtitleTiming = SubtitleTiming(), watched: Boolean = true) = + SubtitleSyncEntry( + state = SubtitleSyncState(key = key, mediaFileId = "1", source = "external", language = "en", timing = timing, sync = job), + watchedJobId = job?.id?.takeIf { watched }, + ) + + /** One update: the sync state of [entry], with [key] on screen at a revision whose cues loaded up to [loaded]. */ + private fun input(entry: SubtitleSyncEntry, onScreen: Boolean = true, revision: Int = 0, loaded: Int = 0) = + SubtitleSyncUiState(available = true, externalAvailable = true, entries = mapOf(key to entry)) + .feedbackInput( + activeKey = key.takeIf { onScreen }, + cueRevisions = mapOf(key to revision), + loadedCueRevisions = mapOf(key to loaded), + nameOf = { "English" }, + ) + + private fun SubtitleSyncFeedbackState.after(vararg inputs: SubtitleSyncFeedbackInput) = + inputs.fold(this) { state, input -> state.step(input) } + + @Test + fun followsAnOwnSyncUntilTheNewCuesShow() { + var state = SubtitleSyncFeedbackState().after(input(entry(job(SubtitleSyncJob.RUNNING, 0.4, "analyzing")))) + assertEquals( + SubtitleSyncNotice("j1", SubtitleSyncNotice.Tone.Progress, "Syncing English subtitles", "Listening to the audio…", 40), + state.notice, + ) + + val synced = entry(job(SubtitleSyncJob.SYNCED), corrected) + state = state.after(input(synced)) + assertEquals("Applying new timing…", state.notice?.title) + assertEquals(100, state.notice?.percent) + + // The player asks for the corrected cues; it is this viewer's own change. + state = state.after(input(synced, revision = 1)) + assertEquals("Applying new timing…", state.notice?.title) + assertNull(state.foreign) + + state = state.after(input(synced, revision = 1, loaded = 1)) + assertEquals( + SubtitleSyncNotice("j1", SubtitleSyncNotice.Tone.Success, "Subtitles synced", "−3.0 s"), + state.notice, + ) + assertTrue("j1" in state.applied) + // A later update does not announce it again. + assertEquals(state.notice, state.after(input(synced, revision = 1, loaded = 1)).notice) + } + + @Test + fun aSyncOfATrackNotOnScreenSaysSoAtOnce() { + val state = SubtitleSyncFeedbackState().after( + input(entry(job(SubtitleSyncJob.PENDING, 0.0, "queued")), onScreen = false), + input(entry(job(SubtitleSyncJob.SYNCED), corrected), onScreen = false), + ) + assertEquals( + SubtitleSyncNotice("j1", SubtitleSyncNotice.Tone.Success, "English subtitles synced", "−3.0 s"), + state.notice, + ) + } + + @Test + fun warnsWhenASyncCannotHelp() { + val noMatch = SubtitleSyncFeedbackState().after(input(entry(job(SubtitleSyncJob.NO_MATCH)))) + assertEquals(SubtitleSyncNotice.Tone.Warning, noMatch.notice?.tone) + assertEquals("English subtitles don't match the audio", noMatch.notice?.title) + assertEquals("They're probably for another release. The timing wasn't changed.", noMatch.notice?.detail) + + val failed = SubtitleSyncFeedbackState().after(input(entry(job(SubtitleSyncJob.FAILED, failure = "unavailable")))) + assertEquals("Couldn't sync English subtitles", failed.notice?.title) + assertEquals("The server is busy. Try again in a few minutes.", failed.notice?.detail) + + val already = SubtitleSyncFeedbackState().after(input(entry(job(SubtitleSyncJob.ALREADY_SYNCED)))) + assertEquals(SubtitleSyncNotice.Tone.Info, already.notice?.tone) + assertEquals("English subtitles already match the audio", already.notice?.title) + } + + @Test + fun someoneElsesChangeIsAnnouncedOnceItsCuesShow() { + val other = entry(job(SubtitleSyncJob.SYNCED), corrected, watched = false) + var state = SubtitleSyncFeedbackState().after(input(other)) + assertNull(state.notice) + state = state.after(input(other, revision = 1)) + assertNull(state.notice) + state = state.after(input(other, revision = 1, loaded = 1)) + assertEquals("Subtitle timing updated", state.notice?.title) + assertEquals(SubtitleSyncNotice.Tone.Info, state.notice?.tone) + } + + @Test + fun aProgressCardNobodyFollowsLeaves() { + var state = SubtitleSyncFeedbackState().after(input(entry(job(SubtitleSyncJob.RUNNING, 0.2)))) + assertEquals(SubtitleSyncNotice.Tone.Progress, state.notice?.tone) + // Another file: the entries are gone. + state = state.step(SubtitleSyncFeedbackInput(watched = null, activeKey = null)) + assertNull(state.notice) + } + + @Test + fun finishedCardsLeaveOnTheirOwnAndApplyingGivesUpWaiting() = runTest { + val tracker = SubtitleSyncFeedbackTracker(backgroundScope) + tracker.update(input(entry(job(SubtitleSyncJob.RUNNING, 0.5)))) + tracker.update(input(entry(job(SubtitleSyncJob.SYNCED), corrected))) + assertEquals("Applying new timing…", tracker.notice.value?.title) + // The reload never reports: the success shows anyway. + advanceTimeBy(SubtitleSyncFeedbackTracker.APPLY_TIMEOUT_MS + 1) + assertEquals("Subtitles synced", tracker.notice.value?.title) + advanceTimeBy(SubtitleSyncFeedbackTracker.NOTICE_VISIBLE_MS + 1) + assertNull(tracker.notice.value) + + val warning = SubtitleSyncFeedbackTracker(backgroundScope) + warning.update(input(entry(job(SubtitleSyncJob.NO_MATCH)))) + advanceTimeBy(SubtitleSyncFeedbackTracker.NOTICE_VISIBLE_MS + 1) + assertEquals(SubtitleSyncNotice.Tone.Warning, warning.notice.value?.tone) + advanceTimeBy(SubtitleSyncFeedbackTracker.WARNING_VISIBLE_MS) + assertNull(warning.notice.value) + + warning.update(input(entry(job(SubtitleSyncJob.FAILED).copy(id = "j2")))) + runCurrent() + warning.dismiss() + assertNull(warning.notice.value) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/CatalogRepositoryDetailCacheTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/CatalogRepositoryDetailCacheTest.kt index 9910fde0d4..390c1cf284 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/CatalogRepositoryDetailCacheTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/CatalogRepositoryDetailCacheTest.kt @@ -11,8 +11,10 @@ import org.prairieserver.prairie.network.PrairieJson import org.prairieserver.prairie.network.api.CatalogApi import org.prairieserver.prairie.repository.port.CatalogCachePort import org.prairieserver.prairie.repository.port.CatalogCacheWriteLease +import org.prairieserver.prairie.repository.port.CatalogCacheScope import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.MockEngineConfig import io.ktor.client.engine.mock.respond import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.http.HttpHeaders @@ -36,9 +38,9 @@ import kotlin.test.assertTrue class CatalogRepositoryDetailCacheTest { private class FakeCache( - val preset: ItemDetail? = null, - val seasonsPreset: SeasonsResponse? = null, - val episodesPreset: EpisodesResponse? = null, + var preset: ItemDetail? = null, + var seasonsPreset: SeasonsResponse? = null, + var episodesPreset: EpisodesResponse? = null, val identityTransitions: IdentityTransitionBarrier? = null, val beforeItemCache: suspend () -> Unit = {}, ) : CatalogCachePort { @@ -54,8 +56,22 @@ class CatalogRepositoryDetailCacheTest { lease.identityGeneration == identityTransitions.generation.value ) { cachedId = contentId + preset = detail } } + val invalidatedScopes = mutableListOf<CatalogCacheScope?>() + override suspend fun invalidateDetailMetadata(scope: CatalogCacheScope?) { + invalidatedScopes += scope + preset = null + seasonsPreset = null + episodesPreset = null + } + override suspend fun cacheSeasons(seriesId: String, response: SeasonsResponse) { + seasonsPreset = response + } + override suspend fun cacheEpisodes(seriesId: String, seasonNumber: Int, response: EpisodesResponse) { + episodesPreset = response + } override suspend fun getCachedItemDetail(contentId: String): ItemDetail? = preset override suspend fun getCachedSeasons(seriesId: String): SeasonsResponse? = seasonsPreset override suspend fun getCachedEpisodes( @@ -184,6 +200,30 @@ class CatalogRepositoryDetailCacheTest { assertEquals(1, calls) } + @Test + fun accessChangeReloadDoesNotJoinAnActiveWarmup() = runTest { + var calls = 0 + val entered = CompletableDeferred<Unit>() + val release = CompletableDeferred<Unit>() + val repository = gatedRepository( + requestDispatcher = StandardTestDispatcher(testScheduler), + requestEntered = entered, + releaseResponse = release, + onRequest = { calls += 1 }, + ) + + // The warm-up went out before the access change; the reload after it + // must send its own request. + val warmup = async { repository.warmItemDetail("c1") } + entered.await() + val reload = async { repository.getItemDetail("c1", joinWarmup = false) } + repeat(10) { yield() } + release.complete(Unit) + listOf(warmup, reload).awaitAll() + + assertEquals(2, calls) + } + @Test fun cancelingHomePrefetchDoesNotCancelDestinationDetailRequest() = runTest { var calls = 0 @@ -298,4 +338,139 @@ class CatalogRepositoryDetailCacheTest { assertTrue(repository.getItemDetail("c1") is ApiResult.Success) assertEquals(null, cache.cachedId) } + @Test + fun languageChangeRejectsOldWarmupsAndReplacesAllLocalizedCaches() = runTest { + val language = org.prairieserver.prairie.domain.settings.CatalogLanguageRevision() + val oldRequestsEntered = CompletableDeferred<Unit>() + val releaseOldResponses = CompletableDeferred<Unit>() + var oldCalls = 0 + var newCalls = 0 + val cache = FakeCache() + val client = HttpClient(MockEngine(MockEngineConfig().apply { + dispatcher = StandardTestDispatcher(testScheduler) + addHandler { request -> + val old = language.revision.value == 0L + if (old) { + oldCalls++ + if (oldCalls == 3) oldRequestsEntered.complete(Unit) + releaseOldResponses.await() + } else { + newCalls++ + } + val title = if (old) "Old language" else "New language" + val body = when { + request.url.encodedPath.endsWith("/episodes") -> + """{"items":[{"content_id":"e1","season_number":1,"episode_number":1,"title":"$title","pending_translation_language":"de"}]}""" + request.url.encodedPath.endsWith("/seasons") -> + """{"items":[{"content_id":"s1","season_number":1,"title":"$title"}]}""" + else -> """{"content_id":"c1","type":"series","title":"$title","cast":[],"crew":[],"subtitles":[],"versions":[]}""" + } + respond(body, HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + } + })) { install(ContentNegotiation) { json(PrairieJson) } } + val repository = CatalogRepository( + CatalogApi(client), cache, + requestDispatcher = StandardTestDispatcher(testScheduler), + catalogLanguage = language, + ) + val oldWarmups = listOf( + async { repository.warmItemDetail("c1") }, + async { repository.warmSeasons("c1") }, + async { repository.warmEpisodes("c1", 1) }, + ) + oldRequestsEntered.await() + language.changed() + val destinations = listOf( + async { repository.getItemDetail("c1") }, + async { repository.getSeasonsForPrefetch("c1") }, + async { repository.getEpisodesForPrefetch("c1", 1) }, + ) + // Drain new requests without releasing the old responses. A destination + // joining an old warmup would still be waiting here. + testScheduler.advanceUntilIdle() + val allDestinationsFinished = destinations.all { it.isCompleted } + releaseOldResponses.complete(Unit) + oldWarmups.awaitAll() + destinations.awaitAll() + assertTrue(allDestinationsFinished) + assertEquals(3, newCalls) + assertEquals("New language", repository.getCachedItemDetail("c1")?.title) + assertEquals("New language", repository.getCachedSeasons("c1")?.seasons?.single()?.title) + assertEquals("New language", repository.getCachedEpisodes("c1", 1)?.episodes?.single()?.title) + assertEquals("de", repository.getCachedEpisodes("c1", 1)?.episodes?.single()?.pendingTranslationLanguage) + } + + @Test + fun languageChangeClearsCachedPrefetchAndOfflineFallbacks() = runTest { + val language = org.prairieserver.prairie.domain.settings.CatalogLanguageRevision() + val cache = FakeCache( + preset = ItemDetail(contentId = "c1", type = "series", title = "Old"), + seasonsPreset = SeasonsResponse(), + episodesPreset = EpisodesResponse(), + ) + val client = HttpClient(MockEngine { + respond("{}", HttpStatusCode.ServiceUnavailable, headersOf(HttpHeaders.ContentType, "application/json")) + }) { install(ContentNegotiation) { json(PrairieJson) } } + val repository = CatalogRepository(CatalogApi(client), cache, catalogLanguage = language) + language.changed() + assertTrue(repository.getItemDetailForPrefetch("c1") is ApiResult.Error) + assertTrue(repository.getSeasonsForPrefetch("c1") is ApiResult.Error) + assertTrue(repository.getEpisodesForPrefetch("c1", 1) is ApiResult.Error) + } + + @Test + fun languageInvalidationWaitsForAnAdmittedCacheWriteThenPurgesIt() = runTest { + val language = org.prairieserver.prairie.domain.settings.CatalogLanguageRevision() + val writeEntered = CompletableDeferred<Unit>() + val releaseWrite = CompletableDeferred<Unit>() + val cache = FakeCache(beforeItemCache = { + writeEntered.complete(Unit) + releaseWrite.await() + }) + val client = HttpClient(MockEngine { + respond("""{"content_id":"c1","type":"movie","title":"Old","cast":[],"crew":[],"subtitles":[],"versions":[]}""", + HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + }) { install(ContentNegotiation) { json(PrairieJson) } } + val repository = CatalogRepository(CatalogApi(client), cache, catalogLanguage = language) + val oldRequest = async { repository.getItemDetail("c1") } + writeEntered.await() + language.changed() + val invalidation = async { repository.invalidateLocalizedMetadata() } + testScheduler.runCurrent() + val invalidationWaited = !invalidation.isCompleted + releaseWrite.complete(Unit) + oldRequest.await() + invalidation.await() + assertTrue(invalidationWaited) + assertEquals(null, repository.getCachedItemDetail("c1")) + } + + @Test + fun languageChangeBeforeLazyRepositoryConstructionStillPurgesPersistedMetadata() = runTest { + val language = org.prairieserver.prairie.domain.settings.CatalogLanguageRevision() + val cache = FakeCache(preset = ItemDetail(contentId = "c1", type = "series", title = "Old")) + language.changed() + val client = HttpClient(MockEngine { error("No live request expected") }) + val repository = CatalogRepository(CatalogApi(client), cache, catalogLanguage = language) + repository.invalidateLocalizedMetadata() + assertEquals(null, repository.getCachedItemDetail("c1")) + } + + @Test + fun languageInvalidationTracksEveryWriterAndOnlyAcknowledgesItsOwnRevision() = runTest { + val language = org.prairieserver.prairie.domain.settings.CatalogLanguageRevision() + val a = CatalogCacheScope("server", "a") + val b = CatalogCacheScope("server", "b") + language.changed(a) + language.changed(b) + val cache = FakeCache() + val client = HttpClient(MockEngine { error("No live request expected") }) + val repository = CatalogRepository(CatalogApi(client), cache, catalogLanguage = language) + repository.invalidateLocalizedMetadata() + assertEquals<List<CatalogCacheScope?>>(listOf(a, b), cache.invalidatedScopes) + language.changed(a) + repository.invalidateLocalizedMetadata() + assertEquals<List<CatalogCacheScope?>>(listOf(a, b, a), cache.invalidatedScopes) + } + } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/DeviceLoginRepositoryTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/DeviceLoginRepositoryTest.kt index e0f1a03474..4829c95c23 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/DeviceLoginRepositoryTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/DeviceLoginRepositoryTest.kt @@ -98,7 +98,7 @@ class DeviceLoginRepositoryTest { } @Test - fun `start failure transitions to Failed(StartFailed)`() = runTest(UnconfinedTestDispatcher()) { + fun `start 5xx transitions to Failed(Unreachable) without raw server text`() = runTest(UnconfinedTestDispatcher()) { val api = object : DeviceLoginApi { override suspend fun startDeviceLogin(deviceName: String?, devicePlatform: String?) = ApiResult.Error(code = 500, error = "", message = "boom") @@ -115,7 +115,8 @@ class DeviceLoginRepositoryTest { repo.begin("d", "p") val state = repo.state.value assertIs<DeviceLoginRepository.DeviceLoginState.Failed>(state) - assertEquals(DeviceLoginRepository.FailureReason.StartFailed, state.reason) + assertEquals(DeviceLoginRepository.FailureReason.Unreachable, state.reason) + assertEquals(null, state.message) } @Test @@ -236,7 +237,7 @@ class DeviceLoginRepositoryTest { } @Test - fun `network error on start transitions to Failed(StartFailed)`() = runTest(UnconfinedTestDispatcher()) { + fun `network error on start transitions to Failed(Unreachable)`() = runTest(UnconfinedTestDispatcher()) { val api = object : DeviceLoginApi { override suspend fun startDeviceLogin(deviceName: String?, devicePlatform: String?) = ApiResult.NetworkError(exception = RuntimeException("offline")) @@ -252,6 +253,97 @@ class DeviceLoginRepositoryTest { repo.begin("d", "p") val state = repo.state.value assertIs<DeviceLoginRepository.DeviceLoginState.Failed>(state) - assertEquals(DeviceLoginRepository.FailureReason.StartFailed, state.reason) + assertEquals(DeviceLoginRepository.FailureReason.Unreachable, state.reason) + } + + @Test + fun `persistent network errors stop at the local deadline with backoff`() = runTest { + val pollTimes = mutableListOf<Long>() + val api = object : DeviceLoginApi { + override suspend fun startDeviceLogin(deviceName: String?, devicePlatform: String?) = + ApiResult.Success(session().copy(expiresIn = 120, interval = 1)) + override suspend fun pollDeviceLogin(deviceCode: String): ApiResult<DeviceLoginPollResponse> { + pollTimes += testScheduler.currentTime + return ApiResult.NetworkError(RuntimeException("offline")) + } + override suspend fun lookupDeviceLogin(token: String?, code: String?) = error("unreachable") + override suspend fun approveDeviceLogin(token: String?, code: String?) = error("unreachable") + override suspend fun denyDeviceLogin(token: String?, code: String?) = error("unreachable") + } + val repo = DeviceLoginRepository(api, clock = DeviceLoginClock { testScheduler.currentTime }) + repo.begin("d", "p") + val state = repo.state.value + assertIs<DeviceLoginRepository.DeviceLoginState.Failed>(state) + assertEquals(DeviceLoginRepository.FailureReason.Expired, state.reason) + assertTrue(testScheduler.currentTime <= 120_000L, "must not poll past the local deadline") + val gaps = pollTimes.zipWithNext { a, b -> b - a } + assertEquals(listOf(1_000L, 2_000L, 4_000L, 8_000L, 16_000L, 30_000L), gaps.take(6)) + } + + @Test + fun `canceled status ends the attempt`() = runTest(UnconfinedTestDispatcher()) { + var polls = 0 + val api = object : DeviceLoginApi { + override suspend fun startDeviceLogin(deviceName: String?, devicePlatform: String?) = + ApiResult.Success(session()) + override suspend fun pollDeviceLogin(deviceCode: String): ApiResult<DeviceLoginPollResponse> { + polls++ + return ApiResult.Success( + if (polls == 1) DeviceLoginPollResponse(status = "pending", opened = true) + else DeviceLoginPollResponse(status = "canceled"), + ) + } + override suspend fun lookupDeviceLogin(token: String?, code: String?) = error("unreachable") + override suspend fun approveDeviceLogin(token: String?, code: String?) = error("unreachable") + override suspend fun denyDeviceLogin(token: String?, code: String?) = error("unreachable") + } + val repo = DeviceLoginRepository(api) + repo.begin("d", "p") + val state = repo.state.value + assertIs<DeviceLoginRepository.DeviceLoginState.Failed>(state) + assertEquals(DeviceLoginRepository.FailureReason.Canceled, state.reason) + } + + @Test + fun `a pending answer's expires_at keeps the attempt alive past the start answer's expiry`() = runTest { + val api = object : DeviceLoginApi { + override suspend fun startDeviceLogin(deviceName: String?, devicePlatform: String?) = + ApiResult.Success(session().copy(expiresIn = 60, expiresAt = "2026-01-01T00:01:00Z", interval = 5)) + override suspend fun pollDeviceLogin(deviceCode: String): ApiResult<DeviceLoginPollResponse> = + ApiResult.Success( + when { + testScheduler.currentTime >= 200_000L -> + DeviceLoginPollResponse(status = "approved", accessToken = "at", refreshToken = "rt") + // An approver's lookup extended the request to 00:05:00. + else -> DeviceLoginPollResponse(status = "pending", opened = true, expiresAt = "2026-01-01T00:05:00Z") + }, + ) + override suspend fun lookupDeviceLogin(token: String?, code: String?) = error("unreachable") + override suspend fun approveDeviceLogin(token: String?, code: String?) = error("unreachable") + override suspend fun denyDeviceLogin(token: String?, code: String?) = error("unreachable") + } + val repo = DeviceLoginRepository(api, clock = DeviceLoginClock { testScheduler.currentTime }) + repo.begin("d", "p") + assertIs<DeviceLoginRepository.DeviceLoginState.Approved>(repo.state.value) + } + + @Test + fun `the attempt polls once more at its deadline before giving up`() = runTest { + val pollTimes = mutableListOf<Long>() + val api = object : DeviceLoginApi { + override suspend fun startDeviceLogin(deviceName: String?, devicePlatform: String?) = + ApiResult.Success(session().copy(expiresIn = 12, interval = 5)) + override suspend fun pollDeviceLogin(deviceCode: String): ApiResult<DeviceLoginPollResponse> { + pollTimes += testScheduler.currentTime + return ApiResult.Success(DeviceLoginPollResponse(status = "pending")) + } + override suspend fun lookupDeviceLogin(token: String?, code: String?) = error("unreachable") + override suspend fun approveDeviceLogin(token: String?, code: String?) = error("unreachable") + override suspend fun denyDeviceLogin(token: String?, code: String?) = error("unreachable") + } + val repo = DeviceLoginRepository(api, clock = DeviceLoginClock { testScheduler.currentTime }) + repo.begin("d", "p") + assertEquals(listOf(0L, 5_000L, 10_000L, 12_000L), pollTimes) + assertEquals(DeviceLoginRepository.FailureReason.Expired, (repo.state.value as DeviceLoginRepository.DeviceLoginState.Failed).reason) } } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/DeviceSignInMachineTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/DeviceSignInMachineTest.kt new file mode 100644 index 0000000000..f7988e523e --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/DeviceSignInMachineTest.kt @@ -0,0 +1,471 @@ +package org.prairieserver.prairie.repository + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.prairieserver.prairie.model.auth.DeviceLoginCancelResponse +import org.prairieserver.prairie.model.auth.DeviceLoginCapabilityResponse +import org.prairieserver.prairie.model.auth.DeviceLoginDecisionResponse +import org.prairieserver.prairie.model.auth.DeviceLoginLookupResponse +import org.prairieserver.prairie.model.auth.DeviceLoginPollResponse +import org.prairieserver.prairie.model.auth.DeviceLoginStartResponse +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.api.DeviceLoginApi +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class DeviceSignInMachineTest { + + private class ScriptedApi( + var capability: ApiResult<DeviceLoginCapabilityResponse> = ApiResult.Success( + DeviceLoginCapabilityResponse(cancel = true, openedSignal = true), + ), + var start: (Int) -> ApiResult<DeviceLoginStartResponse> = { n -> ApiResult.Success(session("dev-$n")) }, + var poll: (String) -> ApiResult<DeviceLoginPollResponse> = { ApiResult.Success(DeviceLoginPollResponse("pending")) }, + /** How long each poll call takes, so a test can stop the machine mid-call. */ + var pollDurationMs: Long = 0L, + /** How long each cancel call takes; a cancel is recorded once it has completed. */ + var cancelDurationMs: Long = 0L, + ) : DeviceLoginApi { + var starts = 0 + var polls = 0 + val canceled = mutableListOf<String>() + + override suspend fun deviceLoginCapabilityAt(serverUrl: String) = capability + override suspend fun startDeviceLoginAt(serverUrl: String, deviceName: String?, devicePlatform: String?) = + start(++starts) + override suspend fun pollDeviceLoginAt(serverUrl: String, deviceCode: String): ApiResult<DeviceLoginPollResponse> { + polls++ + if (pollDurationMs > 0) delay(pollDurationMs) + return poll(deviceCode) + } + override suspend fun cancelDeviceLoginAt(serverUrl: String, deviceCode: String): ApiResult<DeviceLoginCancelResponse> { + if (cancelDurationMs > 0) delay(cancelDurationMs) + canceled += deviceCode + return ApiResult.Success(DeviceLoginCancelResponse("canceled")) + } + override suspend fun startDeviceLogin(deviceName: String?, devicePlatform: String?) = error("unscoped") + override suspend fun pollDeviceLogin(deviceCode: String) = error("unscoped") + override suspend fun lookupDeviceLogin(token: String?, code: String?): ApiResult<DeviceLoginLookupResponse> = error("unused") + override suspend fun approveDeviceLogin(token: String?, code: String?): ApiResult<DeviceLoginDecisionResponse> = error("unused") + override suspend fun denyDeviceLogin(token: String?, code: String?): ApiResult<DeviceLoginDecisionResponse> = error("unused") + } + + private fun TestScope.machine(api: ScriptedApi, config: DeviceSignInConfig = DeviceSignInConfig()) = + DeviceSignInMachine( + repository = DeviceLoginRepository(api), + serverUrl = "https://prairie.test", + deviceName = "Shield", + devicePlatform = "android-tv", + clock = DeviceLoginClock { testScheduler.currentTime }, + config = config, + ) + + @Test + fun showsCodeThenApproves() = runTest { + var polls = 0 + val api = ScriptedApi(poll = { + polls++ + ApiResult.Success( + if (polls < 3) DeviceLoginPollResponse("pending") + else DeviceLoginPollResponse("approved", accessToken = "at", refreshToken = "rt"), + ) + }) + val m = machine(api) + val job = launch { m.run() } + runCurrent() + val showing = assertIs<DeviceSignInState.ShowingCode>(m.state.value) + assertEquals("4821-7730", showing.session.userCode) + advanceUntilIdle() + assertIs<DeviceSignInState.Approved>(m.state.value) + assertTrue(job.isCompleted) + } + + @Test + fun expiredCodeIsRenewedInPlace() = runTest { + val api = ScriptedApi(poll = { code -> + ApiResult.Success(DeviceLoginPollResponse(if (code == "dev-1") "expired" else "pending")) + }) + val m = machine(api) + val job = launch { m.run() } + advanceTimeBy(10_000) + val showing = assertIs<DeviceSignInState.ShowingCode>(m.state.value) + assertEquals("dev-2", showing.session.deviceCode) + assertTrue(showing.renewed) + job.cancel() + } + + @Test + fun localDeadlinePollsOnceMoreThenWithdrawsAndRenews() = runTest { + val pollTimes = mutableListOf<Long>() + val api = ScriptedApi( + start = { n -> ApiResult.Success(session("dev-$n", expiresIn = 62)) }, + poll = { pollTimes += testScheduler.currentTime; ApiResult.Success(DeviceLoginPollResponse("pending")) }, + ) + val m = machine(api) + val job = launch { m.run() } + advanceTimeBy(62_001) + // The wait before the deadline is clamped to it, and the code is only + // replaced after that last poll still found it pending. + assertTrue(62_000L in pollTimes, "polls at the deadline before replacing: $pollTimes") + assertEquals("dev-2", assertIs<DeviceSignInState.ShowingCode>(m.state.value).session.deviceCode) + assertEquals(listOf("dev-1"), api.canceled, "the replaced code is withdrawn on the server") + job.cancel() + } + + @Test + fun aPendingPollsExpiresAtMovesTheDeadline() = runTest { + // The start answer says the code lives 60 s (server clock 00:01:00); + // a lookup in the last poll interval extends it to 00:05:00. + val api = ScriptedApi( + start = { n -> ApiResult.Success(session("dev-$n", expiresIn = 60, expiresAt = "2026-01-01T00:01:00Z")) }, + poll = { + val extended = testScheduler.currentTime >= 60_000L + ApiResult.Success( + DeviceLoginPollResponse( + "pending", + opened = extended, + expiresAt = if (extended) "2026-01-01T00:05:00Z" else "2026-01-01T00:01:00Z", + ), + ) + }, + ) + val m = machine(api) + val job = launch { m.run() } + advanceTimeBy(4 * 60_000L) + val showing = assertIs<DeviceSignInState.ShowingCode>(m.state.value) + assertEquals("dev-1", showing.session.deviceCode, "the code someone is approving stays") + assertTrue(showing.opened) + assertEquals(emptyList(), api.canceled) + + advanceTimeBy(60_001) + assertEquals("dev-2", assertIs<DeviceSignInState.ShowingCode>(m.state.value).session.deviceCode) + assertEquals(listOf("dev-1"), api.canceled) + job.cancel() + } + + @Test + fun aReplacedLiveCodeIsWithdrawnWhileCancelSupportIsUnknown() = runTest { + // The capability read keeps failing: the same rule as abandon applies, + // so the code the TV moved past is still withdrawn. + val api = ScriptedApi( + capability = ApiResult.NetworkError(RuntimeException("offline")), + start = { n -> ApiResult.Success(session("dev-$n", expiresIn = 10)) }, + ) + val m = machine(api) + val job = launch { m.run() } + advanceTimeBy(10_001) + assertEquals("dev-2", assertIs<DeviceSignInState.ShowingCode>(m.state.value).session.deviceCode) + assertEquals(listOf("dev-1"), api.canceled) + job.cancel() + } + + @Test + fun aReplacedLiveCodeIsNotWithdrawnWhenTheServerHasNoCancel() = runTest { + val api = ScriptedApi( + capability = ApiResult.Success(DeviceLoginCapabilityResponse(cancel = false)), + start = { n -> ApiResult.Success(session("dev-$n", expiresIn = 10)) }, + ) + val m = machine(api) + val job = launch { m.run() } + advanceTimeBy(10_001) + assertEquals("dev-2", assertIs<DeviceSignInState.ShowingCode>(m.state.value).session.deviceCode) + assertEquals(emptyList(), api.canceled) + job.cancel() + } + + @Test + fun aFailedCapabilityReadIsRetriedWhenTheCodeIsReplaced() = runTest { + val api = ScriptedApi( + capability = ApiResult.NetworkError(RuntimeException("offline")), + start = { n -> ApiResult.Success(session("dev-$n", expiresIn = 10)) }, + ) + val m = machine(api) + val job = launch { m.run() } + runCurrent() + api.capability = ApiResult.Success(DeviceLoginCapabilityResponse(cancel = true)) + advanceTimeBy(10_001) + assertEquals(listOf("dev-1"), api.canceled) + job.cancel() + } + + @Test + fun pausesAfterAnHourOfRenewals() = runTest { + val api = ScriptedApi(start = { n -> ApiResult.Success(session("dev-$n", expiresIn = 900)) }) + val m = machine(api) + val job = launch { m.run() } + advanceTimeBy(61 * 60_000L) + assertEquals(DeviceSignInState.Paused, m.state.value) + assertTrue(job.isCompleted) + assertEquals(4, api.starts, "15-minute codes renew three times in an hour") + + m.restart() + val again = launch { m.run() } + runCurrent() + assertIs<DeviceSignInState.ShowingCode>(m.state.value) + again.cancel() + } + + @Test + fun networkFailuresBackOffAndReportUnreachableThenRecover() = runTest { + var offline = true + val pollTimes = mutableListOf<Long>() + val api = ScriptedApi(poll = { + pollTimes += testScheduler.currentTime + if (offline) ApiResult.NetworkError(RuntimeException("offline")) else ApiResult.Success(DeviceLoginPollResponse("pending")) + }) + val m = machine(api) + val job = launch { m.run() } + advanceTimeBy(29_000) + assertIs<DeviceSignInState.ShowingCode>(m.state.value) + advanceTimeBy(40_000) + val unreachable = assertIs<DeviceSignInState.Unreachable>(m.state.value) + assertEquals("dev-1", unreachable.session?.deviceCode) + val gaps = pollTimes.zipWithNext { a, b -> b - a } + assertEquals(listOf(5_000L, 10_000L, 20_000L, 30_000L), gaps.take(4)) + + offline = false + advanceTimeBy(31_000) + assertIs<DeviceSignInState.ShowingCode>(m.state.value) + job.cancel() + } + + @Test + fun startFailuresReportUnreachableAfterTenSeconds() = runTest { + val api = ScriptedApi(start = { ApiResult.Error(503, "", "") }) + val m = machine(api) + val job = launch { m.run() } + advanceTimeBy(5_000) + assertEquals(DeviceSignInState.GettingCode, m.state.value) + advanceTimeBy(11_000) + assertEquals(DeviceSignInState.Unreachable(null), m.state.value) + job.cancel() + } + + @Test + fun persistentRateLimitIsReported() = runTest { + val api = ScriptedApi(poll = { ApiResult.Error(429, "rate_limited", "") }) + val m = machine(api) + val job = launch { m.run() } + advanceTimeBy(59_000) + assertIs<DeviceSignInState.ShowingCode>(m.state.value) + advanceTimeBy(40_000) + assertIs<DeviceSignInState.TooManyRequests>(m.state.value) + job.cancel() + } + + @Test + fun openedIsReportedWithoutMovingTheDeadlineOnItsOwn() = runTest { + val api = ScriptedApi( + start = { n -> ApiResult.Success(session("dev-$n", expiresIn = 60)) }, + poll = { ApiResult.Success(DeviceLoginPollResponse("pending", opened = true)) }, + ) + val m = machine(api) + val job = launch { m.run() } + runCurrent() + assertTrue(assertIs<DeviceSignInState.ShowingCode>(m.state.value).opened) + // Only the server's expires_at extends a code; opened alone doesn't. + advanceTimeBy(61_000) + assertEquals("dev-2", assertIs<DeviceSignInState.ShowingCode>(m.state.value).session.deviceCode) + job.cancel() + } + + @Test + fun deniedAndMissingTokensSettle() = runTest { + val denied = machine(ScriptedApi(poll = { ApiResult.Success(DeviceLoginPollResponse("denied")) })) + denied.run() + assertEquals(DeviceSignInState.Denied, denied.state.value) + + val missing = machine(ScriptedApi(poll = { ApiResult.Success(DeviceLoginPollResponse("approved")) })) + missing.run() + assertEquals(DeviceSignInState.Failed, missing.state.value) + } + + @Test + fun serverWithoutDeviceSignInFallsBackToPassword() = runTest { + val unconfigured = machine( + ScriptedApi(capability = ApiResult.Success(DeviceLoginCapabilityResponse(deviceLoginAvailable = false))), + ) + unconfigured.run() + assertEquals(DeviceSignInState.NoDeviceSignIn, unconfigured.state.value) + + val noRoute = machine( + ScriptedApi( + capability = ApiResult.Error(404, "", ""), + start = { ApiResult.Error(404, "not_found", "") }, + ), + ) + noRoute.run() + assertEquals(DeviceSignInState.NoDeviceSignIn, noRoute.state.value) + + val retired = machine(ScriptedApi(start = { ApiResult.Error(410, "gone", "") })) + retired.run() + assertEquals(DeviceSignInState.UpdateRequired, retired.state.value) + } + + @Test + fun stoppingKeepsTheCodeAndResumingPollsItImmediately() = runTest { + val api = ScriptedApi() + val m = machine(api) + val first = launch { m.run() } + advanceTimeBy(12_000) + first.cancel() + val pollsWhenStopped = api.polls + advanceTimeBy(60_000) + assertEquals(pollsWhenStopped, api.polls, "no polling while stopped") + + val resumed = launch { m.run() } + runCurrent() + assertEquals(pollsWhenStopped + 1, api.polls, "polls at once on resume") + assertEquals(1, api.starts, "a live code is kept") + resumed.cancel() + } + + @Test + fun requestStopLetsAnInFlightPollFinishSoItsTokensAreKept() = runTest { + val api = ScriptedApi( + poll = { ApiResult.Success(DeviceLoginPollResponse("approved", accessToken = "at", refreshToken = "rt")) }, + pollDurationMs = 1_000L, + ) + val m = machine(api) + val job = launch { m.run() } + runCurrent() + assertEquals(1, api.polls, "the poll is in flight") + m.requestStop() + advanceUntilIdle() + assertIs<DeviceSignInState.Approved>(m.state.value) + assertTrue(job.isCompleted) + } + + @Test + fun requestStopEndsTheWaitBetweenPolls() = runTest { + val api = ScriptedApi() + val m = machine(api) + val job = launch { m.run() } + runCurrent() + m.requestStop() + runCurrent() + assertTrue(job.isCompleted, "stops without waiting out the interval") + assertEquals(1, api.polls) + advanceTimeBy(60_000) + assertEquals(1, api.polls) + + val resumed = launch { m.run() } + runCurrent() + assertEquals(2, api.polls, "polls at once on resume") + assertEquals(1, api.starts) + resumed.cancel() + } + + @Test + fun abandonCancelsOnlyWhenTheServerSupportsIt() = runTest { + val api = ScriptedApi() + val m = machine(api) + val job = launch { m.run() } + runCurrent() + job.cancel() + m.abandon(this) + advanceUntilIdle() + assertEquals(listOf("dev-1"), api.canceled) + + val old = ScriptedApi(capability = ApiResult.Success(DeviceLoginCapabilityResponse(cancel = false))) + val m2 = machine(old) + val job2 = launch { m2.run() } + runCurrent() + job2.cancel() + m2.abandon(this) + advanceUntilIdle() + assertEquals(emptyList(), old.canceled) + } + + @Test + fun abandonWithdrawsTheCodeWhenTheCapabilityCouldNotBeRead() = runTest { + val api = ScriptedApi(capability = ApiResult.NetworkError(RuntimeException("offline"))) + val m = machine(api) + val job = launch { m.run() } + runCurrent() + assertIs<DeviceSignInState.ShowingCode>(m.state.value) + job.cancel() + m.abandon(this) + advanceUntilIdle() + assertEquals(listOf("dev-1"), api.canceled, "unknown support still gets the cancel; an old server answers 404") + } + + @Test + fun cancellingTheRunDuringARenewalsWithdrawStillWithdrawsTheCode() = runTest { + val api = ScriptedApi( + start = { n -> ApiResult.Success(session("dev-$n", expiresIn = 10)) }, + cancelDurationMs = 1_000L, + ) + val m = machine(api) + val job = launch { m.run() } + // The poll at the deadline finds dev-1 pending: the withdraw starts. + advanceTimeBy(10_500) + assertEquals(emptyList(), api.canceled, "the withdraw is in flight") + job.cancel() + m.abandon(this) + advanceUntilIdle() + assertTrue("dev-1" in api.canceled, "the withdraw in flight survives cancelling the run: ${api.canceled}") + assertEquals(1, api.starts, "no code after the one withdrawn") + } + + @Test + fun aRunSupersededDuringASlowPollDropsItsLateAnswer() = runTest { + for (late in listOf("pending", "expired")) { + val api = ScriptedApi( + poll = { code -> ApiResult.Success(DeviceLoginPollResponse(if (code == "dev-1") late else "pending")) }, + // Answers at 12 s, between the new code's polls at 10 s and 15 s. + pollDurationMs = 12_000L, + ) + val m = machine(api) + // Never cancelled: it stands for a run left blocked in its call. + val stale = launch { m.run() } + runCurrent() + assertEquals(1, api.polls, "dev-1's poll is in flight") + + m.requestStop() + m.abandon(this) + m.restart() + api.pollDurationMs = 0L + val fresh = launch { m.run() } + runCurrent() + assertEquals("dev-2", assertIs<DeviceSignInState.ShowingCode>(m.state.value).session.deviceCode) + + advanceTimeBy(12_001) + assertTrue(stale.isCompleted, "the superseded run returned ($late)") + assertEquals( + "dev-2", + assertIs<DeviceSignInState.ShowingCode>(m.state.value).session.deviceCode, + "the withdrawn code never comes back ($late)", + ) + advanceTimeBy(10_000) + assertEquals("dev-2", assertIs<DeviceSignInState.ShowingCode>(m.state.value).session.deviceCode) + assertEquals(2, api.starts, "the new code is kept, not replaced ($late)") + assertEquals(listOf("dev-1"), api.canceled) + fresh.cancel() + } + } + + private companion object { + fun session(deviceCode: String, expiresIn: Int = 900, expiresAt: String = "2099-01-01T00:00:00Z") = DeviceLoginStartResponse( + deviceCode = deviceCode, + userCode = "4821-7730", + matchCode = "warm pony", + verificationUri = "https://prairie.test/activate", + verificationUriComplete = "https://prairie.test/activate?code=48217730", + expiresAt = expiresAt, + expiresIn = expiresIn, + interval = 5, + deviceName = "Shield", + devicePlatform = "android-tv", + ) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/HiddenLibrariesStoreTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/HiddenLibrariesStoreTest.kt new file mode 100644 index 0000000000..d7d48724a5 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/HiddenLibrariesStoreTest.kt @@ -0,0 +1,243 @@ +package org.prairieserver.prairie.repository + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.add +import org.prairieserver.prairie.model.personal.UserLibrary +import org.prairieserver.prairie.model.settings.EffectiveSettingValue +import org.prairieserver.prairie.model.settings.EffectiveSettingValuesResponse +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionKind +import org.prairieserver.prairie.network.PrairieJson +import org.prairieserver.prairie.network.api.PersonalDataApi +import org.prairieserver.prairie.network.api.SettingsApi +import org.prairieserver.prairie.repository.port.CatalogCachePort +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class HiddenLibrariesStoreTest { + + private class FakeSettings(var hidden: JsonElement = JsonNull) : SettingsApi( + org.prairieserver.prairie.network.apiv2.SettingsV2Api( + HttpClient(), + org.prairieserver.prairie.network.TokenManagerImpl(), + org.prairieserver.prairie.network.apiv2.ApiV2Gate.Unrestricted, + ), + ) { + var beforeAnswer: suspend () -> Unit = {} + var fails = false + + override suspend fun getEffectiveValues( + keys: List<String>, + libraryIds: List<Int>, + seriesIds: List<String>, + authority: AuthScopeSnapshot?, + ): ApiResult<EffectiveSettingValuesResponse> { + beforeAnswer() + if (fails) return ApiResult.Error(503, "unavailable", "Unavailable") + return ApiResult.Success( + EffectiveSettingValuesResponse( + settings = listOf(EffectiveSettingValue(key = HiddenLibrariesStore.KEY, value = hidden)), + ), + ) + } + } + + private class FakeCache : CatalogCachePort { + var cachedLibraries: List<UserLibrary>? = null + + override suspend fun cacheLibraries(libraries: List<UserLibrary>) { + cachedLibraries = libraries + } + + override suspend fun getCachedLibraries(): List<UserLibrary>? = cachedLibraries + } + + private var libraryRequests = 0 + + private fun librariesClient() = HttpClient( + MockEngine { + libraryRequests++ + respond( + """{"items":[{"id":"1","name":"Movies","type":"movies","sort_order":1},""" + + """{"id":"2","name":"TV Shows","type":"series","sort_order":2}]}""", + HttpStatusCode.OK, + headersOf(HttpHeaders.ContentType, "application/json"), + ) + }, + ) { install(ContentNegotiation) { json(PrairieJson) } } + + private fun ids(vararg values: Int) = buildJsonArray { values.forEach { add(it) } } + + @Test + fun parsesTheWebsAcceptedEncodings() { + assertEquals(setOf(2, 5), HiddenLibrariesStore.parseLibraryIds(ids(2, 5, 2))) + assertEquals(setOf(3), HiddenLibrariesStore.parseLibraryIds(JsonPrimitive("[3]"))) + assertEquals(emptySet(), HiddenLibrariesStore.parseLibraryIds(JsonNull)) + assertEquals(emptySet(), HiddenLibrariesStore.parseLibraryIds(null)) + assertEquals(emptySet(), HiddenLibrariesStore.parseLibraryIds(JsonPrimitive("not json"))) + val mixed = buildJsonArray { add("4"); add(0); add(-1); add(1.5); add(7); add(8.0) } + assertEquals(setOf(7, 8), HiddenLibrariesStore.parseLibraryIds(mixed)) + } + + @Test + fun libraryListLeavesOutHiddenLibrariesAndCachesWhatIsShown() = runTest { + val barrier = DefaultIdentityTransitionBarrier() + val cache = FakeCache() + val repository = PersonalDataRepository( + personalDataApi = PersonalDataApi(librariesClient()), + catalogCache = cache, + identityTransitions = barrier, + hiddenLibraries = HiddenLibrariesStore(SettingsRepository(FakeSettings(ids(2))), barrier), + ) + + val result = repository.listUserLibraries() + + assertEquals(listOf(1), (result as ApiResult.Success).data.map { it.id }) + assertEquals(listOf(1), cache.cachedLibraries?.map { it.id }) + } + + @Test + fun foregroundCheckDoesNotReportALibraryTheProfileHidAsAnAccessChange() = runTest { + val barrier = DefaultIdentityTransitionBarrier() + // The screens cached what they showed: library 2 is hidden. + val cache = FakeCache().apply { cachedLibraries = listOf(UserLibrary(id = 1, name = "Library 1", type = "movie")) } + val repository = PersonalDataRepository( + personalDataApi = PersonalDataApi(librariesClient()), + catalogCache = cache, + identityTransitions = barrier, + hiddenLibraries = HiddenLibrariesStore(SettingsRepository(FakeSettings(ids(2))), barrier), + ) + + assertFalse(repository.libraryListChangedSinceCached()) + assertEquals(listOf(1), cache.cachedLibraries?.map { it.id }) + } + + @Test + fun recheckTreatsANewlyHiddenLibraryAsRemovedWithoutReadingTwice() = runTest { + val barrier = DefaultIdentityTransitionBarrier() + val settings = FakeSettings() + val store = HiddenLibrariesStore(SettingsRepository(settings), barrier) + val repository = PersonalDataRepository( + personalDataApi = PersonalDataApi(librariesClient()), + identityTransitions = barrier, + hiddenLibraries = store, + ) + assertEquals(listOf(1, 2), (repository.listUserLibraries() as ApiResult.Success).data.map { it.id }) + + settings.hidden = ids(2) + store.refresh() + libraryRequests = 0 + val rechecked = repository.recheckUserLibraries(knownIds = setOf(1, 2)) + + assertEquals(listOf(1), (rechecked as ApiResult.Success).data.map { it.id }) + assertEquals(1, libraryRequests) + } + + @Test + fun aSwitchWhileTheHiddenSetLoadsAnswersIdentityChanged() = runTest { + val barrier = DefaultIdentityTransitionBarrier() + val settings = FakeSettings(ids(2)) + settings.beforeAnswer = { barrier.changing(IdentityTransitionKind.PROFILE_SWITCH) { } } + val cache = FakeCache() + val repository = PersonalDataRepository( + personalDataApi = PersonalDataApi(librariesClient()), + catalogCache = cache, + identityTransitions = barrier, + hiddenLibraries = HiddenLibrariesStore(SettingsRepository(settings), barrier), + ) + + val result = repository.listUserLibraries() + + assertEquals("identity_changed", (result as ApiResult.Error).error) + assertEquals(null, cache.cachedLibraries) + } + + @Test + fun onlyAChangeFoundByARefreshBumpsTheRevision() = runTest { + val barrier = DefaultIdentityTransitionBarrier() + val settings = FakeSettings(ids(2)) + val store = HiddenLibrariesStore(SettingsRepository(settings), barrier) + + store.ensureLoaded() + store.refresh() + assertEquals(0, store.revision.value) + + settings.hidden = ids() + store.ensureLoaded() + assertEquals(setOf(2), store.current()) + store.refresh() + assertEquals(emptySet(), store.current()) + assertEquals(1, store.revision.value) + } + + @Test + fun aSuccessfulReadAfterAFailedOneBumpsTheRevision() = runTest { + val barrier = DefaultIdentityTransitionBarrier() + val settings = FakeSettings(ids(2)).apply { fails = true } + val store = HiddenLibrariesStore(SettingsRepository(settings), barrier) + + store.ensureLoaded() + assertEquals(emptySet(), store.current()) + + settings.fails = false + store.ensureLoaded() + assertEquals(setOf(2), store.current()) + assertEquals(1, store.revision.value) + } + + @Test + fun aReadStartedMidSwitchAnswersForTheNewIdentity() = runTest { + val barrier = DefaultIdentityTransitionBarrier() + val settings = FakeSettings(ids(2)) + val store = HiddenLibrariesStore(SettingsRepository(settings), barrier) + + lateinit var read: kotlinx.coroutines.Deferred<Unit> + barrier.changing(IdentityTransitionKind.PROFILE_SWITCH) { + // The generation has advanced; the new identity isn't installed yet. + read = async { store.ensureLoaded() } + testScheduler.runCurrent() + settings.hidden = ids(1) + } + read.await() + + assertEquals(setOf(1), store.current()) + } + + @Test + fun aReadThatStraddlesAProfileSwitchIsDropped() = runTest { + val barrier = DefaultIdentityTransitionBarrier() + val settings = FakeSettings(ids(2)) + val store = HiddenLibrariesStore(SettingsRepository(settings), barrier) + store.refresh() + assertEquals(setOf(2), store.current()) + + val entered = CompletableDeferred<Unit>() + val release = CompletableDeferred<Unit>() + settings.beforeAnswer = { entered.complete(Unit); release.await() } + settings.hidden = ids(1) + val read = async { store.refresh() } + entered.await() + barrier.changing(IdentityTransitionKind.PROFILE_SWITCH) { } + release.complete(Unit) + read.await() + + assertEquals(emptySet(), store.current()) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/HomeRealtimeAccessChangeTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/HomeRealtimeAccessChangeTest.kt new file mode 100644 index 0000000000..84542f0a54 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/HomeRealtimeAccessChangeTest.kt @@ -0,0 +1,145 @@ +package org.prairieserver.prairie.repository + +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.prairieserver.prairie.network.AccessChangeSignals +import org.prairieserver.prairie.network.HomeRealtimeClient +import org.prairieserver.prairie.network.HomeRealtimeEvent +import org.prairieserver.prairie.network.TokenManagerImpl +import org.prairieserver.prairie.network.apiv2.isAccessChangedClose +import org.prairieserver.prairie.network.apiv2.isAccessChangedFrame +import io.ktor.websocket.CloseReason +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class HomeRealtimeAccessChangeTest { + + private class ScriptedClient(private val script: (Int) -> Flow<HomeRealtimeEvent>) : HomeRealtimeClient { + var connects = 0 + override fun connect(): Flow<HomeRealtimeEvent> = script(++connects) + } + + @Test + fun `an access change refreshes Home once per burst`() = runTest { + val signals = AccessChangeSignals() + val client = ScriptedClient { flow { awaitCancellation() } } + val coordinator = HomeRealtimeCoordinator(client, TokenManagerImpl(), signals) + var refreshes = 0 + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + coordinator.refreshSignals.collect { refreshes++ } + } + testScheduler.runCurrent() + + // Both events sockets see the change, each reporting it once. + signals.reportAccessChanged() + signals.reportAccessChanged() + testScheduler.advanceTimeBy(10_000L) + + assertEquals(1, refreshes) + } + + @Test + fun `reports of one change from both sockets refresh once`() = runTest { + val signals = AccessChangeSignals { testScheduler.currentTime } + val changes = mutableListOf<Long>() + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + signals.changes.collect { changes += testScheduler.currentTime } + } + testScheduler.runCurrent() + + // Each socket runs its own 15-second server check, so the second + // report of the same change can arrive seconds after the first. + signals.reportAccessChanged() + testScheduler.runCurrent() + testScheduler.advanceTimeBy(12_000L) + signals.reportAccessChanged() + testScheduler.runCurrent() + assertEquals(listOf(0L), changes, "the first report refreshes at once; the second is the same change") + + // A later change refreshes again. + testScheduler.advanceTimeBy(AccessChangeSignals.ACCESS_CHANGE_COALESCE_MS) + signals.reportAccessChanged() + testScheduler.runCurrent() + assertEquals(2, changes.size) + } + + @Test + fun `a second change reported by a reconnected socket inside the window refreshes`() = runTest { + val signals = AccessChangeSignals { testScheduler.currentTime } + val changes = mutableListOf<Long>() + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + signals.changes.collect { changes += testScheduler.currentTime } + } + testScheduler.runCurrent() + val homeOpened = signals.connectionTime() + val notificationsOpened = signals.connectionTime() + + // Home reports the first edit and reconnects under the new policy. + testScheduler.advanceTimeBy(3_000L) + signals.reportAccessChanged(homeOpened) + testScheduler.runCurrent() + testScheduler.advanceTimeBy(1_500L) + val homeReopened = signals.connectionTime() + + // Notifications reports the same edit from its older connection. + testScheduler.advanceTimeBy(7_500L) + signals.reportAccessChanged(notificationsOpened) + testScheduler.runCurrent() + assertEquals(1, changes.size, "the older connection's report is the change already loaded") + + // A second edit, seen by the reconnected Home socket 15 s after it opened. + testScheduler.advanceTimeBy(4_000L) + signals.reportAccessChanged(homeReopened) + testScheduler.runCurrent() + assertEquals(2, changes.size, "a connection opened after the refresh reports a new change") + } + + @Test + fun `nothing refreshes without an access change`() = runTest { + val signals = AccessChangeSignals() + val coordinator = HomeRealtimeCoordinator(ScriptedClient { flow { awaitCancellation() } }, TokenManagerImpl(), signals) + var refreshes = 0 + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + coordinator.refreshSignals.collect { refreshes++ } + } + + testScheduler.advanceTimeBy(10_000L) + + assertEquals(0, refreshes) + } + + @Test + fun `the socket reconnects promptly after an access-changed close`() = runTest { + // First connection: subscribed, then the server's 4001 close ends it. + val client = ScriptedClient { attempt -> + if (attempt == 1) flowOf(HomeRealtimeEvent.Connected, HomeRealtimeEvent.Closed()) + else flow { awaitCancellation() } + } + val coordinator = HomeRealtimeCoordinator(client, TokenManagerImpl(), AccessChangeSignals()) + val job = coordinator.connect(backgroundScope) + + testScheduler.advanceTimeBy(1_100L) + + assertEquals(2, client.connects, "a fresh connection (and ticket) after the initial backoff") + job.cancel() + } + + @Test + fun `access-change frames and close codes are recognised`() { + assertTrue(isAccessChangedFrame("""{"type":"access_changed"}""")) + assertFalse(isAccessChangedFrame("""{"type":"subscribed","channel":"catalog"}""")) + assertFalse(isAccessChangedFrame("not json")) + assertTrue(isAccessChangedClose(CloseReason(4001, "access_changed"))) + assertFalse(isAccessChangedClose(CloseReason(CloseReason.Codes.NORMAL, ""))) + assertFalse(isAccessChangedClose(null)) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/HouseholdManagementSessionTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/HouseholdManagementSessionTest.kt new file mode 100644 index 0000000000..abd1ef2672 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/HouseholdManagementSessionTest.kt @@ -0,0 +1,111 @@ +package org.prairieserver.prairie.repository + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.prairieserver.prairie.model.profile.Profile +import org.prairieserver.prairie.model.profile.VerifyPinResponse +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class HouseholdManagementSessionTest { + private val primary = Profile(id = "1", name = "Parent", isPrimary = true, hasPin = true) + private val stale = ApiResult.Error(403, "profile_verification_required", "") + private val pins = mutableListOf<String>() + private val session = HouseholdManagementSession( + verifyPin = { _, pin -> + pins += pin + ApiResult.Success( + if (pin == "1234") VerifyPinResponse(valid = true, profileToken = "fresh") else VerifyPinResponse(valid = false), + ) + }, + captureScope = { null }, + ) + + @Test fun staleTokenReasksPinAndRetriesOnce() = runTest { + session.begin(primary, "old", null) + val tokensSent = mutableListOf<String?>() + val result = async { + session.run<Unit> { manager -> + tokensSent += manager?.profileToken + if (manager?.profileToken == "old") stale else ApiResult.Success(Unit) + } + } + runCurrent() + assertEquals(primary, assertNotNull(session.reverify.value).profile) + session.submitPin("0000") + runCurrent() + assertEquals("Wrong PIN. Try again.", session.reverify.value?.error) + session.submitPin("1234") + runCurrent() + assertIs<ApiResult.Success<Unit>>(result.await()) + assertEquals<List<String?>>(listOf("old", "fresh"), tokensSent) + assertEquals(listOf("0000", "1234"), pins) + assertEquals("fresh", session.manager?.profileToken) + assertNull(session.reverify.value) + assertTrue(session.isActive.value) + } + + @Test fun cancelEndsTheSessionWithoutRetrying() = runTest { + session.begin(primary, "old", null) + var calls = 0 + val result = async { session.run<Unit> { calls++; stale } } + runCurrent() + session.cancelReverify() + runCurrent() + assertEquals("profile_verification_required", assertIs<ApiResult.Error>(result.await()).error) + assertEquals(1, calls) + assertFalse(session.isActive.value) + assertNull(session.manager) + assertNull(session.reverify.value) + } + + @Test fun cancelWhileCheckingDoesNotRenewOrRetry() = runTest { + val answer = CompletableDeferred<ApiResult<VerifyPinResponse>>() + val slow = HouseholdManagementSession(verifyPin = { _, _ -> answer.await() }, captureScope = { null }) + slow.begin(primary, "old", null) + var calls = 0 + val result = async { slow.run<Unit> { calls++; stale } } + runCurrent() + slow.submitPin("1234") + runCurrent() + assertTrue(assertNotNull(slow.reverify.value).isVerifying) + slow.cancelReverify() + answer.complete(ApiResult.Success(VerifyPinResponse(valid = true, profileToken = "fresh"))) + runCurrent() + assertIs<ApiResult.Error>(result.await()) + assertEquals(1, calls) + assertFalse(slow.isActive.value) + assertNull(slow.reverify.value) + } + + @Test fun accountChangeDuringVerificationEndsTheSession() = runTest { + val accountA = AuthScopeSnapshot(serverId = "s1", profileId = null, serverUrl = "https://a", profileToken = null) + var current: AuthScopeSnapshot? = accountA + val switching = HouseholdManagementSession( + verifyPin = { _, _ -> + current = accountA.copy(serverId = "s2", serverUrl = "https://b") + ApiResult.Success(VerifyPinResponse(valid = true, profileToken = "fresh")) + }, + captureScope = { current }, + ) + switching.begin(primary, "old", accountA) + var calls = 0 + val result = async { switching.run<Unit> { calls++; stale } } + runCurrent() + switching.submitPin("1234") + runCurrent() + assertIs<ApiResult.Error>(result.await()) + assertEquals(1, calls) + assertFalse(switching.isActive.value) + assertNull(switching.reverify.value) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/NotificationsRepositoryTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/NotificationsRepositoryTest.kt index 9795d79a62..a68d1e75f9 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/NotificationsRepositoryTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/NotificationsRepositoryTest.kt @@ -401,7 +401,7 @@ class NotificationsRepositoryTest { assertFalse(NotificationRealtimeEvent.Read("x").isAuthClose()) } - // ---- connectRealtime: auth close stops loop and sets realtimeFatal ------- + // ---- connectRealtime: auth close parks the loop until the identity changes ---- @Test fun `connectRealtime stops reconnecting and sets realtimeFatal on auth Closed`() = @@ -420,20 +420,86 @@ class NotificationsRepositoryTest { } val repo = NotificationsRepository(api, realtimeFactory = { realtime }) val job = repo.connectRealtime(this) - // Give the loop a chance to run. Because reconnect uses delay, and - // testScheduler advances time, we advance past the initial backoff. - testScheduler.advanceTimeBy(2_000L) + // Well past every backoff step: a parked loop must not retry the + // identity that was just refused. + testScheduler.advanceTimeBy(120_000L) kotlinx.coroutines.yield() - job.cancel() - // connect() must have been called exactly once — the loop must not - // have retried after an auth-class Closed. assertEquals(1, connectCount, "Auth-close must stop the reconnect loop (connectCount=$connectCount)") assertTrue(repo.realtimeFatal.value, "realtimeFatal must be true after an auth-class Closed") + assertTrue(job.isActive, "the loop parks rather than ending") + job.cancel() + } + + @Test + fun `connectRealtime resumes after a stale-profile refusal once the profile is re-verified`() = + kotlinx.coroutines.test.runTest { + val api = FakeNotificationsApi() + var connectCount = 0 + val realtime = object : org.prairieserver.prairie.network.NotificationsRealtimeClient { + override fun connect(): kotlinx.coroutines.flow.Flow<NotificationRealtimeEvent> { + connectCount++ + return if (connectCount == 1) { + // The ticket mint was refused: the profile token is stale. + kotlinx.coroutines.flow.flowOf(NotificationRealtimeEvent.Closed("ticket_error_403")) + } else { + kotlinx.coroutines.flow.flow { awaitCancellation() } + } + } + } + val repo = NotificationsRepository(api, realtimeFactory = { realtime }) + val job = repo.connectRealtime(this) + testScheduler.advanceTimeBy(60_000L) + kotlinx.coroutines.yield() + assertEquals(1, connectCount) + assertTrue(repo.realtimeFatal.value) + + // Re-entering the PIN (or picking another profile) resets the + // repository, which is the identity change the loop waits for. + repo.reset() + testScheduler.runCurrent() + + assertEquals(2, connectCount, "the loop must reconnect with a fresh ticket") + assertFalse(repo.realtimeFatal.value) + job.cancel() + } + + @Test + fun `connectRealtime treats an access-changed close as an ordinary reconnect`() = + kotlinx.coroutines.test.runTest { + val api = FakeNotificationsApi() + var connectCount = 0 + val realtime = object : org.prairieserver.prairie.network.NotificationsRealtimeClient { + override fun connect(): kotlinx.coroutines.flow.Flow<NotificationRealtimeEvent> { + connectCount++ + return if (connectCount == 1) { + // The server's 4001 close ends the socket cleanly. + kotlinx.coroutines.flow.flowOf( + NotificationRealtimeEvent.Snapshot(emptyList()), + NotificationRealtimeEvent.Closed(), + ) + } else { + kotlinx.coroutines.flow.flow { awaitCancellation() } + } + } + } + val repo = NotificationsRepository(api, realtimeFactory = { realtime }) + val job = repo.connectRealtime(this) + testScheduler.advanceTimeBy(1_100L) + kotlinx.coroutines.yield() + + assertEquals(2, connectCount, "a clean close reconnects after the initial backoff") + assertFalse(repo.realtimeFatal.value) + job.cancel() } + @Test + fun `access_changed close reasons are not auth closes`() { + assertFalse(NotificationRealtimeEvent.Closed("access_changed").isAuthClose()) + } + // ---- connectRealtime: backoff resets on first event of each connect ------ @Test diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/PersonalDataRepositoryCacheTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/PersonalDataRepositoryCacheTest.kt index 257f4dd503..43999acb88 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/PersonalDataRepositoryCacheTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/PersonalDataRepositoryCacheTest.kt @@ -20,6 +20,7 @@ import org.prairieserver.prairie.network.api.PersonalDataApi import org.prairieserver.prairie.repository.port.CatalogCachePort import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue class PersonalDataRepositoryCacheTest { @@ -30,6 +31,49 @@ class PersonalDataRepositoryCacheTest { override suspend fun cacheLibraries(libraries: List<UserLibrary>) { cachedLibraries = libraries } + + override suspend fun getCachedLibraries(): List<UserLibrary>? = cachedLibraries + } + + private fun librariesClient(vararg ids: Int) = HttpClient( + MockEngine { + val items = ids.joinToString(",") { """{"id":"$it","name":"Library $it","type":"movie","sort_order":0}""" } + respond( + """{"items":[$items]}""", + HttpStatusCode.OK, + headersOf(HttpHeaders.ContentType, "application/json"), + ) + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + } + + private fun library(id: Int) = UserLibrary(id = id, name = "Library $id", type = "movie") + + @Test + fun libraryListChangeIsReportedAgainstTheCachedList() = runTest { + val cache = FakeCache().apply { cachedLibraries = listOf(library(1), library(2)) } + val repository = PersonalDataRepository(PersonalDataApi(librariesClient(1)), catalogCache = cache) + + assertTrue(repository.libraryListChangedSinceCached()) + assertEquals(listOf(1), cache.cachedLibraries?.map { it.id }) + // The fresh list is now the baseline, so the same answer is no change. + assertFalse(repository.libraryListChangedSinceCached()) + } + + @Test + fun libraryListWithTheSameLibrariesInAnotherOrderIsNoChange() = runTest { + val cache = FakeCache().apply { cachedLibraries = listOf(library(1), library(2)) } + val repository = PersonalDataRepository(PersonalDataApi(librariesClient(2, 1)), catalogCache = cache) + + assertFalse(repository.libraryListChangedSinceCached()) + } + + @Test + fun libraryListWithoutACachedListIsNoChange() = runTest { + val repository = PersonalDataRepository(PersonalDataApi(librariesClient(1)), catalogCache = FakeCache()) + + assertFalse(repository.libraryListChangedSinceCached()) } @Test @@ -62,7 +106,8 @@ class PersonalDataRepositoryCacheTest { identityTransitions.changing(IdentityTransitionKind.PROFILE_SWITCH) { } releaseResponse.complete(Unit) - assertTrue(oldProfileRequest.await() is ApiResult.Success) + // The old profile's list doesn't come back for the new profile either. + assertEquals("identity_changed", (oldProfileRequest.await() as ApiResult.Error).error) assertEquals(null, cache.cachedLibraries) } } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/ProfileVerificationRecoveryTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/ProfileVerificationRecoveryTest.kt new file mode 100644 index 0000000000..b98c534b83 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/ProfileVerificationRecoveryTest.kt @@ -0,0 +1,378 @@ +package org.prairieserver.prairie.repository + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.prairieserver.prairie.network.AccessChangeSignals +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionKind +import org.prairieserver.prairie.network.ProfileIdentity +import org.prairieserver.prairie.network.StaleProfileReport +import org.prairieserver.prairie.network.TemporaryAuthScope +import org.prairieserver.prairie.network.TokenManager +import org.prairieserver.prairie.network.TokenManagerImpl +import org.prairieserver.prairie.network.api.ProfileApi +import org.prairieserver.prairie.network.apiv2.ApiV2Gate +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * A stale PIN profile token (403 `profile_verification_required`) must send the + * user back to profile selection without signing them out, and only for the + * identity that is actually active. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ProfileVerificationRecoveryTest { + + private val serverUrl = "https://prairie.example" + + private class ScopedTokenManager( + private val barrier: IdentityTransitionBarrier, + private val delegate: TokenManagerImpl = TokenManagerImpl(), + ) : TokenManager by delegate { + override suspend fun snapshotCurrentScope() = AuthScopeSnapshot( + serverId = "server-1", + profileId = delegate.getProfileId(), + serverUrl = delegate.getServerUrl(), + profileToken = delegate.getProfileToken(), + identityGeneration = barrier.generation.value, + isIdentityGenerationStamped = true, + credentialEpoch = 1, + ) + + override suspend fun getCurrentServerId(): String = "server-1" + + // Interface delegation would read the delegate's identity otherwise. + override suspend fun getProfileIdentity(): ProfileIdentity = delegate.getProfileIdentity() + } + + private class Fixture( + val barrier: DefaultIdentityTransitionBarrier, + val tokens: ScopedTokenManager, + val profiles: ProfileRepository, + val recovery: ProfileVerificationRecovery, + ) + + private suspend fun kotlinx.coroutines.test.TestScope.fixture(): Fixture { + val barrier = DefaultIdentityTransitionBarrier() + val tokens = ScopedTokenManager(barrier) + tokens.setServerUrl(serverUrl) + tokens.saveTokens("access", "refresh", 3_600) + val noOpClient = HttpClient(MockEngine { _ -> + respond("{}", HttpStatusCode.OK, headersOf("Content-Type", "application/json")) + }) + val profiles = ProfileRepository( + profileApi = ProfileApi(noOpClient, ApiV2Gate.Unrestricted), + tokenManager = tokens, + identityTransitions = barrier, + ) + profiles.selectProfile("kid", "stale-pin-token") + val recovery = ProfileVerificationRecovery(AccessChangeSignals(), tokens, profiles, barrier, backgroundScope) + return Fixture(barrier, tokens, profiles, recovery) + } + + private fun report( + profileId: String = "kid", + profileToken: String? = "stale-pin-token", + url: String = "$serverUrl/api/v2/home/sections", + pinned: AuthScopeSnapshot? = null, + ) = StaleProfileReport(url, profileId, profileToken, pinned) + + @Test + fun `a stale profile token clears the profile and keeps the sign-in`() = runTest { + val f = fixture() + + f.recovery.handle(report()) + + assertNull(f.tokens.getProfileId()) + assertNull(f.tokens.getProfileToken()) + assertEquals("access", f.tokens.getAccessToken(), "the account must stay signed in") + assertEquals("refresh", f.tokens.getRefreshToken()) + val prompt = assertNotNull(f.recovery.pending.value) + assertEquals( + ProfilePromptAction.Navigate, + f.recovery.actionFor(prompt, "home", deferRoutes = setOf("player")), + ) + } + + @Test + fun `repeated refusals for the same identity clear it once`() = runTest { + val f = fixture() + + f.recovery.handle(report()) + val generationAfterFirst = f.barrier.generation.value + // Every screen that loaded under the stale token reports it too. + repeat(3) { f.recovery.handle(report()) } + + assertEquals(generationAfterFirst, f.barrier.generation.value, "no further identity transitions") + } + + @Test + fun `a refusal for a token that was already replaced is ignored`() = runTest { + val f = fixture() + // The user re-entered the PIN before the late response arrived. + f.profiles.selectProfile("kid", "fresh-pin-token") + + f.recovery.handle(report(profileToken = "stale-pin-token")) + + assertEquals("kid", f.tokens.getProfileId()) + assertEquals("fresh-pin-token", f.tokens.getProfileToken()) + assertNull(f.recovery.pending.value) + } + + @Test + fun `a refusal for another profile or server is ignored`() = runTest { + val f = fixture() + + f.recovery.handle(report(profileId = "parent")) + f.recovery.handle(report(url = "https://other.example/api/v2/home/sections")) + + assertEquals("kid", f.tokens.getProfileId()) + assertNull(f.recovery.pending.value) + } + + @Test + fun `a pinned background request from an earlier identity is ignored`() = runTest { + val f = fixture() + val captured = f.tokens.snapshotCurrentScope() + // Same profile id and token, but the identity moved since the capture. + f.barrier.changing(IdentityTransitionKind.PROFILE_SWITCH) { } + + f.recovery.handle(report(pinned = captured)) + + assertEquals("kid", f.tokens.getProfileId()) + assertNull(f.recovery.pending.value) + } + + @Test + fun `a pinned background request on the active identity recovers it`() = runTest { + val f = fixture() + + f.recovery.handle(report(pinned = f.tokens.snapshotCurrentScope())) + + assertNull(f.tokens.getProfileId()) + assertNotNull(f.recovery.pending.value) + } + + @Test + fun `a remote playback overlay is never cleared`() = runTest { + val f = fixture() + f.tokens.beginTemporaryScope( + TemporaryAuthScope( + generationId = "overlay", + serverId = "server-1", + serverUrl = serverUrl, + accessToken = "overlay-access", + refreshToken = "overlay-refresh", + profileId = "kid", + profileToken = "stale-pin-token", + expiresAtEpochMs = Long.MAX_VALUE, + ), + ) + + f.recovery.handle(report()) + + assertEquals("kid", f.tokens.getProfileId()) + assertNull(f.recovery.pending.value) + } + + @Test + fun `the prompt waits behind playback and drops once a profile is picked`() = runTest { + val f = fixture() + f.recovery.handle(report()) + val prompt = assertNotNull(f.recovery.pending.value) + + assertEquals( + ProfilePromptAction.Defer, + f.recovery.actionFor(prompt, "player", deferRoutes = setOf("player")), + ) + + f.profiles.selectProfile("kid", "fresh-pin-token") + assertEquals( + ProfilePromptAction.Drop, + f.recovery.actionFor(prompt, "home", deferRoutes = setOf("player")), + ) + } + + @Test + fun `the prompt drops after sign-out`() = runTest { + val f = fixture() + f.recovery.handle(report()) + val prompt = assertNotNull(f.recovery.pending.value) + + f.tokens.clearTokens() + + assertFalse(f.recovery.isCurrent(prompt)) + f.recovery.consume(prompt) + assertNull(f.recovery.pending.value) + } + + @Test + fun `unrelated identity transitions keep the prompt`() = runTest { + val f = fixture() + f.recovery.handle(report()) + val prompt = assertNotNull(f.recovery.pending.value) + + // For example another server removed, or the active one re-selected. + f.barrier.changing(IdentityTransitionKind.SERVER_REMOVE) { } + + assertEquals( + ProfilePromptAction.Navigate, + f.recovery.actionFor(prompt, "home", deferRoutes = setOf("player")), + ) + } + + @Test + fun `a remote playback overlay defers the prompt until it ends`() = runTest { + val f = fixture() + f.recovery.handle(report()) + val prompt = assertNotNull(f.recovery.pending.value) + f.tokens.beginTemporaryScope( + TemporaryAuthScope( + generationId = "overlay", + serverId = "server-1", + serverUrl = serverUrl, + accessToken = "overlay-access", + refreshToken = "overlay-refresh", + profileId = "guest", + profileToken = "guest-token", + expiresAtEpochMs = Long.MAX_VALUE, + ), + ) + + assertEquals(ProfilePromptAction.Defer, f.recovery.actionFor(prompt, "home", deferRoutes = emptySet())) + + f.tokens.endTemporaryScope() + assertEquals(ProfilePromptAction.Navigate, f.recovery.actionFor(prompt, "home", deferRoutes = emptySet())) + } + + @Test + fun `ending a remote playback overlay asks again without a navigation`() = runTest { + // Production shape: the token manager mutates identity through the same barrier. + val barrier = DefaultIdentityTransitionBarrier() + val tokens = ScopedTokenManager(barrier, TokenManagerImpl(barrier)) + tokens.setServerUrl(serverUrl) + tokens.saveTokens("access", "refresh", 3_600) + val profiles = ProfileRepository( + profileApi = ProfileApi(HttpClient(MockEngine { respond("{}") }), ApiV2Gate.Unrestricted), + tokenManager = tokens, + identityTransitions = barrier, + ) + profiles.selectProfile("kid", "stale-pin-token") + val recovery = ProfileVerificationRecovery(AccessChangeSignals(), tokens, profiles, barrier, backgroundScope) + recovery.handle(report()) + val prompt = assertNotNull(recovery.pending.value) + tokens.beginTemporaryScope( + TemporaryAuthScope( + generationId = "overlay", + serverId = "server-1", + serverUrl = serverUrl, + accessToken = "overlay-access", + refreshToken = "overlay-refresh", + profileId = "guest", + profileToken = "guest-token", + expiresAtEpochMs = Long.MAX_VALUE, + ), + ) + val checks = mutableListOf<ProfileVerificationPrompt?>() + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + recovery.promptChecks.collect { checks += it } + } + testScheduler.runCurrent() + assertEquals(listOf<ProfileVerificationPrompt?>(prompt), checks) + assertEquals(ProfilePromptAction.Defer, recovery.actionFor(prompt, "home", deferRoutes = emptySet())) + + tokens.endTemporaryScope() + testScheduler.runCurrent() + + assertTrue(checks.size > 1, "the overlay ending re-emits the pending prompt") + assertEquals(prompt, checks.last()) + assertEquals(ProfilePromptAction.Navigate, recovery.actionFor(prompt, "home", deferRoutes = emptySet())) + } + + @Test + fun `on the picker the prompt is consumed without navigating`() = runTest { + val f = fixture() + val cleared = mutableListOf<Unit>() + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + f.recovery.profileCleared.collect { cleared += it } + } + f.recovery.handle(report()) + testScheduler.runCurrent() + val prompt = assertNotNull(f.recovery.pending.value) + + assertEquals( + ProfilePromptAction.AlreadyThere, + f.recovery.actionFor(prompt, "profiles", deferRoutes = emptySet(), pickerRoutes = setOf("profiles")), + ) + assertEquals(1, cleared.size, "the picker is told to reload its grid") + } + + @Test + fun `a failing clear does not stop later reports`() = runTest { + val barrier = DefaultIdentityTransitionBarrier() + var failNext = true + barrier.installGate { + if (failNext) { + failNext = false + error("gate failed") + } + } + val tokens = ScopedTokenManager(barrier) + tokens.setServerUrl(serverUrl) + tokens.saveTokens("access", "refresh", 3_600) + tokens.setProfileIdentity("kid", "stale-pin-token") + val profiles = ProfileRepository( + profileApi = ProfileApi(HttpClient(MockEngine { respond("{}") }), ApiV2Gate.Unrestricted), + tokenManager = tokens, + identityTransitions = barrier, + ) + val signals = AccessChangeSignals() + val recovery = ProfileVerificationRecovery(signals, tokens, profiles, barrier, backgroundScope) + testScheduler.runCurrent() + + signals.reportStaleProfile(report()) + testScheduler.runCurrent() + assertEquals("kid", tokens.getProfileId(), "the failed attempt changed nothing") + + signals.reportStaleProfile(report()) + testScheduler.runCurrent() + assertNull(tokens.getProfileId()) + assertNotNull(recovery.pending.value) + } + + @Test + fun `reports reach the recovery through the signals`() = runTest { + val barrier = DefaultIdentityTransitionBarrier() + val tokens = ScopedTokenManager(barrier) + tokens.setServerUrl(serverUrl) + tokens.saveTokens("access", "refresh", 3_600) + val profiles = ProfileRepository( + profileApi = ProfileApi(HttpClient(MockEngine { respond("{}") }), ApiV2Gate.Unrestricted), + tokenManager = tokens, + identityTransitions = barrier, + ) + profiles.selectProfile("kid", "stale-pin-token") + val signals = AccessChangeSignals() + val recovery = ProfileVerificationRecovery(signals, tokens, profiles, barrier, backgroundScope) + testScheduler.runCurrent() + + signals.reportStaleProfile(report()) + testScheduler.runCurrent() + + assertNull(tokens.getProfileId()) + assertTrue(recovery.pending.value != null) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/RequestsRepositoryTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/RequestsRepositoryTest.kt index 366e897a06..fc25f36159 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/RequestsRepositoryTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/RequestsRepositoryTest.kt @@ -1,5 +1,7 @@ package org.prairieserver.prairie.repository +import org.prairieserver.prairie.model.request.AdminRequestAction +import org.prairieserver.prairie.model.request.AdminRequestCapabilities import org.prairieserver.prairie.model.request.CreateMediaRequest import org.prairieserver.prairie.model.request.MediaRequest import org.prairieserver.prairie.model.request.RequestMediaDetail @@ -12,10 +14,14 @@ import org.prairieserver.prairie.model.request.RequestsListResponse import org.prairieserver.prairie.model.request.RequestStatus import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.api.RequestsApi +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlin.test.assertTrue private fun stubRequest( @@ -39,6 +45,7 @@ private class FakeRequestsApi( private val mineResult: ApiResult<RequestsListResponse> = ApiResult.Success(RequestsListResponse()), private val createResult: ApiResult<MediaRequest> = ApiResult.NetworkError(IllegalStateException("no fake")), private val cancelResult: ApiResult<MediaRequest> = ApiResult.NetworkError(IllegalStateException("no fake")), + private val mineGate: CompletableDeferred<Unit>? = null, ) : RequestsApi { var mineCalls = 0 @@ -70,6 +77,7 @@ private class FakeRequestsApi( offset: Int?, ): ApiResult<RequestsListResponse> { mineCalls++ + mineGate?.await() return mineResult } @@ -77,6 +85,19 @@ private class FakeRequestsApi( ApiResult.NetworkError(IllegalStateException("no fake")) override suspend fun cancel(id: String): ApiResult<MediaRequest> = cancelResult + + override suspend fun adminCapabilities(): ApiResult<AdminRequestCapabilities> = + ApiResult.NetworkError(IllegalStateException("no fake")) + + override suspend fun adminRequests( + status: String?, + outcome: String?, + mediaType: String?, + tmdbId: Int?, + ): ApiResult<RequestsListResponse> = ApiResult.NetworkError(IllegalStateException("no fake")) + + override suspend fun adminAction(id: String, action: AdminRequestAction, reason: String?): ApiResult<MediaRequest> = + ApiResult.NetworkError(IllegalStateException("no fake")) } class RequestsRepositoryTest { @@ -154,4 +175,59 @@ class RequestsRepositoryTest { assertTrue(repo.mine.first().isEmpty()) } + + @Test + fun `a read that answers after reset stores nothing`() = runTest { + val previous = stubRequest("a", 1) + val gate = CompletableDeferred<Unit>() + val api = FakeRequestsApi(mineResult = ApiResult.Success(RequestsListResponse(listOf(previous))), mineGate = gate) + val repo = RequestsRepository(api) + + val read = async { repo.refreshMine() } + runCurrent() + // The profile switches while the previous profile's list is in flight. + repo.reset() + gate.complete(Unit) + + assertTrue(read.await() is ApiResult.Error) + assertTrue(repo.mine.first().isEmpty()) + assertNull(repo.cache.ownRecord(previous.cacheKey())) + } + + @Test + fun `a recent complete read answers for one title until reset`() = runTest { + val api = FakeRequestsApi(mineResult = ApiResult.Success(RequestsListResponse(listOf(stubRequest("a", 1))))) + val repo = RequestsRepository(api) + + repo.refreshMine() + repo.ensureMine() + assertEquals(1, api.mineCalls) + + repo.reset() + repo.ensureMine() + assertEquals(2, api.mineCalls) + } + + @Test + fun `a list read that a create overtook keeps the created request`() = runTest { + val gate = CompletableDeferred<Unit>() + val created = stubRequest("created", 2) + val api = FakeRequestsApi( + mineResult = ApiResult.Success(RequestsListResponse(listOf(stubRequest("old", 1)))), + createResult = ApiResult.Success(created), + mineGate = gate, + ) + val repo = RequestsRepository(api) + + val read = async { repo.refreshMine() } + runCurrent() + repo.create(CreateMediaRequest(mediaType = RequestMediaType.Movie, tmdbId = 2, title = "Movie 2")) + gate.complete(Unit) + read.await() + + assertEquals(listOf(created), repo.mine.first()) + // The snapshot doesn't count as fresh, so the next caller reads again. + repo.ensureMine() + assertEquals(2, api.mineCalls) + } } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/SequencedPlaybackTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/SequencedPlaybackTest.kt index 66cf21d75f..4bb2ee062c 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/SequencedPlaybackTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/SequencedPlaybackTest.kt @@ -83,18 +83,21 @@ class SequencedPlaybackTest { } finally { c.close() } } - @Test fun recoveryRetriesLostStopWithExactBodyAndNoAutoplay() = runTest { + @Test fun laterStartsRetryALostStopWithExactBody() = runTest { val store = Store().apply { entries = listOf(entry()) } val deletes = mutableListOf<String>() + var starts = 0 val c = client { req -> when (req.url.encodedPath) { "/api/v2/playback/capabilities" -> reply(caps()) "/api/v2/account/me" -> reply(account) + "/api/v2/playback/start" -> { starts++; reply(decision("session-${starts + 1}"), HttpStatusCode.Created) } "/api/v2/playback/session-1" -> { assertEquals(HttpMethod.Delete, req.method) assertTrue(req.attributes[SingleAttemptAttributeKey]) deletes += req.body.toByteArray().decodeToString() - assertEquals(store.entries.single().stop, PrairieJson.decodeFromString<PlaybackStopV2>(deletes.last())) + assertEquals(store.entries.single { it.attemptId == "attempt-1" }.stop, + PrairieJson.decodeFromString<PlaybackStopV2>(deletes.last())) if (deletes.size == 1) throw IllegalStateException("lost stop reply") reply("""{"outcome":"stopped","stop_id":"$stopId"}""") } @@ -104,30 +107,37 @@ class SequencedPlaybackTest { try { val identity = Identity() val runtime = SequencedPlayback(PlaybackV2Api(c, ApiV2Gate.Unrestricted), identity, identity, store) { stopId } - assertIs<ApiResult.Success<Unit>>(runtime.recover()) + // One attempt per start: the lost reply leaves the stop for the next start. + assertIs<ApiResult.Success<PlaybackDecisionResponseV3>>(runtime.start(request().copy(playbackAttemptId = "attempt-2"))) + assertEquals(1, deletes.size); assertFalse(store.entries.single { it.attemptId == "attempt-1" }.terminal) + assertIs<ApiResult.Success<PlaybackDecisionResponseV3>>(runtime.start(request().copy(playbackAttemptId = "attempt-3"))) assertEquals(2, deletes.size); assertEquals(deletes.first(), deletes.last()) - assertTrue(store.entries.single().terminal) + assertTrue(store.entries.single { it.attemptId == "attempt-1" }.terminal) } finally { c.close() } } - @Test fun unavailableStopSurvivesRestartAndIdentityReplacementCannotReplay() = runTest { - val store = Store().apply { entries = listOf(entry()) } + @Test fun unavailableStopNeverBlocksStartAndIdentityReplacementCannotReplay() = runTest { + val store = Store().apply { entries = listOf(entry(), entry().copy(attemptId = "attempt-0", sessionId = "session-0")) } val identity = Identity() - var deletes = 0 + var deletes = 0; var starts = 0 val c = client { req -> when (req.url.encodedPath) { "/api/v2/playback/capabilities" -> reply(caps()) "/api/v2/account/me" -> reply(account) + "/api/v2/playback/start" -> { starts++; reply(decision("session-${starts + 1}"), HttpStatusCode.Created) } else -> { deletes++; reply("""{"code":"authority_unavailable","detail":"pending"}""", HttpStatusCode.ServiceUnavailable) } } } + fun earlier() = store.entries.single { it.attemptId == "attempt-1" } try { val runtime = SequencedPlayback(PlaybackV2Api(c, ApiV2Gate.Unrestricted), identity, identity, store) { stopId } - assertEquals("stop_pending", assertIs<ApiResult.Error>(runtime.recover()).error) - assertEquals(3, deletes); assertFalse(store.entries.single().terminal) - val body = store.entries.single().stop + // The first failed stop ends the pass: one request, and the second stop waits. + assertIs<ApiResult.Success<PlaybackDecisionResponseV3>>(runtime.start(request().copy(playbackAttemptId = "attempt-2"))) + assertEquals(1, deletes); assertFalse(earlier().terminal) + assertNull(store.entries.single { it.attemptId == "attempt-0" }.stop) + val body = earlier().stop identity.login = "login-2" val restarted = SequencedPlayback(PlaybackV2Api(c, ApiV2Gate.Unrestricted), identity, identity, store) { error("Must retain StopID") } - restarted.recover() - assertEquals(3, deletes); assertEquals(body, store.entries.single().stop) + assertIs<ApiResult.Success<PlaybackDecisionResponseV3>>(restarted.start(request().copy(playbackAttemptId = "attempt-3"))) + assertEquals(1, deletes); assertEquals(body, earlier().stop); assertEquals(2, starts) } finally { c.close() } } @@ -145,25 +155,26 @@ class SequencedPlaybackTest { } finally { c.close() } } - @Test fun uncertainStartIsPersistedBeforeDispatchAndBlocksAnotherAttempt() = runTest { + @Test fun uncertainStartIsPersistedBeforeDispatchAndDroppedByTheNextAttempt() = runTest { val identity = Identity(); val store = Store(); var starts = 0 val c = client { req -> when (req.url.encodedPath) { "/api/v2/playback/capabilities" -> reply(caps()) "/api/v2/account/me" -> reply(account) else -> { starts++ - assertEquals(store.entries.single().start.toString(), req.body.toByteArray().decodeToString()) - assertTrue(store.entries.single().start["file_id"]!!.jsonPrimitive.isString) + assertEquals(store.entries.last().start.toString(), req.body.toByteArray().decodeToString()) + assertTrue(store.entries.last().start["file_id"]!!.jsonPrimitive.isString) assertTrue(req.attributes[SingleAttemptAttributeKey]) - throw IllegalStateException("lost reply") + if (starts == 1) throw IllegalStateException("lost reply") + reply(decision("session-2"), HttpStatusCode.Created) } } } try { val runtime = SequencedPlayback(PlaybackV2Api(c, ApiV2Gate.Unrestricted), identity, identity, store) { stopId } assertIs<ApiResult.NetworkError>(runtime.start(request())) - // A later start first replays the exact retained attempt; a lost reply keeps the fence. - assertIs<ApiResult.NetworkError>(runtime.start(request().copy(playbackAttemptId = "attempt-2"))) - assertEquals(2, starts); assertEquals(listOf("attempt-1"), runtime.pending.value) + // The lost start is never replayed: the server expires whatever it may have opened. + assertIs<ApiResult.Success<PlaybackDecisionResponseV3>>(runtime.start(request().copy(playbackAttemptId = "attempt-2"))) + assertEquals(2, starts); assertTrue(store.entries.single { it.attemptId == "attempt-1" }.terminal) } finally { c.close() } } @@ -241,14 +252,14 @@ class SequencedPlaybackTest { val runtime = SequencedPlayback(PlaybackV2Api(c, ApiV2Gate.Unrestricted), identity, identity, store) { stopId } val first = assertIs<ApiResult.Success<PlaybackDecisionResponseV3>>(runtime.start(request())) assertEquals("subtitle_unavailable_in_version", first.data.terminal?.reason) - assertTrue(store.entries.single().terminal); assertTrue(runtime.pending.value.isEmpty()) + assertTrue(store.entries.single().terminal) // The refused attempt never fences the next start; a fresh attempt goes straight to the server. assertIs<ApiResult.Success<PlaybackDecisionResponseV3>>(runtime.start(request().copy(playbackAttemptId = "attempt-2"))) assertEquals(2, starts) } finally { c.close() } } - @Test fun validationRejectionRetainsExactAttemptWithoutLegacyFallback() = runTest { + @Test fun validationRejectionNeitherFallsBackNorBlocksTheNextAttempt() = runTest { val identity = Identity(); val store = Store(); var starts = 0 val c = client { req -> when (req.url.encodedPath) { "/api/v2/playback/capabilities" -> reply(caps()) @@ -260,40 +271,39 @@ class SequencedPlaybackTest { val runtime = SequencedPlayback(PlaybackV2Api(c, ApiV2Gate.Unrestricted), identity, identity, store) { stopId } val repository = PlaybackRepository(runtime) assertEquals(422, assertIs<ApiResult.Error>(repository.startPlaybackV3(request())).code) - // The retained attempt is replayed byte-for-byte before a new one may start; the same refusal fences again. - assertEquals(422, assertIs<ApiResult.Error>(repository.startPlaybackV3(request())).code) - assertEquals(2, starts); assertEquals(request().v2Body(installation), store.entries.single().start) + assertEquals(request().v2Body(installation), store.entries.single().start) + // The refused attempt is dropped, not replayed; the next one goes straight to the server. + assertEquals(422, assertIs<ApiResult.Error>(repository.startPlaybackV3(request().copy(playbackAttemptId = "attempt-2"))).code) + assertEquals(2, starts) } finally { c.close() } } - @Test fun recoveryReplayStaysPendingWhenFirstStopJournalWriteFails() = runTest { + @Test fun stopThatCannotBeJournaledIsRetriedByALaterStart() = runTest { val identity = Identity() - val store = Store().apply { entries = listOf(entry().copy(sessionId = null)); failStop = true } + val store = Store().apply { entries = listOf(entry()); failStop = true } var starts = 0; var deletes = 0 - val decision = """{"protocol_version":3,"server_features":["playback_plan_v3","neutral_playback_v3_contract_v1","sequenced_progress_v1"],"outcome":"playable","session_id":"session-1","playback_plan":{"plan_id":"plan","session_id":"session-1","delivery":"original_http","stream":{"url":"/api/v2/stream/session-1","protocol":"http_progressive"},"decision_reason":"direct"}}""" val c = client { req -> when (req.url.encodedPath) { "/api/v2/playback/capabilities" -> reply(caps()) "/api/v2/account/me" -> reply(account) - "/api/v2/playback/start" -> { starts++; reply(decision, HttpStatusCode.Created) } + "/api/v2/playback/start" -> { starts++; reply(decision("session-${starts + 1}"), HttpStatusCode.Created) } "/api/v2/playback/session-1" -> { deletes++ - assertEquals(store.entries.single().stop, PrairieJson.decodeFromString<PlaybackStopV2>(req.body.toByteArray().decodeToString())) + assertEquals(store.entries.single { it.attemptId == "attempt-1" }.stop, + PrairieJson.decodeFromString<PlaybackStopV2>(req.body.toByteArray().decodeToString())) reply("""{"outcome":"stopped","stop_id":"$stopId"}""") } else -> error("Unexpected request") } } try { val runtime = SequencedPlayback(PlaybackV2Api(c, ApiV2Gate.Unrestricted), identity, identity, store) { stopId } - val repository = PlaybackRepository(runtime) - assertEquals("playback_storage", assertIs<ApiResult.Error>(repository.recoverPlayback()).error) + // The stop cannot be journaled, so it is never sent; the new start still goes ahead. + assertIs<ApiResult.Success<PlaybackDecisionResponseV3>>(runtime.start(request().copy(playbackAttemptId = "attempt-2"))) assertEquals(1, starts); assertEquals(0, deletes) - assertEquals(listOf("attempt-1"), runtime.pending.value) - assertEquals("session-1", store.entries.single().sessionId) - assertEquals("playback_storage", assertIs<ApiResult.Error>(runtime.start(request().copy(playbackAttemptId = "another"))).error) + assertFalse(store.entries.single { it.attemptId == "attempt-1" }.terminal) store.failStop = false - assertIs<ApiResult.Success<Unit>>(repository.recoverPlayback()) - assertEquals(1, starts); assertEquals(1, deletes) - assertTrue(store.entries.single().terminal); assertTrue(runtime.pending.value.isEmpty()) + assertIs<ApiResult.Success<PlaybackDecisionResponseV3>>(runtime.start(request().copy(playbackAttemptId = "attempt-3"))) + assertEquals(2, starts); assertEquals(1, deletes) + assertTrue(store.entries.single { it.attemptId == "attempt-1" }.terminal) } finally { c.close() } } @@ -304,6 +314,7 @@ class SequencedPlaybackTest { attemptCount = 1, positionSeconds = 42.0, selectedTracks = SelectedPlaybackTracksV3(), capabilities = ClientCodecCapabilities(), clientPlaybackContext = request().clientPlaybackContext, ) + private fun decision(session: String) = adoptedDecision.replace("session-1", session) private val adoptedDecision = """{"protocol_version":3,"server_features":["playback_plan_v3","neutral_playback_v3_contract_v1","sequenced_progress_v1"],"outcome":"playable","session_id":"session-1","playback_plan":{"plan_id":"plan-0001","plan_attempt_key":"plan-key-0001","session_id":"session-1","delivery":"original_http","stream":{"url":"/api/v2/stream/session-1","protocol":"http_progressive"},"decision_reason":"direct","requested_media_file_id":"42","effective_media_file_id":"42","source":{"media_file_id":"42"}}}""" @Test fun uncertainReplanIsDurableAndCannotReplayOrRebase() = runTest { @@ -473,4 +484,129 @@ class SequencedPlaybackTest { assertFailsWith<IllegalArgumentException> { decodePlaybackDecisionV2(body) } } + /** A title a phone launches on the TV plays under process-only remote-playback credentials. */ + private fun remotePlaybackIdentity() = Identity().apply { + temporary = true + scope = scope.copy(credentialGenerationId = "remote-1") + } + + @Test fun remotePlaybackIdentityPlaysWithoutTheDurableJournal() = runTest { + val identity = remotePlaybackIdentity() + val store = Store().apply { fail = true } // Any journal write would fail the call. + val c = client { req -> when (req.url.encodedPath) { + "/api/v2/playback/capabilities" -> reply(caps()) + "/api/v2/account/me" -> reply(account) + "/api/v2/playback/start" -> reply(adoptedDecision, HttpStatusCode.Created) + "/api/v2/playback/session-1/progress" -> { + val sample = PrairieJson.decodeFromString<PlaybackProgressV2>(req.body.toByteArray().decodeToString()) + reply("""{"outcome":"applied","accepted":{"sequence":${sample.sequence},"position":${sample.position},"is_paused":${sample.isPaused}}}""") + } + "/api/v2/playback/session-1" -> reply("""{"outcome":"stopped","stop_id":"$stopId"}""") + else -> error("Unexpected request ${req.url}") + } } + try { + val runtime = SequencedPlayback(PlaybackV2Api(c, ApiV2Gate.Unrestricted), identity, identity, store) { stopId } + assertIs<ApiResult.Success<PlaybackDecisionResponseV3>>(runtime.start(request())) + assertIs<ApiResult.Success<Unit>>(runtime.progress("session-1", 12.0, false)) + assertIs<ApiResult.Success<Unit>>(runtime.stop("session-1")) + assertTrue(store.entries.isEmpty()) + } finally { c.close() } + } + + @Test fun uncertainRemotePlaybackStartNeverBlocksTheNextOne() = runTest { + val identity = remotePlaybackIdentity() + val starts = mutableListOf<String>() + val c = client { req -> when (req.url.encodedPath) { + "/api/v2/playback/capabilities" -> reply(caps()) + "/api/v2/account/me" -> reply(account) + "/api/v2/playback/start" -> { + val attempt = PrairieJson.parseToJsonElement(req.body.toByteArray().decodeToString()) + .jsonObject["playback_attempt_id"]!!.jsonPrimitive.content + starts += attempt + // The first start's reply is lost; the next start succeeds. + if (starts.size == 1) throw IllegalStateException("lost reply") + reply(decision("session-$attempt"), HttpStatusCode.Created) + } + else -> error("Unexpected request ${req.url}") + } } + try { + val runtime = SequencedPlayback(PlaybackV2Api(c, ApiV2Gate.Unrestricted), identity, identity, Store()) { stopId } + assertIs<ApiResult.NetworkError>(runtime.start(request())) + // The phone sends another title: the lost start is dropped, not replayed. + assertIs<ApiResult.Success<PlaybackDecisionResponseV3>>(runtime.start(request().copy(playbackAttemptId = "attempt-2"))) + assertEquals(listOf("attempt-1", "attempt-2"), starts) + } finally { c.close() } + } + + @Test fun endedRemotePlaybackAttemptsAreDroppedOnTheNextSave() = runTest { + val identity = remotePlaybackIdentity() + var starts = 0 + val c = client { req -> when (req.url.encodedPath) { + "/api/v2/playback/capabilities" -> reply(caps()) + "/api/v2/account/me" -> reply(account) + "/api/v2/playback/start" -> { starts++; reply(adoptedDecision.replace("session-1", "session-$starts"), HttpStatusCode.Created) } + else -> error("Unexpected request ${req.url}") + } } + try { + val runtime = SequencedPlayback(PlaybackV2Api(c, ApiV2Gate.Unrestricted), identity, identity, Store()) { stopId } + assertIs<ApiResult.Success<PlaybackDecisionResponseV3>>(runtime.start(request())) + assertTrue(runtime.owns("session-1")) + // The TV restores its own login before the phone's title stopped. + identity.temporary = false + identity.scope = identity.scope.copy(credentialGenerationId = null, identityGeneration = 2) + assertIs<ApiResult.Success<PlaybackDecisionResponseV3>>(runtime.start(request().copy(playbackAttemptId = "attempt-2"))) + assertFalse(runtime.owns("session-1")) + assertTrue(runtime.owns("session-2")) + } finally { c.close() } + } + + @Test fun remotePlaybackIdentityEndingDuringAProgressRetryIsAnIdentityChange() = runTest { + val identity = remotePlaybackIdentity() + var progressCalls = 0 + val c = client { req -> when (req.url.encodedPath) { + "/api/v2/playback/capabilities" -> reply(caps()) + "/api/v2/account/me" -> reply(account) + "/api/v2/playback/start" -> reply(adoptedDecision, HttpStatusCode.Created) + "/api/v2/playback/session-1/progress" -> { + progressCalls++ + // The first sample's reply is lost, so the next call retries it first. + if (progressCalls == 1) throw IllegalStateException("lost reply") + // The TV restores its own login while the retry is in flight. + identity.temporary = false + identity.scope = identity.scope.copy(credentialGenerationId = null, identityGeneration = 2) + reply("""{"outcome":"applied","accepted":{"sequence":1,"position":12.0,"is_paused":false}}""") + } + else -> error("Unexpected request ${req.url}") + } } + try { + val runtime = SequencedPlayback(PlaybackV2Api(c, ApiV2Gate.Unrestricted), identity, identity, Store()) { stopId } + assertIs<ApiResult.Success<PlaybackDecisionResponseV3>>(runtime.start(request())) + assertIs<ApiResult.NetworkError>(runtime.progress("session-1", 12.0, false)) + assertEquals("identity_changed", assertIs<ApiResult.Error>(runtime.progress("session-1", 13.0, false)).error) + assertEquals(2, progressCalls) + } finally { c.close() } + } + + @Test fun endedRemotePlaybackIdentityCannotActForItsAttempt() = runTest { + val identity = remotePlaybackIdentity() + val store = Store() + val c = client { req -> when (req.url.encodedPath) { + "/api/v2/playback/capabilities" -> reply(caps()) + "/api/v2/account/me" -> reply(account) + "/api/v2/playback/start" -> reply(adoptedDecision, HttpStatusCode.Created) + else -> error("Must not act for an ended identity: ${req.url}") + } } + try { + val runtime = SequencedPlayback(PlaybackV2Api(c, ApiV2Gate.Unrestricted), identity, identity, store) { stopId } + assertIs<ApiResult.Success<PlaybackDecisionResponseV3>>(runtime.start(request())) + // The TV restores its own saved login. + identity.temporary = false + identity.scope = identity.scope.copy(credentialGenerationId = null, identityGeneration = 2) + assertEquals("identity_changed", assertIs<ApiResult.Error>(runtime.progress("session-1", 12.0, false)).error) + assertEquals("identity_changed", assertIs<ApiResult.Error>(runtime.stop("session-1")).error) + // Nothing to recover later: the credentials are gone for good. + assertTrue(store.entries.isEmpty()) + } finally { c.close() } + } + } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepositoryTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepositoryTest.kt index e51beac9e5..4f2b9213cb 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepositoryTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepositoryTest.kt @@ -4,35 +4,51 @@ import org.prairieserver.prairie.model.watchtogether.AddSuggestionRequest import org.prairieserver.prairie.model.watchtogether.CreateRoomRequest import org.prairieserver.prairie.model.watchtogether.JoinRoomRequest import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.model.watchtogether.MemberStateRequest +import org.prairieserver.prairie.model.watchtogether.MemberStateResponse +import org.prairieserver.prairie.model.watchtogether.PickerResponse import org.prairieserver.prairie.model.watchtogether.PromoteSuggestionRequest +import org.prairieserver.prairie.model.watchtogether.RoomPhase import org.prairieserver.prairie.model.watchtogether.RoomResponse import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.SelectionModeRequest import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest +import org.prairieserver.prairie.model.watchtogether.SourceFallbackRequest import org.prairieserver.prairie.model.watchtogether.Suggestion +import org.prairieserver.prairie.model.watchtogether.SuggestionPageInfo +import org.prairieserver.prairie.model.watchtogether.SuggestionReceipt import org.prairieserver.prairie.model.watchtogether.SuggestionsResponse +import org.prairieserver.prairie.model.watchtogether.TransportAction +import org.prairieserver.prairie.model.watchtogether.TransportCommand import org.prairieserver.prairie.model.watchtogether.UpdatePolicyRequest +import org.prairieserver.prairie.model.watchtogether.WatchTogetherCapabilitiesV2 import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.AuthScopeSnapshot import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier import org.prairieserver.prairie.network.IdentityTransitionKind import org.prairieserver.prairie.network.RoomRealtimeEvent +import org.prairieserver.prairie.network.RoomTicketExpiringException +import org.prairieserver.prairie.network.RoomTicketRefusedException import org.prairieserver.prairie.network.WatchTogetherRealtimeClient import org.prairieserver.prairie.network.api.WatchTogetherApi import org.prairieserver.prairie.watchtogether.RoomSession import org.prairieserver.prairie.watchtogether.RoomTransportIntent +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.async +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch -import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest +import kotlin.random.Random import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -47,12 +63,28 @@ class WatchTogetherRepositoryTest { private fun snapshot( roomId: String = "room-1", revision: Long = 1L, + generation: Long = 1L, memberCount: Int = 1, - ) = RoomSnapshot(roomId = roomId, selectionRevision = revision, memberCount = memberCount, code = "ABCD1234") + phase: RoomPhase = RoomPhase.Lobby, + attachedSessionId: String? = null, + ) = RoomSnapshot( + roomId = roomId, + selectionRevision = revision, + generation = generation, + memberCount = memberCount, + code = "ABCD1234", + phase = phase, + attachedSessionId = attachedSessionId, + ) - private fun suggestion(id: String, voteCount: Int = 0, votedByMe: Boolean = false) = Suggestion( + private fun suggestion( + id: String, + voteCount: Int = 0, + votedByMe: Boolean = false, + createdAt: String = "2026-06-12T08:00:00Z", + ) = Suggestion( id = id, roomId = "room-1", contentId = "c-$id", contentType = "movie", - title = "T-$id", voteCount = voteCount, votedByMe = votedByMe, createdAt = "2026-06-12T08:00:00Z", + title = "T-$id", voteCount = voteCount, votedByMe = votedByMe, createdAt = createdAt, ) private class FakeApi( @@ -60,109 +92,118 @@ class WatchTogetherRepositoryTest { RoomResponse(RoomSnapshot(roomId = "room-1", code = "ABCD1234"), "jwt-room"), ), ) : WatchTogetherApi { - var createCalls = 0 - var addSuggestionCalls = 0 - var voteCalls = 0 - var promoteCalls = 0 - var closeCalls = 0 + val calls = mutableListOf<String>() var lastRoomToken: String? = null var lastRoomId: String? = null var lastSelection: SetSelectionRequest? = null var lastAuthScope: AuthScopeSnapshot? = null var createResult: CompletableDeferred<ApiResult<RoomResponse>>? = null var selectionResult: CompletableDeferred<ApiResult<RoomResponse>>? = null - var listSuggestionsResponse: ApiResult<SuggestionsResponse> = - ApiResult.Success(SuggestionsResponse()) + var roomReadResult: CompletableDeferred<ApiResult<RoomResponse>>? = null + var roomReadResponse: ApiResult<RoomResponse>? = null + var mutationResponse: ApiResult<RoomResponse>? = null + var voteResponse: ApiResult<Unit> = ApiResult.Success(Unit) + var listSuggestionPages: MutableList<ApiResult<SuggestionsResponse>> = mutableListOf() + var listSuggestionsResponse: ApiResult<SuggestionsResponse> = ApiResult.Success(SuggestionsResponse()) var listSuggestionsResult: CompletableDeferred<ApiResult<SuggestionsResponse>>? = null - var listSuggestionsCalls = 0 - override suspend fun createRoom(request: CreateRoomRequest, scope: AuthScopeSnapshot): ApiResult<RoomResponse> { - createCalls++ + val listCursors = mutableListOf<String?>() + + fun count(name: String) = calls.count { it == name } + + private fun record(name: String, roomId: String?, token: String?, scope: AuthScopeSnapshot) { + calls += name + roomId?.let { lastRoomId = it } + lastRoomToken = token lastAuthScope = scope + } + + private fun mutation(): ApiResult<RoomResponse> = mutationResponse ?: createResponse + + override suspend fun capabilities(scope: AuthScopeSnapshot) = + ApiResult.Success(WatchTogetherCapabilitiesV2()) + + override suspend fun createRoom(request: CreateRoomRequest, scope: AuthScopeSnapshot): ApiResult<RoomResponse> { + record("create", null, null, scope) return createResult?.await() ?: createResponse } - override suspend fun joinRoom(request: JoinRoomRequest, scope: AuthScopeSnapshot) = - createResponse.also { lastAuthScope = scope } - override suspend fun getRoom(roomId: String, roomToken: String, scope: AuthScopeSnapshot) = - createResponse.also { lastRoomToken = roomToken; lastAuthScope = scope } + override suspend fun joinRoom(request: JoinRoomRequest, scope: AuthScopeSnapshot): ApiResult<RoomResponse> { + record("join", null, null, scope) + return createResponse + } + override suspend fun getRoom(roomId: String, roomToken: String, scope: AuthScopeSnapshot): ApiResult<RoomResponse> { + record("get", roomId, roomToken, scope) + return roomReadResult?.await() ?: roomReadResponse ?: createResponse + } + override suspend fun updatePolicy(roomId: String, request: UpdatePolicyRequest, scope: AuthScopeSnapshot) = + mutation().also { record("policy", roomId, null, scope) } + override suspend fun stageSelection(roomId: String, request: SetSelectionRequest, scope: AuthScopeSnapshot) = + mutation().also { record("stage", roomId, null, scope) } + override suspend fun startPlayback(roomId: String, scope: AuthScopeSnapshot) = + mutation().also { record("start", roomId, null, scope) } + override suspend fun stopPlayback(roomId: String, scope: AuthScopeSnapshot) = + mutation().also { record("stop", roomId, null, scope) } + override suspend fun setSelectionMode(roomId: String, request: SelectionModeRequest, scope: AuthScopeSnapshot) = + mutation().also { record("mode", roomId, null, scope) } override suspend fun setSelection( roomId: String, - roomToken: String, request: SetSelectionRequest, scope: AuthScopeSnapshot, ): ApiResult<RoomResponse> { - lastRoomId = roomId - lastRoomToken = roomToken + record("select", roomId, null, scope) lastSelection = request - lastAuthScope = scope - return selectionResult?.await() ?: createResponse + return selectionResult?.await() ?: mutation() + } + override suspend fun closeRoom(roomId: String, scope: AuthScopeSnapshot): ApiResult<Unit> { + record("close", roomId, null, scope) + return ApiResult.Success(Unit) } - override suspend fun updatePolicy( + override suspend fun sourceFallback( roomId: String, roomToken: String, - request: UpdatePolicyRequest, + request: SourceFallbackRequest, scope: AuthScopeSnapshot, - ) = createResponse.also { lastRoomToken = roomToken; lastAuthScope = scope } - override suspend fun closeRoom( + ) = mutation().also { record("fallback", roomId, roomToken, scope) } + override suspend fun listSuggestions( roomId: String, roomToken: String, scope: AuthScopeSnapshot, - ): ApiResult<Unit> { - closeCalls++ - lastRoomToken = roomToken - lastAuthScope = scope - return ApiResult.Success(Unit) + cursor: String?, + limit: Int, + ): ApiResult<SuggestionsResponse> { + record("list", roomId, roomToken, scope) + listCursors += cursor + listSuggestionsResult?.let { return it.await() } + return if (listSuggestionPages.isNotEmpty()) listSuggestionPages.removeAt(0) else listSuggestionsResponse } - override suspend fun listSuggestions(roomId: String, roomToken: String, scope: AuthScopeSnapshot) = - (listSuggestionsResult?.await() ?: listSuggestionsResponse).also { - listSuggestionsCalls++ - lastRoomToken = roomToken - lastAuthScope = scope - } override suspend fun addSuggestion( roomId: String, roomToken: String, request: AddSuggestionRequest, scope: AuthScopeSnapshot, - ): ApiResult<SuggestionsResponse> { - addSuggestionCalls++ - lastRoomToken = roomToken - lastAuthScope = scope - return ApiResult.Success(SuggestionsResponse()) - } - override suspend fun deleteSuggestion( - roomId: String, - roomToken: String, - suggestionId: String, - scope: AuthScopeSnapshot, - ) = ApiResult.Success(SuggestionsResponse()).also { lastRoomToken = roomToken; lastAuthScope = scope } - override suspend fun vote( - roomId: String, - roomToken: String, - suggestionId: String, - scope: AuthScopeSnapshot, - ): ApiResult<SuggestionsResponse> { - voteCalls++ - lastRoomToken = roomToken - lastAuthScope = scope - return ApiResult.Success(SuggestionsResponse()) + ): ApiResult<SuggestionReceipt> { + record("suggest", roomId, roomToken, scope) + return ApiResult.Success(SuggestionReceipt(request.suggestionId)) } - override suspend fun unvote( + override suspend fun deleteSuggestion(roomId: String, roomToken: String, suggestionId: String, scope: AuthScopeSnapshot) = + ApiResult.Success(Unit).also { record("delete", roomId, roomToken, scope) } + override suspend fun vote(roomId: String, roomToken: String, suggestionId: String, scope: AuthScopeSnapshot) = + voteResponse.also { record("vote", roomId, roomToken, scope) } + override suspend fun unvote(roomId: String, roomToken: String, suggestionId: String, scope: AuthScopeSnapshot) = + voteResponse.also { record("unvote", roomId, roomToken, scope) } + override suspend fun promoteSuggestion( roomId: String, roomToken: String, - suggestionId: String, + request: PromoteSuggestionRequest, scope: AuthScopeSnapshot, - ) = ApiResult.Success(SuggestionsResponse()).also { lastRoomToken = roomToken; lastAuthScope = scope } - override suspend fun promoteSuggestion( + ) = mutation().also { record("promote", roomId, roomToken, scope) } + override suspend fun memberState( roomId: String, roomToken: String, - request: PromoteSuggestionRequest, + request: MemberStateRequest, scope: AuthScopeSnapshot, - ): ApiResult<RoomResponse> { - promoteCalls++ - lastRoomToken = roomToken - lastAuthScope = scope - return createResponse - } + ) = ApiResult.Success(MemberStateResponse()).also { record("memberState", roomId, roomToken, scope) } + override suspend fun picker(roomId: String, roomToken: String, scope: AuthScopeSnapshot) = + ApiResult.Success(PickerResponse()).also { record("picker", roomId, roomToken, scope) } } private class FakeRealtime( @@ -174,17 +215,18 @@ class WatchTogetherRepositoryTest { var terminateImmediately = false var attachCount = 0 val transportActions = mutableListOf<String>() - val readySessions = mutableListOf<String>() + val readyCommands = mutableListOf<String?>() val bufferingSessions = mutableListOf<String>() + val lobbyReady = mutableListOf<Boolean>() + var pings = 0 var transportResultGate: CompletableDeferred<Boolean>? = null var connectBehavior: ((Int) -> Flow<RoomRealtimeEvent>)? = null - /** - * When non-null, each connect attempt emits this one event and then throws. - * Models a flapping server: healthy traffic is observed but the connection - * always drops — the classic scenario that could bypass the failure cap if - * failures were reset per-event rather than per-clean-completion. - */ + /** Each attempt emits this one event and then throws: a flapping server. */ var flappingEvent: RoomRealtimeEvent? = null + val connectedRoomIds = mutableListOf<String>() + val connectedRoomTokens = mutableListOf<String>() + val connectedAuthScopes = mutableListOf<AuthScopeSnapshot?>() + override fun connect( roomId: String, roomToken: String, @@ -202,14 +244,12 @@ class WatchTogetherRepositoryTest { } val flapEvent = flappingEvent if (flapEvent != null) return flow { + emit(RoomRealtimeEvent.Opened) emit(flapEvent) throw IllegalStateException("connection dropped after event") } return events.asSharedFlow() } - val connectedRoomIds = mutableListOf<String>() - val connectedRoomTokens = mutableListOf<String>() - val connectedAuthScopes = mutableListOf<AuthScopeSnapshot?>() override suspend fun attachSession(sessionId: String): Boolean { attachCount++ return true @@ -229,16 +269,29 @@ class WatchTogetherRepositoryTest { transportActions += action return true } - override suspend fun stateReport(sessionId: String, positionSeconds: Double, isPaused: Boolean) = true - override suspend fun ready(sessionId: String, positionSeconds: Double, isPaused: Boolean): Boolean { - readySessions += sessionId + override suspend fun stateReport( + sessionId: String, + positionSeconds: Double, + isPaused: Boolean, + commandId: String?, + isReady: Boolean, + ) = true + override suspend fun ready(sessionId: String, positionSeconds: Double, isPaused: Boolean, commandId: String?): Boolean { + readyCommands += commandId return true } override suspend fun buffering(sessionId: String, positionSeconds: Double, isPaused: Boolean): Boolean { bufferingSessions += sessionId return true } - override suspend fun ping(clientSentAt: String) = true + override suspend fun lobbyReady(ready: Boolean): Boolean { + lobbyReady += ready + return true + } + override suspend fun ping(clientSentAt: String): Boolean { + pings++ + return true + } } private val scopeA = AuthScopeSnapshot( @@ -249,256 +302,83 @@ class WatchTogetherRepositoryTest { identityGeneration = 1L, ) - private fun repo( + /** Jitter-free, background-work-free timing so tests control every delay. */ + private val testTiming = WatchPartyTiming(backgroundWork = false) + private object NoJitter : Random() { + override fun nextBits(bitCount: Int): Int = 0 + } + + private fun TestScope.repo( api: FakeApi = FakeApi(), realtime: FakeRealtime = FakeRealtime(), authScopeProvider: suspend () -> AuthScopeSnapshot? = { scopeA }, + now: () -> Long = { testScheduler.currentTime }, + timing: WatchPartyTiming = testTiming, + realtimeFactory: (() -> WatchTogetherRealtimeClient?)? = null, ) = WatchTogetherRepository( api = api, - realtimeFactory = { realtime }, + realtimeFactory = realtimeFactory ?: { realtime }, + monotonicNowMs = now, authScopeProvider = authScopeProvider, + wallClockMs = { 1_000_000L + testScheduler.currentTime }, + timing = timing, + random = NoJitter, ) - // ---- create stores room token ------------------------------------------- + private fun create() = CreateRoomRequest(roomId = "room-1") + + private fun room(snapshot: RoomSnapshot, token: String = "jwt-room") = ApiResult.Success(RoomResponse(snapshot, token)) + + private val firstBackoff = testTiming.backoffMs.first() + + // ---- create / join / proof ---------------------------------------------- @Test - fun `create stores room token used by subsequent room-scoped calls`() = runTest { + fun `create stores the proof used by member calls and keeps host calls proof-free`() = runTest { val api = FakeApi() val r = repo(api = api) - r.createRoom(CreateRoomRequest(selectionMode = "vote")) - r.setSelection(SetSelectionRequest(contentId = "tt-9")) + r.createRoom(create()) + r.promoteSuggestion("s-1") assertEquals("jwt-room", api.lastRoomToken) + r.setSelection(SetSelectionRequest(contentId = "tt-9")) + assertNull(api.lastRoomToken) assertEquals("tt-9", api.lastSelection?.contentId) } @Test - fun `empty vote room owner keeps existing suggestion vote override and close authority`() = runTest { - val api = FakeApi( - createResponse = ApiResult.Success( - RoomResponse( - room = RoomSnapshot( - roomId = "room-1", - selectionMode = RoomSelectionMode.Vote, - selfRole = MemberRole.Host, - selfCanManageRoom = true, - ), - roomAccessToken = "room-token", - ), - ), - ) - val repository = WatchTogetherRepository( - api = api, - authScopeProvider = { scopeA }, - ) - - repository.createRoom(CreateRoomRequest(selectionMode = RoomSelectionMode.Vote.wire)) - repository.addSuggestion( - AddSuggestionRequest( - contentId = "movie-1", - contentType = "movie", - title = "Movie One", - ), - ) - repository.vote("suggestion-1") - repository.promoteSuggestion(PromoteSuggestionRequest("suggestion-1")) - repository.closeRoom() - - assertEquals(1, api.createCalls) - assertEquals(1, api.addSuggestionCalls) - assertEquals(1, api.voteCalls) - assertEquals(1, api.promoteCalls) - assertEquals(1, api.closeCalls) - assertEquals("room-token", api.lastRoomToken) - } - - @Test - fun `join hydrates suggestions that predate the websocket connection`() = runTest { - val api = FakeApi().apply { - listSuggestionsResponse = ApiResult.Success( - SuggestionsResponse(suggestions = listOf(suggestion("existing"))), - ) - } - val repository = repo(api = api) - - val result = repository.joinRoom(JoinRoomRequest(code = "ABCD1234")) - - assertIs<ApiResult.Success<RoomResponse>>(result) - assertEquals(1, api.listSuggestionsCalls) - assertEquals(listOf("existing"), repository.suggestions.value.map { it.id }) - } - - @Test - fun `join returns obsolete when its hydration lease is replaced by another room`() = runTest { - val hydration = CompletableDeferred<ApiResult<SuggestionsResponse>>() - val api = FakeApi().apply { listSuggestionsResult = hydration } - val repository = repo(api = api) - val joiningA = async { repository.joinRoom(JoinRoomRequest(code = "ABCD1234")) } - runCurrent() - - api.createResponse = ApiResult.Success( - RoomResponse(RoomSnapshot(roomId = "room-2", code = "EFGH5678"), "jwt-room-2"), - ) - assertIs<ApiResult.Success<RoomResponse>>(repository.createRoom(CreateRoomRequest())) - hydration.complete(ApiResult.Success(SuggestionsResponse(suggestions = listOf(suggestion("stale"))))) - - val staleResult = assertIs<ApiResult.Error>(joiningA.await()) - assertEquals("obsolete_room_request", staleResult.error) - assertEquals("room-2", repository.roomSnapshot.value?.roomId) - assertTrue(repository.suggestions.value.isEmpty()) - } - - @Test - fun `join returns obsolete when a newer room request starts during hydration`() = runTest { - val hydration = CompletableDeferred<ApiResult<SuggestionsResponse>>() - val newerCreate = CompletableDeferred<ApiResult<RoomResponse>>() - val api = FakeApi().apply { - listSuggestionsResult = hydration - createResult = newerCreate - } - val repository = repo(api = api) - val joiningA = async { repository.joinRoom(JoinRoomRequest(code = "ABCD1234")) } - runCurrent() - val creatingB = async { repository.createRoom(CreateRoomRequest()) } - runCurrent() - - hydration.complete(ApiResult.Success(SuggestionsResponse(suggestions = listOf(suggestion("stale"))))) - - val staleResult = assertIs<ApiResult.Error>(joiningA.await()) - assertEquals("obsolete_room_request", staleResult.error) - assertTrue(repository.suggestions.value.isEmpty()) - newerCreate.complete( - ApiResult.Success( - RoomResponse(RoomSnapshot(roomId = "room-2", code = "EFGH5678"), "jwt-room-2"), - ), - ) - assertIs<ApiResult.Success<RoomResponse>>(creatingB.await()) - } - - @Test - fun `transport lease cannot send into a replacement room`() = runTest { + fun `every successful room response renews the proof without changing membership`() = runTest { val api = FakeApi() - val realtime = FakeRealtime() - val repository = repo(api = api, realtime = realtime) - repository.createRoom(CreateRoomRequest()) - val connection = launch { repository.connect("room-1") } - runCurrent() - realtime.events.emit(RoomRealtimeEvent.Opened) - runCurrent() - val authorizationA = requireNotNull(repository.currentTransportAuthorization()) + val r = repo(api = api) + r.createRoom(create()) + val membership = r.membershipGeneration() - api.createResponse = ApiResult.Success( - RoomResponse( - RoomSnapshot( - roomId = "room-2", - code = "EFGH5678", - phase = org.prairieserver.prairie.model.watchtogether.RoomPhase.Playing, - selfCanControlTransport = true, - ), - "jwt-room-2", - ), - ) - repository.createRoom(CreateRoomRequest()) + api.mutationResponse = room(snapshot(generation = 2), token = "jwt-renewed") + r.stageSelection(SetSelectionRequest(contentId = "movie:a")) + r.promoteSuggestion("s-1") - assertFalse( - repository.transportRequestForAuthorization( - authorization = authorizationA, - intent = RoomTransportIntent.PlayPause, - action = "pause", - positionSeconds = 42.0, - isPaused = true, - ), - ) - assertTrue(realtime.transportActions.isEmpty()) - connection.cancel() + assertEquals("jwt-renewed", api.lastRoomToken) + assertEquals(membership, r.membershipGeneration()) } @Test - fun `transport lease revalidates authority after policy changes in the same room`() = runTest { - val api = FakeApi().apply { - createResponse = ApiResult.Success( - RoomResponse( - RoomSnapshot( - roomId = "room-1", - code = "ABCD1234", - phase = org.prairieserver.prairie.model.watchtogether.RoomPhase.Playing, - selfCanControlTransport = true, - ), - "jwt-room", - ), - ) - } + fun `room read renews proof even when its snapshot is stale`() = runTest { + val api = FakeApi() val realtime = FakeRealtime() - val repository = repo(api = api, realtime = realtime) - repository.createRoom(CreateRoomRequest()) - val connection = launch { repository.connect("room-1") } - runCurrent() - realtime.events.emit(RoomRealtimeEvent.Opened) - runCurrent() - val authorizationBeforePolicyChange = requireNotNull(repository.currentTransportAuthorization()) - realtime.events.emit( - RoomRealtimeEvent.SnapshotEvent( - api.createResponse.let { (it as ApiResult.Success).data.room.copy(selfCanControlTransport = false) }, - ), - ) - runCurrent() - - assertFalse( - repository.transportRequestForAuthorization( - authorization = authorizationBeforePolicyChange, - intent = RoomTransportIntent.PlayPause, - action = "pause", - positionSeconds = 42.0, - isPaused = true, - ), - ) - assertTrue(realtime.transportActions.isEmpty()) - connection.cancel() - } - - @Test - fun `blocked physical send does not hold room replacement or reset mutex`() = runTest { - val api = FakeApi().apply { - createResponse = ApiResult.Success( - RoomResponse( - RoomSnapshot( - roomId = "room-1", - code = "ABCD1234", - phase = org.prairieserver.prairie.model.watchtogether.RoomPhase.Playing, - selfCanControlTransport = true, - ), - "jwt-room", - ), - ) - } - val realtime = FakeRealtime().apply { - transportResultGate = CompletableDeferred() - } - val repository = repo(api = api, realtime = realtime) - repository.createRoom(CreateRoomRequest()) - val connection = launch { repository.connect("room-1") } + val r = repo(api = api, realtime = realtime) + r.createRoom(create()) + val job = launch { r.connect("room-1") } runCurrent() realtime.events.emit(RoomRealtimeEvent.Opened) + realtime.events.emit(RoomRealtimeEvent.SnapshotEvent(snapshot(generation = 5))) runCurrent() - val authorization = requireNotNull(repository.currentTransportAuthorization()) - val blockedSend = async { - repository.transportRequestForAuthorization( - authorization = authorization, - intent = RoomTransportIntent.PlayPause, - action = "pause", - positionSeconds = 42.0, - isPaused = true, - ) - } - runCurrent() - val reset = async { repository.reset() } - runCurrent() + api.roomReadResponse = room(snapshot(generation = 3), token = "jwt-fresh") + r.refreshRoom() - assertTrue(reset.isCompleted) - assertFalse(blockedSend.isCompleted) - realtime.transportResultGate?.complete(false) - assertFalse(blockedSend.await()) - connection.cancel() + assertEquals(5L, r.roomSnapshot.value?.generation) + r.promoteSuggestion("s-1") + assertEquals("jwt-fresh", api.lastRoomToken) + job.cancel() } @Test @@ -506,57 +386,14 @@ class WatchTogetherRepositoryTest { val api = FakeApi() var currentScope = scopeA val r = repo(api = api, authScopeProvider = { currentScope }) - r.createRoom(CreateRoomRequest()) - currentScope = scopeA.copy( - serverId = "server-b", - serverUrl = "https://b.example", - identityGeneration = 2L, - ) + r.createRoom(create()) + currentScope = scopeA.copy(serverId = "server-b", serverUrl = "https://b.example", identityGeneration = 2L) r.setSelection(SetSelectionRequest(contentId = "tt-9")) assertEquals(scopeA, api.lastAuthScope) } - @Test - fun `realtime reconnect uses the auth scope captured by the room binding`() = runTest { - val realtime = FakeRealtime() - var currentScope = scopeA - val r = repo(realtime = realtime, authScopeProvider = { currentScope }) - r.createRoom(CreateRoomRequest()) - currentScope = scopeA.copy( - serverId = "server-b", - serverUrl = "https://b.example", - identityGeneration = 2L, - ) - - val connection = launch { r.connect("room-1") } - runCurrent() - - assertEquals(1, realtime.connectedAuthScopes.size) - assertEquals(scopeA, realtime.connectedAuthScopes.single()) - connection.cancel() - } - - @Test - fun `every reconnect attempt retains the room auth scope`() = runTest { - val realtime = FakeRealtime().apply { - connectBehavior = { attempt -> - if (attempt == 1) flow { throw IllegalStateException("drop") } else events.asSharedFlow() - } - } - val r = repo(realtime = realtime) - r.createRoom(CreateRoomRequest()) - val connection = launch { r.connect("room-1") } - runCurrent() - advanceTimeBy(WatchTogetherRepository.BACKOFF_MS.first()) - runCurrent() - - assertEquals(2, realtime.connectedAuthScopes.size) - assertTrue(realtime.connectedAuthScopes.all { it == scopeA }) - connection.cancel() - } - @Test fun `create response cannot mint a room after the auth identity changes`() = runTest { val api = FakeApi() @@ -564,7 +401,7 @@ class WatchTogetherRepositoryTest { api.createResult = pending var currentScope = scopeA val r = repo(api = api, authScopeProvider = { currentScope }) - val create = launch { r.createRoom(CreateRoomRequest()) } + val create = launch { r.createRoom(create()) } runCurrent() currentScope = scopeA.copy(identityGeneration = 2L) @@ -583,9 +420,7 @@ class WatchTogetherRepositoryTest { val barrier = DefaultIdentityTransitionBarrier() RoomSession(repository, backgroundScope, barrier) var createResult: ApiResult<RoomResponse>? = null - val create = launch { - createResult = repository.createRoom(CreateRoomRequest()) - } + val create = launch { createResult = repository.createRoom(create()) } runCurrent() barrier.changing(IdentityTransitionKind.PROFILE_SWITCH) { @@ -602,496 +437,688 @@ class WatchTogetherRepositoryTest { fun `blank room token fails closed without replacing the active room`() = runTest { val api = FakeApi() val r = repo(api = api) - r.createRoom(CreateRoomRequest()) - api.createResponse = ApiResult.Success( - RoomResponse(RoomSnapshot(roomId = "room-2", code = "EFGH5678"), ""), - ) - - val result = r.createRoom(CreateRoomRequest()) + r.createRoom(create()) + api.createResponse = room(RoomSnapshot(roomId = "room-2", code = "EFGH5678"), token = "") - assertIs<ApiResult.Error>(result) + assertIs<ApiResult.Error>(r.createRoom(create())) assertEquals("room-1", r.roomSnapshot.value?.roomId) - r.setSelection(SetSelectionRequest(contentId = "still-a")) + r.promoteSuggestion("still-a") assertEquals("room-1", api.lastRoomId) assertEquals("jwt-room", api.lastRoomToken) } - @Test - fun `mismatched room response fails closed without replacing the active room`() = runTest { - val api = FakeApi() - val r = repo(api = api) - r.createRoom(CreateRoomRequest()) - api.createResponse = ApiResult.Success( - RoomResponse(RoomSnapshot(roomId = "", code = "EFGH5678"), "jwt-room-2"), - ) - - val result = r.createRoom(CreateRoomRequest()) - - assertIs<ApiResult.Error>(result) - assertEquals("room-1", r.roomSnapshot.value?.roomId) - } - @Test fun `room scoped response for another room is rejected`() = runTest { val api = FakeApi() val r = repo(api = api) - r.createRoom(CreateRoomRequest()) - api.createResponse = ApiResult.Success( - RoomResponse(RoomSnapshot(roomId = "room-2", code = "EFGH5678"), "jwt-room-2"), - ) + r.createRoom(create()) + api.mutationResponse = room(RoomSnapshot(roomId = "room-2", code = "EFGH5678"), "jwt-room-2") - val result = r.setSelection(SetSelectionRequest(contentId = "wrong-room")) - - assertIs<ApiResult.Error>(result) + assertIs<ApiResult.Error>(r.setSelection(SetSelectionRequest(contentId = "wrong-room"))) assertEquals("room-1", r.roomSnapshot.value?.roomId) } @Test fun `create fails closed when there is no authenticated scope`() = runTest { val r = repo(authScopeProvider = { null }) - - val result = r.createRoom(CreateRoomRequest()) - - assertIs<ApiResult.Error>(result) + assertIs<ApiResult.Error>(r.createRoom(create())) assertNull(r.roomSnapshot.value) } @Test - fun `connect rejects a room id that does not match the active binding`() = runTest { + fun `a new membership clears the previous ended state`() = runTest { val realtime = FakeRealtime() val r = repo(realtime = realtime) - r.createRoom(CreateRoomRequest()) - - val job = launch { r.connect("room-2") } + r.createRoom(create()) + val job = launch { r.connect("room-1") } runCurrent() + realtime.events.emit(RoomRealtimeEvent.ConnectionReplaced("elsewhere")) + job.join() + assertEquals(WatchPartyEndReason.Replaced, r.ended.value?.reason) - assertEquals(0, realtime.connectCount) - job.cancel() + r.joinRoom(JoinRoomRequest(code = "ABCD1234")) + + assertNull(r.ended.value) + assertNull(r.roomClosedReason.value) + assertEquals("room-1", r.roomSnapshot.value?.roomId) } + // ---- actions: serialization, uncertainty, reconciliation ------------------------ + @Test - fun `local delivery failure is surfaced as a non terminal room error`() = runTest { - val repository = repo() - val error = async { repository.errors.first() } - runCurrent() + fun `an uncertain start reconciles by reading the room and is never replayed`() = runTest { + val api = FakeApi() + val r = repo(api = api) + r.createRoom(create()) + api.mutationResponse = ApiResult.NetworkError(RuntimeException("reset")) + api.roomReadResponse = room(snapshot(generation = 4, phase = RoomPhase.Playing)) - repository.reportDeliveryFailure("room_transport_unavailable") + assertIs<ApiResult.NetworkError>(r.startPlayback()) - assertEquals("room_transport_unavailable", error.await()) - assertNull(repository.roomClosedReason.value) + assertEquals(1, api.count("start")) + assertEquals(1, api.count("get")) + assertEquals(RoomPhase.Playing, r.roomSnapshot.value?.phase) } @Test - fun `controller recreation shares successful delivery state for the same epoch`() = runTest { - val repository = repo() - val key = repository.roomDeliveryLatch.keyOrNull( - WatchTogetherConnectionState(generation = 7, epoch = 2, writable = true), - "playback-1", - )!! - - repository.roomDeliveryLatch.recordAttach(key, delivered = true) - - assertTrue(repository.roomDeliveryLatch.isAttached(key)) - assertTrue(!repository.roomDeliveryLatch.needsAttach(key)) - } - - @Test - fun `two participants independently receive room state commands and terminal close`() = runTest { - val sharedSnapshot = snapshot(memberCount = 2) - val response = ApiResult.Success(RoomResponse(sharedSnapshot, "jwt-room")) - val hostRealtime = FakeRealtime() - val guestRealtime = FakeRealtime() - val host = repo(api = FakeApi(response), realtime = hostRealtime) - val guest = repo(api = FakeApi(response), realtime = guestRealtime) - host.createRoom(CreateRoomRequest()) - guest.createRoom(CreateRoomRequest()) - val hostCommand = async { host.transportCommands.first() } - val guestCommand = async { guest.transportCommands.first() } - val hostConnection = launch { host.connect("room-1") } - val guestConnection = launch { guest.connect("room-1") } - runCurrent() - val command = org.prairieserver.prairie.model.watchtogether.TransportCommand( - commandId = "cmd-1", - sessionId = "playback-1", - selectionRevision = sharedSnapshot.selectionRevision, - action = org.prairieserver.prairie.model.watchtogether.TransportAction.Seek, - positionSeconds = 42.0, - executeAt = "2026-07-27T12:00:00Z", - ) - - hostRealtime.events.emit(RoomRealtimeEvent.Opened) - guestRealtime.events.emit(RoomRealtimeEvent.Opened) - hostRealtime.events.emit(RoomRealtimeEvent.SnapshotEvent(sharedSnapshot)) - guestRealtime.events.emit(RoomRealtimeEvent.SnapshotEvent(sharedSnapshot)) - hostRealtime.events.emit(RoomRealtimeEvent.TransportCommandEvent(command)) - guestRealtime.events.emit(RoomRealtimeEvent.TransportCommandEvent(command)) - runCurrent() - - assertEquals(sharedSnapshot, host.roomSnapshot.value) - assertEquals(sharedSnapshot, guest.roomSnapshot.value) - val hostScheduled = hostCommand.await() - val guestScheduled = guestCommand.await() - assertEquals(command, hostScheduled.command) - assertEquals(command, guestScheduled.command) - assertEquals(host.connectionState.value, hostScheduled.connection) - assertEquals(guest.connectionState.value, guestScheduled.connection) - assertTrue(host.attachSession("host-playback")) - assertTrue(guest.attachSession("guest-playback")) - assertTrue(host.transportRequest("pause", 42.0, true)) - assertTrue(guest.transportRequest("play", 42.0, false)) - assertTrue(host.buffering("host-playback", 42.0, true)) - assertTrue(guest.ready("guest-playback", 42.0, false)) - assertEquals(listOf("pause"), hostRealtime.transportActions) - assertEquals(listOf("play"), guestRealtime.transportActions) - - hostRealtime.events.emit(RoomRealtimeEvent.Closed("host_left")) - guestRealtime.events.emit(RoomRealtimeEvent.Closed("host_left")) - hostConnection.join() - guestConnection.join() - - assertEquals("host_left", host.roomClosedReason.value) - assertEquals("host_left", guest.roomClosedReason.value) - assertEquals(1, hostRealtime.connectCount) - assertEquals(1, guestRealtime.connectCount) - } - - @Test - fun `late room A REST completion cannot overwrite replacement room B`() = runTest { + fun `a conflict on a host action is action-level when the room is still readable`() = runTest { val api = FakeApi() val r = repo(api = api) - r.createRoom(CreateRoomRequest()) - val pending = CompletableDeferred<ApiResult<RoomResponse>>() - api.selectionResult = pending - var staleResult: ApiResult<RoomResponse>? = null - val stale = launch { - staleResult = r.setSelection(SetSelectionRequest(contentId = "late-a")) - } - runCurrent() + r.createRoom(create()) + api.mutationResponse = ApiResult.Error(409, "conflict", "Nothing is staged.") - api.selectionResult = null - api.createResponse = ApiResult.Success( - RoomResponse(RoomSnapshot(roomId = "room-2", code = "EFGH5678"), "jwt-room-2"), - ) - r.createRoom(CreateRoomRequest()) - pending.complete( - ApiResult.Success( - RoomResponse(RoomSnapshot(roomId = "room-1", selectionRevision = 99), "jwt-room"), - ), - ) - stale.join() + assertIs<ApiResult.Error>(r.startPlayback()) - assertEquals("room-2", r.roomSnapshot.value?.roomId) - assertIs<ApiResult.Error>(staleResult) + assertEquals(1, api.count("get")) + assertNull(r.roomClosedReason.value) + assertEquals("room-1", r.roomSnapshot.value?.roomId) } @Test - fun `late room A socket event cannot overwrite replacement room B`() = runTest { + fun `a conflict confirmed by the room read ends the engagement`() = runTest { val api = FakeApi() - val realtime = FakeRealtime() - val r = repo(api = api, realtime = realtime) - r.createRoom(CreateRoomRequest()) - val oldConnection = launch { r.connect("room-1") } - runCurrent() + val r = repo(api = api) + r.createRoom(create()) + api.mutationResponse = ApiResult.Error(409, "conflict", "The room is closed.") + api.roomReadResponse = ApiResult.Error(409, "conflict", "The room is closed.") - api.createResponse = ApiResult.Success( - RoomResponse(RoomSnapshot(roomId = "room-2", code = "EFGH5678"), "jwt-room-2"), - ) - r.createRoom(CreateRoomRequest()) - realtime.events.emit(RoomRealtimeEvent.SnapshotEvent(snapshot(roomId = "room-1", revision = 99))) - advanceUntilIdle() + r.stageSelection(SetSelectionRequest(contentId = "movie:a")) - assertEquals("room-2", r.roomSnapshot.value?.roomId) - assertTrue(oldConnection.isCompleted) - assertTrue(!r.attachSession("session-b")) - assertEquals(0, realtime.attachCount) + assertEquals(WatchPartyEndReason.Ended, r.roomClosedReason.value) + assertNull(r.roomSnapshot.value) + assertNull(r.membershipGeneration()) } - // ---- snapshot fold -------------------------------------------------------- + @Test + fun `an ended snapshot in a response is terminal`() = runTest { + val api = FakeApi() + val r = repo(api = api) + r.createRoom(create()) + api.mutationResponse = room(snapshot(generation = 9, phase = RoomPhase.Ended)) + + assertIs<ApiResult.Error>(r.stopPlayback()) + + assertEquals(WatchPartyEndReason.Ended, r.roomClosedReason.value) + assertNull(r.roomSnapshot.value) + } @Test - fun `snapshot event publishes room snapshot`() = runTest { - val realtime = FakeRealtime() - val r = repo(realtime = realtime) - r.createRoom(CreateRoomRequest()) - val job = launch { r.connect("room-1") } - advanceUntilIdle() - realtime.events.emit(RoomRealtimeEvent.SnapshotEvent(snapshot(revision = 5, memberCount = 3))) - advanceUntilIdle() - assertEquals(5L, r.roomSnapshot.value?.selectionRevision) - assertEquals(3, r.roomSnapshot.value?.memberCount) - job.cancel() + fun `ending an already ended room converges on the ended state`() = runTest { + val api = object : WatchTogetherApi by FakeApi() { + override suspend fun closeRoom(roomId: String, scope: AuthScopeSnapshot): ApiResult<Unit> = + ApiResult.Error(409, "conflict", "The room is closed.") + } + val r = WatchTogetherRepository(api = api, authScopeProvider = { scopeA }, timing = testTiming) + r.createRoom(create()) + + assertIs<ApiResult.Success<Unit>>(r.closeRoom()) + assertEquals(WatchPartyEndReason.Ended, r.roomClosedReason.value) } @Test - fun `snapshot for another room cannot replace the active room`() = runTest { - val realtime = FakeRealtime() - val r = repo(realtime = realtime) - r.createRoom(CreateRoomRequest()) - val job = launch { r.connect("room-1") } + fun `actions run one at a time and expose the pending action`() = runTest { + val api = FakeApi() + val r = repo(api = api) + r.createRoom(create()) + val gate = CompletableDeferred<ApiResult<RoomResponse>>() + api.selectionResult = gate + + val first = async { r.setSelection(SetSelectionRequest(contentId = "movie:a")) } + runCurrent() + assertEquals(WatchPartyPendingAction.Select, r.pendingAction.value) + val second = async { r.stopPlayback() } runCurrent() + assertEquals(0, api.count("stop")) - realtime.events.emit(RoomRealtimeEvent.SnapshotEvent(snapshot(roomId = "room-2", revision = 9))) + gate.complete(api.createResponse) + first.await() + second.await() + assertEquals(listOf("select", "stop"), api.calls.filter { it == "select" || it == "stop" }) + assertNull(r.pendingAction.value) + } + + @Test + fun `a late REST completion cannot publish after leaving`() = runTest { + val api = FakeApi() + val r = repo(api = api) + r.createRoom(create()) + val pending = CompletableDeferred<ApiResult<RoomResponse>>() + api.selectionResult = pending + var staleResult: ApiResult<RoomResponse>? = null + val stale = launch { staleResult = r.setSelection(SetSelectionRequest(contentId = "late-a")) } runCurrent() - assertEquals("room-1", r.roomSnapshot.value?.roomId) - job.cancel() + r.reset() + pending.complete(room(RoomSnapshot(roomId = "room-1", selectionRevision = 99, generation = 9))) + stale.join() + + assertEquals("obsolete_room_request", assertIs<ApiResult.Error>(staleResult).error) + assertNull(r.roomSnapshot.value) + + api.createResponse = room(RoomSnapshot(roomId = "room-2", code = "EFGH5678"), "jwt-room-2") + r.createRoom(CreateRoomRequest(roomId = "room-2")) + assertEquals("room-2", r.roomSnapshot.value?.roomId) } - // ---- suggestions fold + voted_by_me re-merge ------------------------------ + // ---- snapshot ordering ------------------------------------------------------- @Test - fun `opened refreshes suggestions after reconnect without refreshing after room closed`() = runTest { - val api = FakeApi().apply { - listSuggestionsResponse = ApiResult.Success( - SuggestionsResponse(suggestions = listOf(suggestion("before-drop"))), - ) - } + fun `an equal-generation HTTP response cannot erase a newer socket attachment`() = runTest { + val api = FakeApi() val realtime = FakeRealtime() - val repository = repo(api = api, realtime = realtime) - repository.createRoom(CreateRoomRequest()) - val connection = launch { repository.connect("room-1") } + val r = repo(api = api, realtime = realtime) + r.createRoom(create()) + val job = launch { r.connect("room-1") } runCurrent() - realtime.events.emit(RoomRealtimeEvent.Opened) + realtime.events.emit(RoomRealtimeEvent.SnapshotEvent(snapshot(generation = 3))) runCurrent() - assertEquals(1, api.listSuggestionsCalls) - assertEquals(listOf("before-drop"), repository.suggestions.value.map { it.id }) - realtime.events.emit(RoomRealtimeEvent.TransportTerminated()) + val gate = CompletableDeferred<ApiResult<RoomResponse>>() + api.roomReadResult = gate + val read = async { r.refreshRoom() } runCurrent() - api.listSuggestionsResponse = ApiResult.Success( - SuggestionsResponse( - suggestions = listOf( - suggestion("before-drop"), - suggestion("missed-during-drop"), - ), - ), - ) - advanceTimeBy(WatchTogetherRepository.BACKOFF_MS.first()) + // The socket confirms the attachment while the read is in flight. + realtime.events.emit(RoomRealtimeEvent.SnapshotEvent(snapshot(generation = 3, attachedSessionId = "ps-1"))) runCurrent() - assertEquals(2, realtime.connectCount) + gate.complete(room(snapshot(generation = 3))) + read.await() + + assertEquals("ps-1", r.roomSnapshot.value?.attachedSessionId) + assertEquals("ps-1", r.roomDeliveryEcho.value?.playbackSessionId) + job.cancel() + } + @Test + fun `an older-generation HTTP response cannot roll the room back`() = runTest { + val api = FakeApi() + val realtime = FakeRealtime() + val r = repo(api = api, realtime = realtime) + r.createRoom(create()) + val job = launch { r.connect("room-1") } + runCurrent() realtime.events.emit(RoomRealtimeEvent.Opened) + realtime.events.emit(RoomRealtimeEvent.SnapshotEvent(snapshot(generation = 6, revision = 3, phase = RoomPhase.Playing))) runCurrent() - assertEquals(2, api.listSuggestionsCalls) - assertEquals( - listOf("before-drop", "missed-during-drop"), - repository.suggestions.value.map { it.id }, - ) - realtime.events.emit(RoomRealtimeEvent.Closed("host_left")) - advanceUntilIdle() - assertEquals(2, realtime.connectCount) - assertEquals(2, api.listSuggestionsCalls) - assertTrue(connection.isCompleted || connection.isCancelled) + api.mutationResponse = room(snapshot(generation = 4, revision = 2)) + r.stageSelection(SetSelectionRequest(contentId = "movie:a")) + + assertEquals(6L, r.roomSnapshot.value?.generation) + assertEquals(RoomPhase.Playing, r.roomSnapshot.value?.phase) + job.cancel() } @Test - fun `rest refresh replaces authoritative local vote set`() = runTest { - val api = FakeApi().apply { - listSuggestionsResponse = ApiResult.Success( - SuggestionsResponse( - suggestions = listOf( - suggestion("removed-vote", votedByMe = true), - suggestion("preserved-vote", votedByMe = true), - ), - ), - ) - } - val repository = repo(api = api) - repository.createRoom(CreateRoomRequest()) + fun `a newer-generation HTTP response advances the room`() = runTest { + val api = FakeApi() + val r = repo(api = api) + r.createRoom(create()) + api.mutationResponse = room(snapshot(generation = 7, revision = 2, phase = RoomPhase.Playing)) - repository.refreshSuggestions() - api.listSuggestionsResponse = ApiResult.Success( - SuggestionsResponse( - suggestions = listOf( - suggestion("removed-vote", votedByMe = false), - suggestion("preserved-vote", votedByMe = true), - ), - ), - ) - repository.refreshSuggestions() + r.startPlayback() - val byId = repository.suggestions.value.associateBy { it.id } - assertFalse(byId.getValue("removed-vote").votedByMe) - assertTrue(byId.getValue("preserved-vote").votedByMe) + assertEquals(RoomPhase.Playing, r.roomSnapshot.value?.phase) } @Test - fun `suggestions event re-merges voted_by_me from local vote set`() = runTest { + fun `a delayed lower-generation socket snapshot is ignored`() = runTest { val api = FakeApi() val realtime = FakeRealtime() val r = repo(api = api, realtime = realtime) - r.createRoom(CreateRoomRequest()) + r.createRoom(create()) val job = launch { r.connect("room-1") } - advanceUntilIdle() - - // Local user voted for s2 (REST round-trip records it in the local set). - r.vote("s2") - advanceUntilIdle() + runCurrent() + realtime.events.emit(RoomRealtimeEvent.Opened) + runCurrent() + api.mutationResponse = room(snapshot(generation = 5, revision = 2, phase = RoomPhase.Playing)) + r.startPlayback() - // Broadcast forces voted_by_me=false for all — repository must re-merge. - realtime.events.emit( - RoomRealtimeEvent.SuggestionsEvent( - listOf( - suggestion("s1", voteCount = 1, votedByMe = false), - suggestion("s2", voteCount = 2, votedByMe = false), - ), - ), - ) - advanceUntilIdle() + realtime.events.emit(RoomRealtimeEvent.SnapshotEvent(snapshot(generation = 4))) + runCurrent() - val byId = r.suggestions.value.associateBy { it.id } - assertTrue(byId.getValue("s2").votedByMe) // re-merged from local set - assertTrue(!byId.getValue("s1").votedByMe) + assertEquals(5L, r.roomSnapshot.value?.generation) job.cancel() } @Test - fun `unvote removes from local vote set so re-merge keeps false`() = runTest { + fun `an ended socket snapshot without room_closed is terminal and stops reconnecting`() = runTest { val realtime = FakeRealtime() val r = repo(realtime = realtime) - r.createRoom(CreateRoomRequest()) + r.createRoom(create()) val job = launch { r.connect("room-1") } + runCurrent() + realtime.events.emit(RoomRealtimeEvent.Opened) + realtime.events.emit(RoomRealtimeEvent.SnapshotEvent(snapshot(generation = 8, phase = RoomPhase.Ended))) + realtime.events.emit(RoomRealtimeEvent.TransportTerminated()) advanceUntilIdle() - r.vote("s2"); advanceUntilIdle() - r.unvote("s2"); advanceUntilIdle() - realtime.events.emit(RoomRealtimeEvent.SuggestionsEvent(listOf(suggestion("s2", votedByMe = false)))) - advanceUntilIdle() - assertTrue(!r.suggestions.value.first().votedByMe) - job.cancel() + + assertEquals(WatchPartyEndReason.Ended, r.roomClosedReason.value) + assertEquals(1, realtime.connectCount) + assertTrue(job.isCompleted) + + // A stale Playing snapshot afterward cannot resurrect it. + assertNull(r.roomSnapshot.value) + assertNull(r.membershipGeneration()) } - // ---- reset --------------------------------------------------------------- + @Test + fun `snapshot for another room cannot replace the active room`() = runTest { + val realtime = FakeRealtime() + val r = repo(realtime = realtime) + r.createRoom(create()) + val job = launch { r.connect("room-1") } + runCurrent() + + realtime.events.emit(RoomRealtimeEvent.SnapshotEvent(snapshot(roomId = "room-2", revision = 9))) + runCurrent() + + assertEquals("room-1", r.roomSnapshot.value?.roomId) + job.cancel() + } @Test - fun `reset clears snapshot suggestions and room token`() = runTest { + fun `late room A socket event cannot overwrite replacement room B`() = runTest { val api = FakeApi() val realtime = FakeRealtime() val r = repo(api = api, realtime = realtime) - r.createRoom(CreateRoomRequest()) - val job = launch { r.connect("room-1") } - advanceUntilIdle() - realtime.events.emit(RoomRealtimeEvent.SnapshotEvent(snapshot())) - realtime.events.emit(RoomRealtimeEvent.SuggestionsEvent(listOf(suggestion("s1")))) + r.createRoom(create()) + val oldConnection = launch { r.connect("room-1") } + runCurrent() + + api.createResponse = room(RoomSnapshot(roomId = "room-2", code = "EFGH5678"), "jwt-room-2") + r.createRoom(CreateRoomRequest(roomId = "room-2")) + realtime.events.emit(RoomRealtimeEvent.SnapshotEvent(snapshot(roomId = "room-1", revision = 99))) advanceUntilIdle() - r.reset() - assertNull(r.roomSnapshot.value) - assertTrue(r.suggestions.value.isEmpty()) - // After reset the room token is gone; a room-scoped call must not reuse it. - api.lastRoomToken = null - val result = r.setSelection(SetSelectionRequest(contentId = "x")) - assertIs<ApiResult.Error>(result) - assertNull(api.lastRoomToken) // fail fast: no request is sent with an empty token - job.cancel() + assertEquals("room-2", r.roomSnapshot.value?.roomId) + assertTrue(oldConnection.isCompleted) + assertTrue(!r.attachSession("session-b")) + assertEquals(0, realtime.attachCount) } - // ---- closed/error surface ------------------------------------------------ - @Test - fun `room_closed populates roomClosedReason and reset clears it`() = runTest { + fun `a malformed known frame reconciles by reading the room`() = runTest { + val api = FakeApi() val realtime = FakeRealtime() - val r = repo(realtime = realtime) - r.createRoom(CreateRoomRequest()) + val r = repo(api = api, realtime = realtime) + r.createRoom(create()) val job = launch { r.connect("room-1") } - advanceUntilIdle() - assertNull(r.roomClosedReason.value) + runCurrent() + realtime.events.emit(RoomRealtimeEvent.Opened) + runCurrent() - realtime.events.emit(RoomRealtimeEvent.Closed("host_left")) - advanceUntilIdle() - assertEquals("host_left", r.roomClosedReason.value) + realtime.events.emit(RoomRealtimeEvent.Malformed("snapshot")) + runCurrent() - r.reset() + assertEquals(1, api.count("get")) assertNull(r.roomClosedReason.value) job.cancel() } + // ---- transport leases ---------------------------------------------------------- + @Test - fun `error frame surfaces on errors and does not populate roomClosedReason`() = runTest { + fun `transport lease cannot send into a replacement room`() = runTest { + val api = FakeApi() val realtime = FakeRealtime() - val r = repo(realtime = realtime) - r.createRoom(CreateRoomRequest()) - val job = launch { r.connect("room-1") } - advanceUntilIdle() - assertNull(r.roomClosedReason.value) + val repository = repo(api = api, realtime = realtime) + repository.createRoom(create()) + val connection = launch { repository.connect("room-1") } + runCurrent() + realtime.events.emit(RoomRealtimeEvent.Opened) + runCurrent() + val authorizationA = requireNotNull(repository.currentTransportAuthorization()) - // Collect the transient errors stream; it's a hot SharedFlow so we must - // be subscribed before the event is emitted. - val seen = mutableListOf<String>() - val errorJob = launch { r.errors.collect { seen.add(it) } } - advanceUntilIdle() + api.createResponse = room( + RoomSnapshot(roomId = "room-2", code = "EFGH5678", phase = RoomPhase.Playing, selfCanControlTransport = true), + "jwt-room-2", + ) + repository.createRoom(CreateRoomRequest(roomId = "room-2")) - // A transient server `error` frame (e.g. a rejected transport_request) - // must NOT eject the user: roomClosedReason stays null and the message - // surfaces on the errors stream instead. - realtime.events.emit(RoomRealtimeEvent.Error(code = "rejected", message = "transport rejected")) - advanceUntilIdle() + assertFalse( + repository.transportRequestForAuthorization(authorizationA, RoomTransportIntent.PlayPause, "pause", 42.0, true), + ) + assertTrue(realtime.transportActions.isEmpty()) + connection.cancel() + } - assertNull(r.roomClosedReason.value) - assertEquals(listOf("transport rejected"), seen) + @Test + fun `transport lease revalidates authority after policy changes in the same room`() = runTest { + val playing = RoomSnapshot(roomId = "room-1", code = "ABCD1234", phase = RoomPhase.Playing, selfCanControlTransport = true) + val api = FakeApi(room(playing)) + val realtime = FakeRealtime() + val repository = repo(api = api, realtime = realtime) + repository.createRoom(create()) + val connection = launch { repository.connect("room-1") } + runCurrent() + realtime.events.emit(RoomRealtimeEvent.Opened) + runCurrent() + val authorizationBeforePolicyChange = requireNotNull(repository.currentTransportAuthorization()) + realtime.events.emit(RoomRealtimeEvent.SnapshotEvent(playing.copy(selfCanControlTransport = false))) + runCurrent() - errorJob.cancel() - job.cancel() + assertFalse( + repository.transportRequestForAuthorization( + authorizationBeforePolicyChange, RoomTransportIntent.PlayPause, "pause", 42.0, true, + ), + ) + assertTrue(realtime.transportActions.isEmpty()) + connection.cancel() } - // ---- reconnect stops on room_closed -------------------------------------- - @Test - fun `reconnect loop stops after room_closed`() = runTest { - val realtime = FakeRealtime() - val r = repo(realtime = realtime) - r.createRoom(CreateRoomRequest()) - val job = launch { r.connect("room-1") } - advanceUntilIdle() - assertEquals(1, realtime.connectCount) + fun `blocked physical send does not hold room replacement or reset mutex`() = runTest { + val playing = RoomSnapshot(roomId = "room-1", code = "ABCD1234", phase = RoomPhase.Playing, selfCanControlTransport = true) + val api = FakeApi(room(playing)) + val realtime = FakeRealtime().apply { transportResultGate = CompletableDeferred() } + val repository = repo(api = api, realtime = realtime) + repository.createRoom(create()) + val connection = launch { repository.connect("room-1") } + runCurrent() + realtime.events.emit(RoomRealtimeEvent.Opened) + runCurrent() + val authorization = requireNotNull(repository.currentTransportAuthorization()) - // Server-initiated close: repository must NOT reconnect. - realtime.events.emit(RoomRealtimeEvent.Closed("host_left")) - advanceUntilIdle() - assertEquals(1, realtime.connectCount) - assertTrue(job.isCompleted || job.isCancelled) + val blockedSend = async { + repository.transportRequestForAuthorization(authorization, RoomTransportIntent.PlayPause, "pause", 42.0, true) + } + runCurrent() + val reset = async { repository.reset() } + runCurrent() - // A terminal binding is tombstoned. Reusing the old room id cannot - // create a fresh connection without a new successful create/join. - r.connect("room-1") - assertEquals(1, realtime.connectCount) + assertTrue(reset.isCompleted) + assertFalse(blockedSend.isCompleted) + realtime.transportResultGate?.complete(false) + assertFalse(blockedSend.await()) + connection.cancel() } @Test - fun `reconnect loop stops after room_closed with no reason`() = runTest { + fun `readiness and lobby frames reach the writable socket with their command`() = runTest { val realtime = FakeRealtime() val r = repo(realtime = realtime) - r.createRoom(CreateRoomRequest()) + r.createRoom(create()) val job = launch { r.connect("room-1") } - advanceUntilIdle() - assertEquals(1, realtime.connectCount) + runCurrent() + assertFalse(r.setLobbyReady(true)) + realtime.events.emit(RoomRealtimeEvent.Opened) + runCurrent() - // Server-initiated close with no reason: repository must NOT reconnect. - realtime.events.emit(RoomRealtimeEvent.Closed(null)) - advanceUntilIdle() - assertEquals(1, realtime.connectCount) - assertEquals("room_closed", r.roomClosedReason.value) - assertTrue(job.isCompleted || job.isCancelled) + assertTrue(r.ready("ps-1", 12.0, false, commandId = "cmd-7")) + assertTrue(r.setLobbyReady(true)) + assertEquals(listOf<String?>("cmd-7"), realtime.readyCommands) + assertEquals(listOf(true), realtime.lobbyReady) + job.cancel() } + // ---- connection lifecycle ----------------------------------------------------- + @Test - fun `replacement during reconnect backoff cannot open another stale socket`() = runTest { - val api = FakeApi() + fun `realtime reconnect uses the auth scope and proof captured by the membership`() = runTest { + val realtime = FakeRealtime().apply { + connectBehavior = { attempt -> + if (attempt == 1) flow { throw IllegalStateException("drop") } else events.asSharedFlow() + } + } + var currentScope = scopeA + val r = repo(realtime = realtime, authScopeProvider = { currentScope }) + r.createRoom(create()) + currentScope = scopeA.copy(serverId = "server-b", serverUrl = "https://b.example", identityGeneration = 2L) + val connection = launch { r.connect("room-1") } + runCurrent() + advanceTimeBy(firstBackoff + 1) + runCurrent() + + assertEquals(2, realtime.connectCount) + assertTrue(realtime.connectedAuthScopes.all { it == scopeA }) + assertTrue(realtime.connectedRoomTokens.all { it == "jwt-room" }) + connection.cancel() + } + + @Test + fun `connect rejects a room id that does not match the active membership`() = runTest { + val realtime = FakeRealtime() + val r = repo(realtime = realtime) + r.createRoom(create()) + + val job = launch { r.connect("room-2") } + runCurrent() + + assertEquals(0, realtime.connectCount) + job.cancel() + } + + @Test + fun `routine socket rotation reconnects at once and keeps the epoch advancing`() = runTest { + val realtime = FakeRealtime().apply { + connectBehavior = { attempt -> + if (attempt == 1) { + flow { + emit(RoomRealtimeEvent.Opened) + emit(RoomRealtimeEvent.SnapshotEvent(snapshot())) + kotlinx.coroutines.delay(5 * 60_000L) + emit(RoomRealtimeEvent.TransportTerminated()) + } + } else { + events.asSharedFlow() + } + } + } + val r = repo(realtime = realtime) + r.createRoom(create()) + val job = launch { r.connect("room-1") } + runCurrent() + advanceTimeBy(5 * 60_000L) + runCurrent() + + // No backoff after a socket that stayed up. + assertEquals(2, realtime.connectCount) + realtime.events.emit(RoomRealtimeEvent.Opened) + runCurrent() + assertEquals(2L, r.connectionState.value.epoch) + assertNull(r.connectionState.value.disconnectedAtMs) + job.cancel() + } + + @Test + fun `connection replaced is terminal for this device and never reclaims`() = runTest { + val realtime = FakeRealtime() + val r = repo(realtime = realtime) + r.createRoom(create()) + val job = launch { r.connect("room-1") } + runCurrent() + realtime.events.emit(RoomRealtimeEvent.Opened) + realtime.events.emit(RoomRealtimeEvent.SnapshotEvent(snapshot().copy(selfRole = MemberRole.Guest))) + runCurrent() + + realtime.events.emit(RoomRealtimeEvent.ConnectionReplaced("This profile joined the Watch Party on another device.")) + advanceUntilIdle() + + assertTrue(job.isCompleted) + assertEquals(1, realtime.connectCount) + assertEquals(WatchPartyEndReason.Replaced, r.roomClosedReason.value) + assertEquals(WatchPartyEnded("room-1", "ABCD1234", WatchPartyEndReason.Replaced, wasHost = false), r.ended.value) + } + + @Test + fun `room_closed populates the reason and reset clears it`() = runTest { + val realtime = FakeRealtime() + val r = repo(realtime = realtime) + r.createRoom(create()) + val job = launch { r.connect("room-1") } + runCurrent() + assertNull(r.roomClosedReason.value) + + realtime.events.emit(RoomRealtimeEvent.Closed("host_left")) + advanceUntilIdle() + assertEquals("host_left", r.roomClosedReason.value) + assertEquals(1, realtime.connectCount) + assertTrue(job.isCompleted) + + // A terminal membership cannot open a fresh connection without a new join. + r.connect("room-1") + assertEquals(1, realtime.connectCount) + + r.reset() + assertNull(r.roomClosedReason.value) + assertNull(r.ended.value) + } + + @Test + fun `room_closed without a reason is reported as the host leaving`() = runTest { + val realtime = FakeRealtime() + val r = repo(realtime = realtime) + r.createRoom(create()) + val job = launch { r.connect("room-1") } + runCurrent() + + realtime.events.emit(RoomRealtimeEvent.Closed(null)) + advanceUntilIdle() + + assertEquals(WatchPartyEndReason.HostLeft, r.roomClosedReason.value) + assertTrue(job.isCompleted) + } + + @Test + fun `error frame surfaces on errors and does not end the party`() = runTest { + val realtime = FakeRealtime() + val r = repo(realtime = realtime) + r.createRoom(create()) + val job = launch { r.connect("room-1") } + runCurrent() + val seen = mutableListOf<String>() + val errorJob = launch { r.errors.collect { seen.add(it) } } + runCurrent() + + realtime.events.emit(RoomRealtimeEvent.Error(code = "bad_request", message = "transport rejected")) + runCurrent() + + assertNull(r.roomClosedReason.value) + assertEquals(listOf("transport rejected"), seen) + errorJob.cancel() + job.cancel() + } + + @Test + fun `terminal ticket refusals end the engagement with a matching reason`() = runTest { + for ((status, reason) in listOf( + 404 to WatchPartyEndReason.NotFound, + 409 to WatchPartyEndReason.Ended, + 422 to WatchPartyEndReason.Unauthorized, + )) { + val realtime = FakeRealtime().apply { + connectBehavior = { flow { emit(RoomRealtimeEvent.TransportTerminated(RoomTicketRefusedException(status, "x"))) } } + } + val r = repo(realtime = realtime) + r.createRoom(create()) + r.connect("room-1") + assertEquals(reason, r.roomClosedReason.value, "status $status") + assertEquals(1, realtime.connectCount) + } + } + + @Test + fun `a 403 ticket refusal renews the proof once and reconnects when the room is readable`() = runTest { + val api = FakeApi() + val realtime = FakeRealtime().apply { + connectBehavior = { attempt -> + if (attempt == 1) { + flow { emit(RoomRealtimeEvent.TransportTerminated(RoomTicketRefusedException(403, "permission_denied"))) } + } else { + events.asSharedFlow() + } + } + } + val r = repo(api = api, realtime = realtime) + r.createRoom(create()) + api.roomReadResponse = room(snapshot(generation = 2), token = "jwt-renewed") + val job = launch { r.connect("room-1") } + runCurrent() + + assertEquals(2, realtime.connectCount) + assertEquals(listOf("jwt-room", "jwt-renewed"), realtime.connectedRoomTokens) + assertNull(r.roomClosedReason.value) + job.cancel() + } + + @Test + fun `an expiring ticket waits briefly and mints again`() = runTest { + val realtime = FakeRealtime().apply { + connectBehavior = { attempt -> + if (attempt == 1) { + flow { emit(RoomRealtimeEvent.TransportTerminated(RoomTicketExpiringException())) } + } else { + events.asSharedFlow() + } + } + } + val r = repo(realtime = realtime) + r.createRoom(create()) + val job = launch { r.connect("room-1") } + runCurrent() + assertEquals(1, realtime.connectCount) + + advanceTimeBy(testTiming.expiringTicketRetryMs + 1) + runCurrent() + + assertEquals(2, realtime.connectCount) + assertNull(r.roomClosedReason.value) + job.cancel() + } + + @Test + fun `a 401 ticket refusal reconnects instead of ending the party`() = runTest { + val realtime = FakeRealtime().apply { + connectBehavior = { attempt -> + if (attempt == 1) { + flow { emit(RoomRealtimeEvent.TransportTerminated(RoomTicketRefusedException(401, "unauthorized"))) } + } else { + events.asSharedFlow() + } + } + } + val r = repo(realtime = realtime) + r.createRoom(create()) + val job = launch { r.connect("room-1") } + advanceTimeBy(testTiming.backoffMs.first() + 1) + runCurrent() + + assertEquals(2, realtime.connectCount) + assertNull(r.roomClosedReason.value) + job.cancel() + } + + @Test + fun `expiring tickets that never stop exhaust the reconnect budget`() = runTest { + val realtime = FakeRealtime().apply { + connectBehavior = { flow { emit(RoomRealtimeEvent.TransportTerminated(RoomTicketExpiringException())) } } + } + val r = repo(realtime = realtime) + r.createRoom(create()) + val job = launch { r.connect("room-1") } + advanceTimeBy(testTiming.reconnectBudgetMs + testTiming.expiringTicketRetryMs + 1) + runCurrent() + + assertTrue(job.isCompleted) + assertEquals(WatchPartyEndReason.ConnectionLost, r.roomClosedReason.value) + } + + @Test + fun `replacement during reconnect backoff cannot open another stale socket`() = runTest { + val api = FakeApi() val realtime = FakeRealtime().apply { connectBehavior = { attempt -> if (attempt == 1) flow { throw IllegalStateException("drop") } else events.asSharedFlow() } } val r = repo(api = api, realtime = realtime) - r.createRoom(CreateRoomRequest()) + r.createRoom(create()) val old = launch { r.connect("room-1") } - runCurrent() // first attempt failed and is waiting in its 500 ms backoff + runCurrent() - api.createResponse = ApiResult.Success( - RoomResponse(RoomSnapshot(roomId = "room-2", code = "EFGH5678"), "jwt-room-2"), - ) - r.createRoom(CreateRoomRequest()) + api.createResponse = room(RoomSnapshot(roomId = "room-2", code = "EFGH5678"), "jwt-room-2") + r.createRoom(CreateRoomRequest(roomId = "room-2")) advanceUntilIdle() assertEquals(1, realtime.connectCount) @@ -1103,12 +1130,8 @@ class WatchTogetherRepositoryTest { val first = FakeRealtime() val second = FakeRealtime() var factoryCall = 0 - val r = WatchTogetherRepository( - api = FakeApi(), - realtimeFactory = { if (factoryCall++ == 0) first else second }, - authScopeProvider = { scopeA }, - ) - r.createRoom(CreateRoomRequest()) + val r = repo(realtimeFactory = { if (factoryCall++ == 0) first else second }) + r.createRoom(create()) val old = launch { r.connect("room-1") } runCurrent() first.events.emit(RoomRealtimeEvent.Opened) @@ -1132,7 +1155,7 @@ class WatchTogetherRepositoryTest { } @Test - fun `throw after opened marks the connection unwritable during backoff`() = runTest { + fun `throw after opened marks the connection unwritable and records when`() = runTest { val realtime = FakeRealtime().apply { connectBehavior = { attempt -> if (attempt == 1) flow { @@ -1142,19 +1165,20 @@ class WatchTogetherRepositoryTest { } } val r = repo(realtime = realtime) - r.createRoom(CreateRoomRequest()) + r.createRoom(create()) val job = launch { r.connect("room-1") } runCurrent() assertTrue(!r.connectionState.value.writable) + assertEquals(0L, r.connectionState.value.disconnectedAtMs) assertTrue(!r.attachSession("during-backoff")) assertEquals(0, realtime.attachCount) job.cancel() } @Test - fun `queued transport command retains the exact epoch that emitted it across reconnect`() = runTest { - val command = org.prairieserver.prairie.model.watchtogether.TransportCommand( + fun `the latest transport command keeps the exact epoch that emitted it`() = runTest { + val command = TransportCommand( commandId = "epoch-1-command", sessionId = "playback-1", selectionRevision = 1, @@ -1174,15 +1198,15 @@ class WatchTogetherRepositoryTest { } } val r = repo(realtime = realtime) - r.createRoom(CreateRoomRequest()) - val received = async { r.transportCommands.first() } + r.createRoom(create()) + val received = async { r.latestTransportCommand.filterNotNull().first() } val job = launch { r.connect("room-1") } runCurrent() val scheduled = received.await() assertEquals(1L, scheduled.connection.epoch) - assertTrue(!r.connectionState.value.writable) - advanceTimeBy(500) + assertEquals(parseMs("2026-07-27T12:00:00Z"), scheduled.executeAtMs) + advanceTimeBy(firstBackoff + 1) runCurrent() realtime.events.emit(RoomRealtimeEvent.Opened) runCurrent() @@ -1192,9 +1216,31 @@ class WatchTogetherRepositoryTest { job.cancel() } + @Test + fun `a burst of commands leaves the newest one for a slow collector`() = runTest { + val realtime = FakeRealtime() + val r = repo(realtime = realtime) + r.createRoom(create()) + val job = launch { r.connect("room-1") } + runCurrent() + realtime.events.emit(RoomRealtimeEvent.Opened) + repeat(40) { index -> + realtime.events.emit( + RoomRealtimeEvent.TransportCommandEvent( + TransportCommand(commandId = "cmd-$index", selectionRevision = 1, action = TransportAction.Seek, executeAt = ""), + ), + ) + } + runCurrent() + + assertEquals("cmd-39", r.latestTransportCommand.value?.command?.commandId) + assertNull(r.latestTransportCommand.value?.executeAtMs) + job.cancel() + } + @Test fun `attach echo retains its observed epoch until the reconnect receives a fresh snapshot`() = runTest { - val attachedSnapshot = snapshot().copy(attachedSessionId = "playback-1") + val attachedSnapshot = snapshot(attachedSessionId = "playback-1") val realtime = FakeRealtime().apply { connectBehavior = { attempt -> if (attempt == 1) { @@ -1209,12 +1255,12 @@ class WatchTogetherRepositoryTest { } } val repository = repo(realtime = realtime) - repository.createRoom(CreateRoomRequest()) + repository.createRoom(create()) val job = launch { repository.connect("room-1") } runCurrent() assertEquals(1L, repository.roomDeliveryEcho.value?.connectionEpoch) - advanceTimeBy(500) + advanceTimeBy(firstBackoff + 1) runCurrent() realtime.events.emit(RoomRealtimeEvent.Opened) runCurrent() @@ -1233,7 +1279,7 @@ class WatchTogetherRepositoryTest { fun `send fails before the active connection reports writable`() = runTest { val realtime = FakeRealtime() val r = repo(realtime = realtime) - r.createRoom(CreateRoomRequest()) + r.createRoom(create()) val job = launch { r.connect("room-1") } runCurrent() @@ -1242,137 +1288,451 @@ class WatchTogetherRepositoryTest { job.cancel() } - // ---- reconnect gives up after max consecutive failures ---------------------- - @Test - fun `reconnect loop gives up after the max consecutive failures`() = runTest { + fun `quick failures keep reconnecting through the host grace before giving up`() = runTest { val realtime = FakeRealtime().apply { failConnect = true } val r = repo(realtime = realtime) - r.createRoom(CreateRoomRequest()) + r.createRoom(create()) val job = launch { r.connect("room-1") } + + advanceTimeBy(testTiming.reconnectBudgetMs - 1) + runCurrent() + assertTrue(job.isActive, "gave up inside the reconnect budget") + assertTrue(realtime.connectCount > testTiming.maxReconnectFailures) + advanceUntilIdle() - // The loop must have stopped on its own (job completed, not still running). - assertTrue(job.isCompleted || job.isCancelled) - // And the closed reason must signal connection_lost. - assertEquals("connection_lost", r.roomClosedReason.value) - // Verify the exact number of attempts so an off-by-one or wrong-constant - // regression is caught immediately. Expected: MAX_RECONNECT_ATTEMPTS = 6. - assertEquals(WatchTogetherRepository.MAX_RECONNECT_ATTEMPTS, realtime.connectCount) - // Stale room state must not be observable after giving up. + assertTrue(job.isCompleted) + assertEquals(WatchPartyEndReason.ConnectionLost, r.roomClosedReason.value) + assertTrue(testScheduler.currentTime >= testTiming.reconnectBudgetMs) assertNull(r.roomSnapshot.value) } @Test - fun `physical transport termination reconnects and counts toward the cap`() = runTest { + fun `physical terminations without a snapshot count as failures`() = runTest { val realtime = FakeRealtime().apply { terminateImmediately = true } val r = repo(realtime = realtime) - r.createRoom(CreateRoomRequest()) + r.createRoom(create()) val job = launch { r.connect("room-1") } advanceUntilIdle() assertTrue(job.isCompleted) - assertEquals(WatchTogetherRepository.MAX_RECONNECT_ATTEMPTS, realtime.connectCount) - assertEquals("connection_lost", r.roomClosedReason.value) + assertEquals(WatchPartyEndReason.ConnectionLost, r.roomClosedReason.value) assertTrue(!r.connectionState.value.writable) - assertEquals( - WatchTogetherRepository.MAX_RECONNECT_ATTEMPTS.toLong(), - r.connectionState.value.epoch, - ) + assertEquals(realtime.connectCount.toLong(), r.connectionState.value.epoch) } @Test - fun `snapshot plus thirty stable seconds resets the reconnect failure window`() = runTest { - var nowMs = 0L + fun `a flapping server that emits then drops still exhausts the budget`() = runTest { val realtime = FakeRealtime().apply { - connectBehavior = { attempt -> - when { - attempt <= 5 -> flow { throw IllegalStateException("early failure") } - attempt == 6 -> flow { - emit(RoomRealtimeEvent.Opened) - nowMs = WatchTogetherRepository.STABLE_CONNECTION_MS - emit(RoomRealtimeEvent.SnapshotEvent(snapshot())) - emit(RoomRealtimeEvent.TransportTerminated()) - } - attempt <= 10 -> flow { throw IllegalStateException("later failure") } - else -> events.asSharedFlow() - } - } + flappingEvent = RoomRealtimeEvent.SnapshotEvent(RoomSnapshot(roomId = "room-1", code = "ABCD1234")) } - val r = WatchTogetherRepository( - api = FakeApi(), - realtimeFactory = { realtime }, - monotonicNowMs = { nowMs }, - authScopeProvider = { scopeA }, - ) - r.createRoom(CreateRoomRequest()) - + val r = repo(realtime = realtime) + r.createRoom(create()) val job = launch { r.connect("room-1") } - advanceUntilIdle() + advanceTimeBy(testTiming.reconnectBudgetMs + testTiming.backoffMs.last() + 1) + runCurrent() - assertEquals(11, realtime.connectCount) - assertTrue(job.isActive) - assertNull(r.roomClosedReason.value) - job.cancel() + assertTrue(job.isCompleted) + assertEquals(WatchPartyEndReason.ConnectionLost, r.roomClosedReason.value) + assertNull(r.roomSnapshot.value) } @Test - fun `wrong room snapshot cannot reset the reconnect failure window`() = runTest { - var nowMs = 0L + fun `a wrong-room snapshot cannot count as a healthy connection`() = runTest { val realtime = FakeRealtime().apply { - connectBehavior = { attempt -> - if (attempt < WatchTogetherRepository.MAX_RECONNECT_ATTEMPTS) { - flow { throw IllegalStateException("early failure") } - } else { - flow { - emit(RoomRealtimeEvent.Opened) - nowMs = WatchTogetherRepository.STABLE_CONNECTION_MS - emit(RoomRealtimeEvent.SnapshotEvent(snapshot(roomId = "wrong-room"))) - emit(RoomRealtimeEvent.TransportTerminated()) - } + connectBehavior = { + flow { + emit(RoomRealtimeEvent.Opened) + kotlinx.coroutines.delay(testTiming.stableConnectionMs) + emit(RoomRealtimeEvent.SnapshotEvent(snapshot(roomId = "wrong-room"))) + emit(RoomRealtimeEvent.TransportTerminated()) } } } - val r = WatchTogetherRepository( - api = FakeApi(), - realtimeFactory = { realtime }, - monotonicNowMs = { nowMs }, - authScopeProvider = { scopeA }, - ) - r.createRoom(CreateRoomRequest()) + val r = repo(realtime = realtime) + r.createRoom(create()) val job = launch { r.connect("room-1") } advanceUntilIdle() assertTrue(job.isCompleted) - assertEquals(WatchTogetherRepository.MAX_RECONNECT_ATTEMPTS, realtime.connectCount) - assertEquals("connection_lost", r.roomClosedReason.value) + assertEquals(WatchPartyEndReason.ConnectionLost, r.roomClosedReason.value) } - // ---- flapping server hits the cap (regression for Issue 1) ----------------- + @Test + fun `a new membership starts without the previous room's clock samples`() = runTest { + val realtime = FakeRealtime() + val r = repo(realtime = realtime, timing = testTiming.copy(backgroundWork = true)) + r.createRoom(create()) + val job = launch { r.connect("room-1") } + runCurrent() + realtime.events.emit(RoomRealtimeEvent.Opened) + runCurrent() + realtime.events.emit( + RoomRealtimeEvent.Pong( + clientSentAt = "2026-09-24T18:00:00.000Z", + serverReceivedAt = "2026-09-24T18:00:00.240Z", + serverSentAt = "2026-09-24T18:00:00.241Z", + clientReceivedMs = parseMs("2026-09-24T18:00:00.081Z"), + ), + ) + runCurrent() + assertEquals(200L, r.clock.value.offsetMs) + job.cancel() + + r.reset() + assertNull(r.clock.value.offsetMs) + r.createRoom(create()) + assertNull(r.clock.value.offsetMs) + } @Test - fun `flapping server that emits then drops still hits the reconnect cap`() = runTest { - // Each connect attempt emits one healthy SnapshotEvent, then throws. - // Under the buggy code (failures reset per healthy event), this would loop - // forever because failures never accumulates to MAX_RECONNECT_ATTEMPTS. - // Under the correct code (failures reset only on clean completion), each - // throwing attempt still increments failures and the loop terminates. - val realtime = FakeRealtime().apply { - flappingEvent = RoomRealtimeEvent.SnapshotEvent( - RoomSnapshot(roomId = "room-1", code = "ABCD1234"), + fun `pings start when the socket opens and pongs update the clock`() = runTest { + val realtime = FakeRealtime() + val r = repo(realtime = realtime, timing = testTiming.copy(backgroundWork = true)) + r.createRoom(create()) + val job = launch { r.connect("room-1") } + runCurrent() + realtime.events.emit(RoomRealtimeEvent.Opened) + runCurrent() + assertEquals(1, realtime.pings) + + realtime.events.emit( + RoomRealtimeEvent.Pong( + clientSentAt = "2026-09-24T18:00:00.000Z", + serverReceivedAt = "2026-09-24T18:00:00.240Z", + serverSentAt = "2026-09-24T18:00:00.241Z", + clientReceivedMs = parseMs("2026-09-24T18:00:00.081Z"), + ), + ) + runCurrent() + assertEquals(200L, r.clock.value.offsetMs) + assertEquals(80L, r.clock.value.rttMs) + + advanceTimeBy(testTiming.initialPingSpacingMs * 2 + 1) + runCurrent() + assertEquals(3, realtime.pings) + job.cancel() + } + + @Test + fun `a pong folded after a wait keeps the clock estimate`() = runTest { + val realtime = FakeRealtime() + val r = repo(realtime = realtime, timing = testTiming.copy(backgroundWork = true)) + r.createRoom(create()) + val job = launch { r.connect("room-1") } + runCurrent() + realtime.events.emit(RoomRealtimeEvent.Opened) + runCurrent() + r.checkClockContinuity() + + // A 200 ms offset and an 80 ms round trip, received at [receivedWall]. + fun pong(receivedWall: Long) = RoomRealtimeEvent.Pong( + clientSentAt = org.prairieserver.prairie.util.formatEpochMillisRfc3339(receivedWall - 81), + serverReceivedAt = org.prairieserver.prairie.util.formatEpochMillisRfc3339(receivedWall + 159), + serverSentAt = org.prairieserver.prairie.util.formatEpochMillisRfc3339(receivedWall + 160), + clientReceivedMs = receivedWall, + ) + realtime.events.emit(pong(1_000_000L + testScheduler.currentTime)) + runCurrent() + assertEquals(200L, r.clock.value.offsetMs) + val generation = r.clock.value.generation + + // Decoded 1.5 s before it is folded: a wait, not a wall-clock jump. + realtime.events.emit(pong(1_000_000L + testScheduler.currentTime - 1_500)) + runCurrent() + r.checkClockContinuity() + + assertEquals(200L, r.clock.value.offsetMs) + assertEquals(generation, r.clock.value.generation) + job.cancel() + } + + // ---- suggestions ------------------------------------------------------------- + + @Test + fun `a coalesced suggestion read reports the shared read's failure`() = runTest { + val api = FakeApi().apply { listSuggestionsResult = CompletableDeferred() } + val r = repo(api = api) + r.createRoom(create()) + val first = async { r.refreshSuggestions() } + runCurrent() + val second = async { r.refreshSuggestions() } + val third = async { r.refreshSuggestions() } + runCurrent() + + api.listSuggestionsResult!!.complete(ApiResult.NetworkError(IllegalStateException("offline"))) + runCurrent() + + assertIs<ApiResult.NetworkError>(first.await()) + assertIs<ApiResult.NetworkError>(second.await()) + assertIs<ApiResult.NetworkError>(third.await()) + assertEquals(2, api.count("list")) + } + + @Test + fun `opening the socket hydrates suggestions off the receive path`() = runTest { + val api = FakeApi().apply { + listSuggestionsResponse = ApiResult.Success(SuggestionsResponse(listOf(suggestion("existing")))) + } + val realtime = FakeRealtime() + val repository = repo(api = api, realtime = realtime) + repository.createRoom(create()) + val connection = launch { repository.connect("room-1") } + runCurrent() + + realtime.events.emit(RoomRealtimeEvent.Opened) + runCurrent() + + assertEquals(1, api.count("list")) + assertEquals(listOf("existing"), repository.suggestions.value.map { it.id }) + assertTrue(repository.personalVotesKnown.value) + connection.cancel() + } + + @Test + fun `slow suggestion hydration does not block socket events`() = runTest { + val api = FakeApi().apply { listSuggestionsResult = CompletableDeferred() } + val realtime = FakeRealtime() + val repository = repo(api = api, realtime = realtime) + repository.createRoom(create()) + val connection = launch { repository.connect("room-1") } + runCurrent() + + realtime.events.emit(RoomRealtimeEvent.Opened) + realtime.events.emit(RoomRealtimeEvent.SnapshotEvent(snapshot(generation = 4, phase = RoomPhase.Playing))) + realtime.events.emit(RoomRealtimeEvent.Closed("host_left")) + runCurrent() + + assertEquals(WatchPartyEndReason.HostLeft, repository.roomClosedReason.value) + connection.cancel() + } + + @Test + fun `suggestion reads drain every page and rank by votes then age`() = runTest { + val api = FakeApi().apply { + listSuggestionPages = mutableListOf( + ApiResult.Success( + SuggestionsResponse( + listOf(suggestion("old", voteCount = 1, createdAt = "2026-06-12T08:00:00Z")), + SuggestionPageInfo(hasMore = true, nextCursor = "c1"), + ), + ), + ApiResult.Success( + SuggestionsResponse( + listOf( + suggestion("new", voteCount = 1, createdAt = "2026-06-12T09:00:00Z", votedByMe = true), + suggestion("top", voteCount = 3, createdAt = "2026-06-12T10:00:00Z"), + ), + ), + ), ) } - val r = repo(realtime = realtime) - r.createRoom(CreateRoomRequest()) + val r = repo(api = api) + r.createRoom(create()) + + assertIs<ApiResult.Success<*>>(r.refreshSuggestions()) + + assertEquals(listOf<String?>(null, "c1"), api.listCursors) + assertEquals(listOf("top", "old", "new"), r.suggestions.value.map { it.id }) + assertTrue(r.suggestions.value.last().votedByMe) + } + + @Test + fun `personal votes are unknown until an authenticated read`() = runTest { + val realtime = FakeRealtime() + val api = FakeApi().apply { listSuggestionsResult = CompletableDeferred() } + val r = repo(api = api, realtime = realtime) + r.createRoom(create()) val job = launch { r.connect("room-1") } - advanceUntilIdle() + runCurrent() + realtime.events.emit(RoomRealtimeEvent.Opened) + realtime.events.emit(RoomRealtimeEvent.SuggestionsEvent(listOf(suggestion("s1", voteCount = 2)))) + runCurrent() + + assertFalse(r.personalVotesKnown.value) + assertEquals(listOf("s1"), r.suggestions.value.map { it.id }) + job.cancel() + } + + @Test + fun `a socket broadcast during a read keeps the newer tallies`() = runTest { + val realtime = FakeRealtime() + val gate = CompletableDeferred<ApiResult<SuggestionsResponse>>() + val api = FakeApi() + val r = repo(api = api, realtime = realtime) + r.createRoom(create()) + val job = launch { r.connect("room-1") } + runCurrent() + api.listSuggestionsResult = gate + realtime.events.emit(RoomRealtimeEvent.Opened) + runCurrent() + + realtime.events.emit(RoomRealtimeEvent.SuggestionsEvent(listOf(suggestion("s1", voteCount = 5)))) + runCurrent() + gate.complete(ApiResult.Success(SuggestionsResponse(listOf(suggestion("s1", voteCount = 4, votedByMe = true))))) + runCurrent() + + val s1 = r.suggestions.value.single() + assertEquals(5, s1.voteCount) + assertTrue(s1.votedByMe) + job.cancel() + } + + @Test + fun `suggestions broadcasts keep known personal votes`() = runTest { + val realtime = FakeRealtime() + val api = FakeApi() + val r = repo(api = api, realtime = realtime) + r.createRoom(create()) + val job = launch { r.connect("room-1") } + runCurrent() + realtime.events.emit(RoomRealtimeEvent.Opened) + runCurrent() + + // The authenticated read after the vote reports it. + api.listSuggestionsResponse = ApiResult.Success( + SuggestionsResponse(listOf(suggestion("s1", voteCount = 1), suggestion("s2", voteCount = 2, votedByMe = true))), + ) + r.vote("s2") + realtime.events.emit( + RoomRealtimeEvent.SuggestionsEvent(listOf(suggestion("s1", voteCount = 1), suggestion("s2", voteCount = 2))), + ) + runCurrent() + + val byId = r.suggestions.value.associateBy { it.id } + assertTrue(byId.getValue("s2").votedByMe) + assertFalse(byId.getValue("s1").votedByMe) + + api.listSuggestionsResponse = ApiResult.Success(SuggestionsResponse(listOf(suggestion("s2", voteCount = 1)))) + r.unvote("s2") + realtime.events.emit(RoomRealtimeEvent.SuggestionsEvent(listOf(suggestion("s2", voteCount = 1)))) + runCurrent() + assertFalse(r.suggestions.value.single().votedByMe) + job.cancel() + } + + @Test + fun `a successful vote stays successful when the list read fails`() = runTest { + val api = FakeApi().apply { listSuggestionsResponse = ApiResult.NetworkError(RuntimeException("offline")) } + val r = repo(api = api) + r.createRoom(create()) + + val result = r.vote("s1") + + assertIs<ApiResult.Success<Unit>>(result) + assertEquals(1, api.count("vote")) + assertEquals(1, api.count("list")) + } + + @Test + fun `adding a suggestion keeps the caller id and reads the list afterward`() = runTest { + val api = FakeApi() + val r = repo(api = api) + r.createRoom(create()) + val request = AddSuggestionRequest(suggestionId = "s-new", contentId = "movie:a", contentType = "movie", title = "A") + + val result = r.addSuggestion(request) + + assertEquals("s-new", assertIs<ApiResult.Success<SuggestionReceipt>>(result).data.suggestionId) + assertEquals(listOf("suggest", "list"), api.calls.filter { it == "suggest" || it == "list" }) + } + + @Test + fun `rest refresh replaces the authoritative local vote set`() = runTest { + val api = FakeApi().apply { + listSuggestionsResponse = ApiResult.Success( + SuggestionsResponse(listOf(suggestion("removed-vote", votedByMe = true), suggestion("kept-vote", votedByMe = true))), + ) + } + val repository = repo(api = api) + repository.createRoom(create()) + + repository.refreshSuggestions() + api.listSuggestionsResponse = ApiResult.Success( + SuggestionsResponse(listOf(suggestion("removed-vote", votedByMe = false), suggestion("kept-vote", votedByMe = true))), + ) + repository.refreshSuggestions() + + val byId = repository.suggestions.value.associateBy { it.id } + assertFalse(byId.getValue("removed-vote").votedByMe) + assertTrue(byId.getValue("kept-vote").votedByMe) + } + + @Test + fun `a stale hydration cannot publish into a replacement room`() = runTest { + val gate = CompletableDeferred<ApiResult<SuggestionsResponse>>() + val api = FakeApi().apply { listSuggestionsResult = gate } + val r = repo(api = api) + r.createRoom(create()) + val stale = async { r.refreshSuggestions() } + runCurrent() - assertTrue(job.isCompleted || job.isCancelled) - assertEquals("connection_lost", r.roomClosedReason.value) - // Must have stopped after exactly MAX_RECONNECT_ATTEMPTS attempts. - assertEquals(WatchTogetherRepository.MAX_RECONNECT_ATTEMPTS, realtime.connectCount) - // Stale room state must not be observable after the cap is hit. + api.createResponse = room(RoomSnapshot(roomId = "room-2", code = "EFGH5678"), "jwt-room-2") + r.createRoom(CreateRoomRequest(roomId = "room-2")) + gate.complete(ApiResult.Success(SuggestionsResponse(listOf(suggestion("stale"))))) + + assertEquals("obsolete_room_request", assertIs<ApiResult.Error>(stale.await()).error) + assertTrue(r.suggestions.value.isEmpty()) + } + + @Test + fun `member state and picker reads use the current proof`() = runTest { + val api = FakeApi() + val r = repo(api = api) + r.createRoom(create()) + + r.memberState(listOf("movie:a")) + assertEquals("jwt-room", api.lastRoomToken) + r.picker() + assertEquals(listOf("memberState", "picker"), api.calls.takeLast(2)) + } + + // ---- reset ------------------------------------------------------------------- + + @Test + fun `reset clears room state and refuses member calls`() = runTest { + val api = FakeApi() + val realtime = FakeRealtime() + val r = repo(api = api, realtime = realtime) + r.createRoom(create()) + val job = launch { r.connect("room-1") } + runCurrent() + realtime.events.emit(RoomRealtimeEvent.SnapshotEvent(snapshot())) + realtime.events.emit(RoomRealtimeEvent.SuggestionsEvent(listOf(suggestion("s1")))) + runCurrent() + + r.reset() assertNull(r.roomSnapshot.value) + assertTrue(r.suggestions.value.isEmpty()) + assertNull(r.latestTransportCommand.value) + api.calls.clear() + assertIs<ApiResult.Error>(r.promoteSuggestion("s1")) + assertTrue(api.calls.isEmpty()) + job.cancel() + } + + @Test + fun `local delivery failure is surfaced as a non terminal notice`() = runTest { + val repository = repo() + val error = async { repository.errors.first() } + runCurrent() + + repository.reportDeliveryFailure("room_transport_unavailable") + + assertEquals("room_transport_unavailable", error.await()) + assertNull(repository.roomClosedReason.value) } + + @Test + fun `selection mode switch is a room action`() = runTest { + val api = FakeApi() + val r = repo(api = api) + r.createRoom(create()) + r.setSelectionMode(RoomSelectionMode.Vote) + assertEquals(1, api.count("mode")) + } + + private fun parseMs(value: String): Long = org.prairieserver.prairie.util.parseRfc3339ToEpochMillis(value)!! } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/CollectionsViewModelLoadOrderTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/CollectionsViewModelLoadOrderTest.kt new file mode 100644 index 0000000000..f6d7d914b3 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/CollectionsViewModelLoadOrderTest.kt @@ -0,0 +1,76 @@ +package org.prairieserver.prairie.viewmodel + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpHeaders +import io.ktor.http.headersOf +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withContext +import org.prairieserver.prairie.network.api.CollectionApi +import org.prairieserver.prairie.network.apiv2.ApiV2Gate +import org.prairieserver.prairie.repository.CollectionRepository +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class CollectionsViewModelLoadOrderTest { + + @Test + fun anOlderLoadDoesNotOverwriteTheRefreshThatReplacedIt() = runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val firstEntered = CompletableDeferred<Unit>() + val releaseFirst = CompletableDeferred<Unit>() + var calls = 0 + val client = HttpClient( + MockEngine { + val call = ++calls + val id = if (call == 1) { + firstEntered.complete(Unit) + releaseFirst.await() + "before" + } else { + "after" + } + respond( + """{"items":[{"id":"$id","name":"$id"}],"groups":[]}""", + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + }, + ) + val viewModel = CollectionsViewModel(CollectionRepository(CollectionApi(client, ApiV2Gate.Unrestricted))) + try { + withContext(Dispatchers.Default) { firstEntered.await() } + + // An access change refreshes while the initial load still waits on + // the server's answer under the old policy. + viewModel.refresh() + withContext(Dispatchers.Default) { + viewModel.uiState.first { state -> state.collections.map { it.id } == listOf("after") } + releaseFirst.complete(Unit) + delay(200) + } + + assertEquals(listOf("after"), viewModel.uiState.value.collections.map { it.id }) + } finally { + // Cancel AND join the VM's scope before Main is reset: a plain cancel() + // lets an in-flight Ktor call resume on a missing Main and leak an + // uncaught exception into whichever test runs next. + releaseFirst.complete(Unit) + viewModel.viewModelScope.coroutineContext[Job]?.cancelAndJoin() + client.close() + Dispatchers.resetMain() + } + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/DevicePairingServerChoiceTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/DevicePairingServerChoiceTest.kt new file mode 100644 index 0000000000..0260e6dde2 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/DevicePairingServerChoiceTest.kt @@ -0,0 +1,354 @@ +package org.prairieserver.prairie.viewmodel + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.prairieserver.prairie.model.auth.DeviceLoginDecisionResponse +import org.prairieserver.prairie.model.auth.DeviceLoginLookupResponse +import org.prairieserver.prairie.model.auth.DeviceLoginPollResponse +import org.prairieserver.prairie.model.auth.DeviceLoginStartResponse +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.api.DeviceLoginApi +import org.prairieserver.prairie.repository.DeviceLoginRepository +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * "Sign in a TV": a typed code is looked up on ONE chosen server (the active + * one preselected), never on every saved server; a miss says so and offers + * the others. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DevicePairingServerChoiceTest { + private val dispatcher = UnconfinedTestDispatcher() + + @BeforeTest + fun setUp() = Dispatchers.setMain(dispatcher) + + @AfterTest + fun tearDown() = Dispatchers.resetMain() + + private val home = DeviceApprovalServer("a", "https://home.example", "Home", isActive = true) + private val cabin = DeviceApprovalServer("b", "https://cabin.example", "Cabin") + + private class ScopedApi(private val codesOn: Map<String, String>) : DeviceLoginApi { + val lookedUpOn = mutableListOf<String>() + val approvedOn = mutableListOf<Pair<String, String>>() + var unscopedLookups = 0 + + /** What a lookup answers after this phone approved; "pending" until then. */ + var statusAfterApproval = "pending" + private var approved = false + + var onLookup: (String) -> Unit = {} + + /** Servers whose session is dead: approving there answers 401. */ + var signedOutOn: Set<String> = emptySet() + + /** Servers whose provider can't re-check the approver's session. */ + var providerDownOn: Set<String> = emptySet() + + override suspend fun lookupDeviceLoginForScope(scope: AuthScopeSnapshot, code: String): ApiResult<DeviceLoginLookupResponse> { + lookedUpOn += scope.serverId + onLookup(scope.serverId) + return if (codesOn[code] == scope.serverId) { + ApiResult.Success( + DeviceLoginLookupResponse( + status = if (approved) statusAfterApproval else "pending", + userCode = "4821-7730", + deviceName = "Living Room TV", + devicePlatform = "android-tv", + serverName = "Home", + ), + ) + } else { + ApiResult.Error(404, "not_found", "Device login request not found") + } + } + + override suspend fun approveDeviceLoginForScope(scope: AuthScopeSnapshot, code: String): ApiResult<DeviceLoginDecisionResponse> { + if (scope.serverId in signedOutOn) return ApiResult.Error(401, "unauthorized", "") + if (scope.serverId in providerDownOn) { + return ApiResult.NetworkError( + org.prairieserver.prairie.network.PrairieAuthUnavailableException( + org.prairieserver.prairie.network.PrairieAuthUnavailableException.PROVIDER_UNAVAILABLE, + ), + ) + } + approvedOn += scope.serverId to code + approved = true + return ApiResult.Success(DeviceLoginDecisionResponse("approved")) + } + + override suspend fun lookupDeviceLogin(token: String?, code: String?): ApiResult<DeviceLoginLookupResponse> { + unscopedLookups++ + return ApiResult.Error(404, "not_found", "Device login request not found") + } + override suspend fun approveDeviceLogin(token: String?, code: String?): ApiResult<DeviceLoginDecisionResponse> = error("unscoped") + override suspend fun denyDeviceLogin(token: String?, code: String?): ApiResult<DeviceLoginDecisionResponse> = error("unscoped") + override suspend fun startDeviceLogin(deviceName: String?, devicePlatform: String?): ApiResult<DeviceLoginStartResponse> = error("unused") + override suspend fun pollDeviceLogin(deviceCode: String): ApiResult<DeviceLoginPollResponse> = error("unused") + } + + /** Every account read, in order, with the lookups: the approver's refresh point. */ + private val events = mutableListOf<String>() + + /** The account each server's own scope reads; a missing entry is a failed read. */ + private val accounts = mutableMapOf("a" to "laura") + + /** Servers whose provider can't re-check the session the account read renews. */ + private val providerDownFor = mutableSetOf<String>() + + private fun servers(vararg list: DeviceApprovalServer) = object : DeviceApprovalServers { + override suspend fun list() = list.toList() + override fun scope(server: DeviceApprovalServer) = AuthScopeSnapshot(server.id, null, server.url, null) + override suspend fun accountName(server: DeviceApprovalServer): String? { + events += "account:${server.id}" + if (server.id in providerDownFor) { + throw org.prairieserver.prairie.network.PrairieAuthUnavailableException( + org.prairieserver.prairie.network.PrairieAuthUnavailableException.PROVIDER_UNAVAILABLE, + ) + } + return accounts[server.id] + } + } + + @Test + fun activeServerIsPreselectedAndTheCodeIsLookedUpThereOnly() = runTest { + val api = ScopedApi(mapOf("48217730" to "a")) + val vm = DevicePairingViewModel(DeviceLoginRepository(api), null, null, servers(cabin, home)) + assertEquals("a", vm.uiState.value.selectedServerId) + assertEquals("laura", vm.uiState.value.accountName) + + vm.onCodeChanged("4821 7730") + assertEquals("48217730", vm.uiState.value.code) + vm.lookup() + assertEquals(listOf("a"), api.lookedUpOn) + assertEquals("Living Room TV", vm.uiState.value.lookup?.deviceName) + + vm.approve() + assertEquals(listOf("a" to "48217730"), api.approvedOn) + assertEquals("approved", vm.uiState.value.completedStatus) + } + + /** Older servers issue letter codes; the phone's code field must still take them as typed. */ + @Test + fun aLetterCodeFromAnOlderServerIsTypedAndLookedUp() = runTest { + val api = ScopedApi(mapOf("ABCDEFGH" to "a")) + val vm = DevicePairingViewModel(DeviceLoginRepository(api), null, null, servers(cabin, home)) + vm.onCodeChanged("abcd-efgh") + assertEquals("ABCDEFGH", vm.uiState.value.code) + vm.lookup() + assertEquals(listOf("a"), api.lookedUpOn) + assertEquals("Living Room TV", vm.uiState.value.lookup?.deviceName) + } + + @Test + fun aMissSaysSoAndOffersTheOtherServers() = runTest { + val api = ScopedApi(mapOf("48217730" to "b")) + val vm = DevicePairingViewModel(DeviceLoginRepository(api), null, null, servers(home, cabin)) + vm.onCodeChanged("48217730") + vm.lookup() + assertEquals(listOf("a"), api.lookedUpOn, "never looked up on every server") + assertTrue(vm.uiState.value.notFound) + assertEquals(DevicePairingError.NotFoundOn("Home"), vm.uiState.value.error) + assertEquals(listOf(cabin), vm.uiState.value.otherServers) + + vm.selectServer("b") + assertEquals(listOf("a", "b"), api.lookedUpOn) + assertNull(vm.uiState.value.error) + assertEquals("Living Room TV", vm.uiState.value.lookup?.deviceName) + assertNull(vm.uiState.value.accountName) + } + + @Test + fun aLinkPreselectsItsServer() = runTest { + val api = ScopedApi(mapOf("48217730" to "b")) + val vm = DevicePairingViewModel(DeviceLoginRepository(api), null, "4821-7730", servers(home, cabin), initialServerId = "b") + assertEquals("b", vm.uiState.value.selectedServerId) + assertEquals(listOf("b"), api.lookedUpOn) + assertEquals("Living Room TV", vm.uiState.value.lookup?.deviceName) + } + + @Test + fun aTokenLinkUsesTheActiveServerWithoutAChooserOrOtherServers() = runTest { + val api = ScopedApi(emptyMap()) + val vm = DevicePairingViewModel(DeviceLoginRepository(api), "tok", null, servers(cabin, home), initialServerId = "b") + assertEquals("a", vm.uiState.value.selectedServerId, "the card names the server the token goes to") + assertEquals(false, vm.uiState.value.canChooseServer) + assertEquals(1, api.unscopedLookups) + assertEquals(DevicePairingError.NotFound, vm.uiState.value.error) + assertEquals(false, vm.uiState.value.notFound, "no 'Try <server>' for a token") + + vm.selectServer("b") + assertEquals("a", vm.uiState.value.selectedServerId) + assertEquals(1, api.unscopedLookups) + } + + @Test + fun afterApprovingTheResultFollowsHowTheRequestEnded() = runTest(StandardTestDispatcher()) { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + for ((status, outcome) in listOf( + "consumed" to DeviceFollowOutcome.SignedIn, + "canceled" to DeviceFollowOutcome.Canceled, + "expired" to DeviceFollowOutcome.Expired, + "denied" to DeviceFollowOutcome.Denied, + "approved" to DeviceFollowOutcome.TimedOut, + )) { + val api = ScopedApi(mapOf("48217730" to "a")).apply { statusAfterApproval = status } + val vm = DevicePairingViewModel(DeviceLoginRepository(api), null, "48217730", servers(home)) + advanceUntilIdle() + vm.approve() + runCurrent() + assertNull(vm.uiState.value.followOutcome, "signing in until the TV says otherwise") + advanceUntilIdle() + assertEquals(outcome, vm.uiState.value.followOutcome, status) + } + } + + @Test + fun aLinkNamingAnotherSavedServerApprovesThereOnly() = runTest { + accounts["b"] = "laura.cabin" + val api = ScopedApi(mapOf("48217730" to "b")) + val vm = DevicePairingViewModel( + DeviceLoginRepository(api), null, "48217730", servers(home, cabin), + initialServerId = "b", lockServer = true, + ) + assertEquals("b", vm.uiState.value.selectedServerId) + assertEquals(false, vm.uiState.value.canChooseServer, "the link named the server") + assertEquals(listOf("b"), api.lookedUpOn) + + vm.approve() + assertEquals(listOf("b" to "48217730"), api.approvedOn, "approved with that server's own scope") + assertEquals(0, api.unscopedLookups) + } + + @Test + fun aLockedServerThisPhoneIsntSignedInToAsksToSignInRatherThanTryingAnother() = runTest { + val api = ScopedApi(mapOf("48217730" to "a")) + val vm = DevicePairingViewModel( + DeviceLoginRepository(api), null, "48217730", servers(home), + initialServerId = "b", lockServer = true, + ) + assertEquals(DevicePairingError.SignInFirst, vm.uiState.value.error) + assertNull(vm.uiState.value.selectedServerId) + assertEquals(emptyList(), api.lookedUpOn, "never looked up on the active server instead") + assertEquals(0, api.unscopedLookups) + + vm.lookup() + vm.approve() + assertEquals(emptyList(), api.lookedUpOn) + assertEquals(emptyList(), api.approvedOn) + } + + @Test + fun theAccountIsReadBeforeEveryLookup() = runTest { + val api = ScopedApi(mapOf("48217730" to "b")) + api.onLookup = { events += "lookup:$it" } + val vm = DevicePairingViewModel(DeviceLoginRepository(api), null, null, servers(home, cabin)) + events.clear() + vm.onCodeChanged("48217730") + vm.lookup() + vm.selectServer("b") + assertEquals(listOf("account:a", "lookup:a", "account:b", "lookup:b"), events) + } + + @Test + fun anUnreadableAccountOnAnotherServerHoldsApprovalUntilARetryReadsIt() = runTest { + val api = ScopedApi(mapOf("48217730" to "b")) + val vm = DevicePairingViewModel( + DeviceLoginRepository(api), null, "48217730", servers(home, cabin), + initialServerId = "b", lockServer = true, + ) + assertEquals("Living Room TV", vm.uiState.value.lookup?.deviceName) + assertNull(vm.uiState.value.accountName) + assertTrue(vm.uiState.value.accountUnconfirmed, "the card says it couldn't confirm the account") + assertEquals(false, vm.uiState.value.canApprove) + assertEquals(true, vm.uiState.value.canDecide, "declining is still possible") + vm.approve() + assertEquals(emptyList(), api.approvedOn, "never approved for an account nobody saw") + + accounts["b"] = "laura.cabin" + vm.retryAccount() + assertEquals("laura.cabin", vm.uiState.value.accountName) + assertEquals(false, vm.uiState.value.accountUnconfirmed) + vm.approve() + assertEquals(listOf("b" to "48217730"), api.approvedOn) + } + + @Test + fun theActiveServersAccountNeedNotBeReadToApprove() = runTest { + accounts.clear() + val api = ScopedApi(mapOf("48217730" to "a")) + val vm = DevicePairingViewModel(DeviceLoginRepository(api), null, "48217730", servers(home, cabin)) + assertEquals(false, vm.uiState.value.accountUnconfirmed) + assertEquals(true, vm.uiState.value.canApprove) + } + + @Test + fun aSignInFirstOnTheChosenServerKeepsThatServerAndTheTypedCode() = runTest { + accounts["b"] = "laura.cabin" + val api = ScopedApi(mapOf("48217730" to "b")).apply { signedOutOn = setOf("b") } + val vm = DevicePairingViewModel(DeviceLoginRepository(api), null, null, servers(home, cabin)) + vm.selectServer("b") + vm.onCodeChanged("4821 7730") + vm.lookup() + vm.approve() + val state = vm.uiState.value + assertEquals(DevicePairingError.SignInFirst, state.error) + // What "Sign in" carries to the sign-in flow: that server, not the active one. + assertEquals("b", state.selectedServerId) + assertEquals("48217730", state.code) + } + + /** + * The sign-in provider couldn't re-check this phone's session when it + * renewed its bearer to approve: the session is kept and the person is + * told to try again, not to sign in again. + */ + @Test + fun aProviderOutageKeepsTheSessionAndSaysSo() = runTest { + val api = ScopedApi(mapOf("48217730" to "a")).apply { providerDownOn = setOf("a") } + val vm = DevicePairingViewModel(DeviceLoginRepository(api), null, null, servers(home)) + vm.onCodeChanged("4821 7730") + vm.lookup() + vm.approve() + assertEquals(DevicePairingError.ProviderUnavailable, vm.uiState.value.error) + assertEquals(emptyList(), api.approvedOn) + } + + /** + * The account read on another saved server renews its bearer first, and + * the provider couldn't re-check that session: the card says so rather + * than "couldn't confirm the account", and approving waits for a retry. + */ + @Test + fun aProviderOutageOnTheAccountReadSaysSo() = runTest { + providerDownFor += "b" + val api = ScopedApi(mapOf("48217730" to "b")) + val vm = DevicePairingViewModel( + DeviceLoginRepository(api), null, "48217730", servers(home, cabin), + initialServerId = "b", lockServer = true, + ) + assertTrue(vm.uiState.value.accountUnconfirmed) + assertEquals(DevicePairingError.ProviderUnavailable, vm.uiState.value.accountError) + assertEquals(false, vm.uiState.value.canApprove) + + providerDownFor.clear() + accounts["b"] = "laura.cabin" + vm.retryAccount() + assertNull(vm.uiState.value.accountError) + assertEquals(true, vm.uiState.value.canApprove) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/DevicePairingUiStateTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/DevicePairingUiStateTest.kt index 7d92ef1432..9b44ef3ee6 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/DevicePairingUiStateTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/DevicePairingUiStateTest.kt @@ -49,7 +49,7 @@ class DevicePairingUiStateTest { val state = DevicePairingUiState( token = "tok_abc", lookup = null, - error = "That code has expired.", + error = DevicePairingError.Expired, ) assertFalse(state.canDecide) } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeDismissalV2Test.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeDismissalV2Test.kt index 173ee140ba..5e6ec78946 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeDismissalV2Test.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeDismissalV2Test.kt @@ -68,7 +68,7 @@ class HomeDismissalV2Test { })) { install(ContentNegotiation) { json(PrairieJson) } } val store = ViewModelStore() try { - val repository = SectionRepository(SectionApi(client, home = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted))) + val repository = SectionRepository(SectionApi(client, home = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted, decodeDispatcher = dispatcher))) val vm = HomeViewModel(repository, MediaActionsCoordinator(PersonalDataRepository(PersonalDataApi(client)))) store.put("home", vm) runCurrent() diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeScreenV2Test.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeScreenV2Test.kt index 76e73c50dd..43966f0d28 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeScreenV2Test.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeScreenV2Test.kt @@ -21,6 +21,7 @@ import kotlin.test.* class HomeScreenV2Test { private var owner = AuthScopeSnapshot("s", "p", "https://example.invalid", "pin", identityGeneration = 1) private val tokens = object : TokenManager by TokenManagerImpl() { override suspend fun snapshotCurrentScope() = owner } + private val language = org.prairieserver.prairie.domain.settings.CatalogLanguageRevision() private var title = "Old" private var unresolved = false private var failFallback = false @@ -60,8 +61,8 @@ class HomeScreenV2Test { })) val store = ViewModelStore() try { - val repository = SectionRepository(SectionApi(client, home = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted))) - val vm = HomeViewModel(repository, MediaActionsCoordinator(PersonalDataRepository(PersonalDataApi(client))), cache, local) + val repository = SectionRepository(SectionApi(client, home = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted, decodeDispatcher = dispatcher))) + val vm = HomeViewModel(repository, MediaActionsCoordinator(PersonalDataRepository(PersonalDataApi(client))), cache, local, catalogLanguage = language) store.put("home", vm); block(vm) } finally { store.clear(); client.close(); Dispatchers.resetMain() } } @@ -96,4 +97,31 @@ class HomeScreenV2Test { gate.complete(Unit); runCurrent() assertEquals("New", vm.uiState.value.sections.single().items.single().title) } + @Test fun languageRefreshCompletesSupersededManualRefreshAndDrainsRealtime() = scenario { vm -> + runCurrent() + val gate = CompletableDeferred<Unit>() + var delayed = true + listHook = { if (delayed) { delayed = false; gate.await() } } + vm.refresh(); runCurrent() + assertTrue(vm.uiState.value.isRefreshing) + vm.refreshFromRealtime() + title = "Localized"; language.changed(); runCurrent() + assertFalse(vm.uiState.value.isRefreshing) + assertEquals(4, listReads) + gate.complete(Unit); runCurrent() + assertEquals("Localized", vm.uiState.value.sections.single().items.single().title) + } + + @Test fun newerLanguageCancelsOlderLanguageRead() = scenario { vm -> + runCurrent() + val gate = CompletableDeferred<Unit>() + var delayed = true + listHook = { if (delayed) { delayed = false; gate.await() } } + title = "First language"; language.changed(); runCurrent() + title = "Latest language"; language.changed(); runCurrent() + assertEquals("Latest language", vm.uiState.value.sections.single().items.single().title) + gate.complete(Unit); runCurrent() + assertEquals("Latest language", vm.uiState.value.sections.single().items.single().title) + } + } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModelCacheIdentityTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModelCacheIdentityTest.kt index 7c12674641..194a9e51db 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModelCacheIdentityTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModelCacheIdentityTest.kt @@ -77,7 +77,7 @@ class HomeViewModelCacheIdentityTest { val cache = RecordingHomeCache() val viewModel = HomeViewModel( sectionRepository = SectionRepository( - sectionApi = SectionApi(client, home = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted)), + sectionApi = SectionApi(client, home = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted, decodeDispatcher = dispatcher)), ), mediaActions = mediaActions(), homeCache = cache, @@ -107,7 +107,7 @@ class HomeViewModelCacheIdentityTest { } val observations = mutableListOf<HomeLoadObservation>() val viewModel = HomeViewModel( - sectionRepository = SectionRepository(SectionApi(client, home = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted))), + sectionRepository = SectionRepository(SectionApi(client, home = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted, decodeDispatcher = dispatcher))), mediaActions = mediaActions(), diagnostics = HomeDiagnosticsObserver(observations::add), ) @@ -125,6 +125,47 @@ class HomeViewModelCacheIdentityTest { assertEquals(listOf(0, 0), observations.map { it.sectionCount }) } + @Test + fun realtimeSignalsDuringARefreshRunOneMoreRefreshAfterIt() = runTest(dispatcher) { + var requests = 0 + val realtimeEntered = CompletableDeferred<Unit>() + val releaseRealtime = CompletableDeferred<Unit>() + val followUpEntered = CompletableDeferred<Unit>() + val client = HttpClient( + MockEngine { + when (++requests) { + 2 -> { + realtimeEntered.complete(Unit) + releaseRealtime.await() + } + 3 -> followUpEntered.complete(Unit) + } + respond( + """{"sections":[]}""", + HttpStatusCode.OK, + headersOf(HttpHeaders.ContentType, "application/json"), + ) + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + } + val viewModel = HomeViewModel( + sectionRepository = SectionRepository(SectionApi(client, home = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted, decodeDispatcher = dispatcher))), + mediaActions = mediaActions(), + ) + viewModel.uiState.first { !it.isLoading } + + viewModel.refreshFromRealtime() + realtimeEntered.await() + // An access change reported while that fetch is out: its answer may + // predate the change. Repeated signals collapse into one follow-up. + viewModel.refreshFromRealtime() + viewModel.refreshFromRealtime() + releaseRealtime.complete(Unit) + + followUpEntered.await() + } + private class RecordingHomeCache : HomeCachePort { var sections: List<ResolvedSection>? = null diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/RequestDetailStatusTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/RequestDetailStatusTest.kt new file mode 100644 index 0000000000..fb6f409725 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/RequestDetailStatusTest.kt @@ -0,0 +1,85 @@ +package org.prairieserver.prairie.viewmodel + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import org.prairieserver.prairie.model.request.AdminRequestAction +import org.prairieserver.prairie.model.request.RequestAttention +import org.prairieserver.prairie.model.request.RequestAvailability +import org.prairieserver.prairie.model.request.RequestDisplayState +import org.prairieserver.prairie.model.request.RequestMediaDetail +import org.prairieserver.prairie.model.request.RequestMediaType +import org.prairieserver.prairie.model.request.RequestOutcome +import org.prairieserver.prairie.model.request.RequestState +import org.prairieserver.prairie.model.request.RequestStatus +import org.prairieserver.prairie.model.request.RequestUserState +import org.prairieserver.prairie.model.request.record + +/** The detail page's status decisions (Apple `RequestDetailStatusTitleTests` / `RequestsV2Tests` parity). */ +class RequestDetailStatusTest { + + private val requestable = RequestMediaDetail( + mediaType = RequestMediaType.Movie, + tmdbId = 1, + title = "Film", + availability = RequestAvailability.Missing, + request = RequestState(requestable = true), + ) + + @Test + fun aFinishedDownloadTheTitleReadCallsRequestableStaysOnItsWay() { + // The server's per-title state skips a completed request that hasn't + // reached the library; the user's own record still knows. + val state = RequestDetailUiState( + detail = requestable, + record = record(status = RequestStatus.Completed, state = RequestUserState.Processing), + ) + assertEquals(RequestPrimaryAction.Status(RequestDisplayState.OnTheWay), state.primaryAction) + assertEquals("Adding to your library", state.primaryActionTitle) + } + + @Test + fun aFailedRequestBecomesThePageStatusAndTheRequesterCanAskAgain() { + val failed = record(status = RequestStatus.Downloading, outcome = RequestOutcome.Failed) + val requester = RequestDetailUiState(detail = requestable, record = failed) + assertEquals(RequestPrimaryAction.Request, requester.primaryAction) + assertEquals("Request Again", requester.primaryActionTitle) + assertEquals(RequestDisplayState.NeedsAttention(RequestAttention.Failed, null), requester.progress?.display) + + // An admin who can retry it gets Retry as the only action. + val admin = RequestDetailUiState(detail = requestable, moderationRecord = failed) + assertEquals(RequestPrimaryAction.Status(RequestDisplayState.NeedsAttention(RequestAttention.Failed, null)), admin.primaryAction) + assertEquals(listOf(AdminRequestAction.Retry), admin.moderationActions) + } + + @Test + fun anOldFailureStopsCountingOnceAnotherRequestIsUnderWay() { + val detail = requestable.copy( + request = RequestState(status = RequestStatus.Approved, requestable = false, requestId = "someone-else"), + ) + val state = RequestDetailUiState(detail = detail, record = record(id = "mine", outcome = RequestOutcome.Failed)) + assertNull(state.endedRequest) + assertEquals(RequestPrimaryAction.Status(RequestDisplayState.OnTheWay), state.primaryAction) + } + + @Test + fun pendingRequestsOfferCancelToTheRequesterAndDecisionsToTheAdmin() { + val pending = record(status = RequestStatus.Pending) + val detail = requestable.copy(request = RequestState(status = RequestStatus.Pending, requestId = pending.id)) + assertEquals(true, RequestDetailUiState(detail = detail, record = pending).canCancel) + val moderating = RequestDetailUiState(detail = detail, moderationRecord = pending, openedForModeration = true) + assertEquals(false, moderating.canCancel) + assertEquals(listOf(AdminRequestAction.Approve, AdminRequestAction.Decline), moderating.moderationActions) + assertEquals("Requested by someone on this server", moderating.eyebrow) + } + + @Test + fun aRequestableFailedAnnotationStillOffersRequestToTheRequester() { + // A card patched from the user's failed record carries its state but + // stays requestable; the page must not block on it. + val detail = requestable.copy( + request = RequestState(status = RequestStatus.Downloading, state = RequestUserState.Failed, requestable = true), + ) + assertEquals(RequestPrimaryAction.Request, RequestDetailUiState(detail = detail).primaryAction) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/RequestsViewModelTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/RequestsViewModelTest.kt index ccc91c8fb0..66d47cb70d 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/RequestsViewModelTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/RequestsViewModelTest.kt @@ -1,7 +1,10 @@ package org.prairieserver.prairie.viewmodel import androidx.lifecycle.viewModelScope +import org.prairieserver.prairie.model.request.AdminRequestAction +import org.prairieserver.prairie.model.request.AdminRequestCapabilities import org.prairieserver.prairie.model.request.CreateMediaRequest +import org.prairieserver.prairie.model.request.RequestDisplayState import org.prairieserver.prairie.model.request.MediaRequest import org.prairieserver.prairie.model.request.RequestAvailability import org.prairieserver.prairie.model.request.RequestDiscoverySection @@ -18,6 +21,7 @@ import org.prairieserver.prairie.model.request.RequestsListResponse import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.api.RequestsApi import org.prairieserver.prairie.repository.RequestsRepository +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -63,7 +67,7 @@ class RequestsViewModelTest { @Test - fun `requests view model loads status then discover sections`() = runTest(dispatcher) { + fun `requests view model loads discover sections and the user's requests`() = runTest(dispatcher) { // Server sections are per-media-type; the VM merges them into the // hub's two carousels (iOS RequestCarouselMerge parity). val section = RequestDiscoverySection( @@ -71,37 +75,26 @@ class RequestsViewModelTest { title = "Trending Movies", results = listOf(stubResult(tmdbId = 11, title = "The Signal")), ) + val failed = stubRequest(id = "r-failed", tmdbId = 70, title = "Broken", outcome = RequestOutcome.Failed) + val pending = stubRequest(id = "r-pending", tmdbId = 71, title = "Waiting") val api = FakeRequestsApi( - statusResult = ApiResult.Success(RequestsFeatureStatus(requestsEnabled = true)), discoverResult = ApiResult.Success(RequestsDiscoverResponse(listOf(section))), + mineResult = ApiResult.Success(RequestsListResponse(listOf(failed, pending))), ) val viewModel = track(RequestsViewModel(RequestsRepository(api))) val state = viewModel.uiState.value assertFalse(state.isLoading) - assertTrue(state.isEnabled) + assertTrue(state.hasLoaded) assertEquals(listOf("trending"), state.sections.map { it.key }) assertEquals("Trending now", state.sections.single().title) assertEquals(listOf(11), state.sections.single().results.map { it.tmdbId }) + // In progress before needs-you, matching the My Requests buckets. + assertEquals(listOf("r-pending", "r-failed"), state.myRequests.map { it.id }) + assertEquals(1, state.inProgressCount) + assertEquals(1, state.needsAttentionCount) assertNull(state.error) - assertEquals(listOf("status", "discover"), api.calls) - } - - @Test - fun `requests view model skips discover when requests are disabled`() = runTest(dispatcher) { - val api = FakeRequestsApi( - statusResult = ApiResult.Success(RequestsFeatureStatus(requestsEnabled = false)), - ) - - val viewModel = track(RequestsViewModel(RequestsRepository(api))) - val state = viewModel.uiState.value - - assertFalse(state.isLoading) - assertFalse(state.isEnabled) - assertTrue(state.sections.isEmpty()) - assertEquals("Requests are not enabled on this server.", state.error) - assertEquals(listOf("status"), api.calls) } @Test @@ -244,6 +237,7 @@ class RequestsViewModelTest { mediaType = RequestMediaType.Movie, tmdbId = 33, )) + assertEquals(RequestPrimaryAction.Request, viewModel.uiState.value.primaryAction) viewModel.submitRequest() val state = viewModel.uiState.value @@ -251,8 +245,9 @@ class RequestsViewModelTest { assertFalse(state.isLoading) assertFalse(state.isSubmitting) assertEquals(refreshedDetail, state.detail) - assertEquals("Request submitted.", state.notice) - assertNull(state.error) + assertEquals(1, state.submittedCount) + assertEquals(RequestPrimaryAction.Status(RequestDisplayState.Pending), state.primaryAction) + assertNull(state.actionErrorMessage) assertEquals( CreateMediaRequest( mediaType = RequestMediaType.Movie, @@ -266,16 +261,66 @@ class RequestsViewModelTest { ), api.createRequests.single(), ) + // The create patched the user's list, so the re-read reuses it. assertEquals( - listOf("detail:movie:33", "detail:movie:33"), + listOf("detail:movie:33", "mine", "detail:movie:33"), api.calls, ) } + @Test + fun `an unconfirmed create stays held until a read shows the request`() = runTest(dispatcher) { + val requestable = RequestMediaDetail( + mediaType = RequestMediaType.Movie, + tmdbId = 33, + title = "Deep Archive", + request = RequestState(requestable = true), + ) + val api = FakeRequestsApi( + // The first read, then the one right after the timeout: the server + // hasn't committed the create yet. + detailResults = ArrayDeque(listOf(ApiResult.Success(requestable), ApiResult.Success(requestable))), + createResult = ApiResult.NetworkError(IllegalStateException("timed out")), + ) + val viewModel = RequestDetailViewModel(RequestsRepository(api), RequestMediaType.Movie, 33) + + viewModel.submitRequest() + assertTrue(viewModel.uiState.value.isSubmissionUnconfirmed) + assertTrue(viewModel.uiState.value.primaryAction != RequestPrimaryAction.Request) + viewModel.submitRequest() + assertEquals(1, api.createRequests.size) + + // The hold lapses on its own. + dispatcher.scheduler.advanceTimeBy(61_000) + assertFalse(viewModel.uiState.value.isSubmissionUnconfirmed) + } + + @Test + fun `an approvals read sent before a decision does not bring the row back`() = runTest(dispatcher) { + val waiting = stubRequest(id = "r-1", tmdbId = 80, title = "Waiting") + val gate = CompletableDeferred<Unit>() + val api = FakeRequestsApi( + adminHandler = { status, _ -> + gate.await() + ApiResult.Success(RequestsListResponse(if (status == RequestStatus.Pending) listOf(waiting) else emptyList())) + }, + adminActionResult = ApiResult.Success(waiting.copy(status = RequestStatus.Approved)), + ) + val viewModel = RequestApprovalsViewModel(RequestsRepository(api), loadOnInit = false) + viewModel.refresh() + + viewModel.perform(AdminRequestAction.Approve, waiting) + dispatcher.scheduler.advanceTimeBy(1_000) + gate.complete(Unit) + dispatcher.scheduler.advanceUntilIdle() + + assertTrue(viewModel.uiState.value.awaitingApproval.isEmpty()) + assertEquals(listOf("r-1"), api.adminActionCalls) + } + @Test fun `requests view model falls back to default message for blank discover errors`() = runTest(dispatcher) { val api = FakeRequestsApi( - statusResult = ApiResult.Success(RequestsFeatureStatus(requestsEnabled = true)), discoverResult = ApiResult.Error(code = 500, error = "internal", message = ""), ) @@ -285,8 +330,8 @@ class RequestsViewModelTest { } @Test - fun `requests view model shows network copy when status request cannot reach the server`() = runTest(dispatcher) { - val api = FakeRequestsApi(statusResult = ApiResult.NetworkError(IllegalStateException("offline"))) + fun `requests view model shows network copy when discover cannot reach the server`() = runTest(dispatcher) { + val api = FakeRequestsApi(discoverResult = ApiResult.NetworkError(IllegalStateException("offline"))) val viewModel = track(RequestsViewModel(RequestsRepository(api))) @@ -296,7 +341,6 @@ class RequestsViewModelTest { @Test fun `requests view model refreshes and clears refreshing flag after discover`() = runTest(dispatcher) { val api = FakeRequestsApi( - statusResult = ApiResult.Success(RequestsFeatureStatus(requestsEnabled = true)), discoverResult = ApiResult.Success( RequestsDiscoverResponse( listOf( @@ -314,8 +358,9 @@ class RequestsViewModelTest { viewModel.refresh() assertFalse(viewModel.uiState.value.isRefreshing) - assertTrue(viewModel.uiState.value.isEnabled) - assertEquals(listOf("status", "discover", "status", "discover"), api.calls) + assertTrue(viewModel.uiState.value.hasLoaded) + assertEquals(2, api.calls.count { it == "discover" }) + assertEquals(2, api.calls.count { it == "mine" }) } @Test @@ -356,62 +401,6 @@ class RequestsViewModelTest { assertTrue(state.hasSubmittedQuery) } - @Test - fun `detail view model surfaces load and submit failures`() = runTest(dispatcher) { - val api = FakeRequestsApi( - detailResults = ArrayDeque( - listOf( - ApiResult.Error(404, "not_found", "Missing"), - ApiResult.Success(stubDetail()), - ), - ), - createResult = ApiResult.Error(409, "duplicate", ""), - ) - val viewModel = track(RequestDetailViewModel( - repository = RequestsRepository(api), - mediaType = RequestMediaType.Movie, - tmdbId = 77, - )) - - assertEquals("Missing", viewModel.uiState.value.error) - - viewModel.submitRequest() - assertEquals("Request details are not loaded.", viewModel.uiState.value.error) - - viewModel.load() - viewModel.submitRequest() - - assertFalse(viewModel.uiState.value.isSubmitting) - assertEquals("Failed to submit request", viewModel.uiState.value.error) - } - - @Test - fun `detail submit falls back to local request state when refresh fails`() = runTest(dispatcher) { - val detail = stubDetail() - val created = stubRequest(id = "request-77", tmdbId = 77, title = detail.title) - val api = FakeRequestsApi( - detailResults = ArrayDeque( - listOf( - ApiResult.Success(detail), - ApiResult.NetworkError(IllegalStateException("offline")), - ), - ), - createResult = ApiResult.Success(created), - ) - val viewModel = track(RequestDetailViewModel( - repository = RequestsRepository(api), - mediaType = RequestMediaType.Movie, - tmdbId = 77, - )) - - viewModel.submitRequest() - - val state = viewModel.uiState.value - assertEquals("request-77", state.detail?.request?.requestId) - assertFalse(state.detail?.request?.requestable ?: true) - assertEquals("Request submitted.", state.notice) - } - @Test fun `my requests view model stale load does not overwrite newer result`() = runTest(dispatcher) { val staleRequest = stubRequest(id = "stale-1", tmdbId = 55, title = "Stale Movie") @@ -461,32 +450,25 @@ class RequestsViewModelTest { val viewModel = track(MyRequestsViewModel(RequestsRepository(api))) assertEquals(listOf(pending), viewModel.uiState.value.requests) + api.mineResult = ApiResult.Success(RequestsListResponse(listOf(cancelled))) - viewModel.cancel("request-1") + viewModel.cancel(pending) val state = viewModel.uiState.value assertFalse(state.isLoading) - assertNull(state.actionInFlightId) - assertEquals(listOf(cancelled), state.requests) + assertNull(state.cancellingId) + // A cancelled request leaves My Requests. + assertTrue(state.requests.isEmpty()) assertNull(state.error) assertEquals(listOf("request-1"), api.cancelCalls) } @Test - fun `my requests view model reports load and cancel failures`() = runTest(dispatcher) { - val api = FakeRequestsApi( - mineResult = ApiResult.Error(500, "internal", ""), - cancelResult = ApiResult.NetworkError(IllegalStateException("offline")), - ) + fun `my requests view model reports load failures`() = runTest(dispatcher) { + val api = FakeRequestsApi(mineResult = ApiResult.Error(500, "internal", "")) val viewModel = track(MyRequestsViewModel(RequestsRepository(api))) assertEquals("Failed to load your requests", viewModel.uiState.value.error) - - viewModel.cancel("request-1") - - val state = viewModel.uiState.value - assertNull(state.actionInFlightId) - assertEquals("Network error. Check your connection.", state.error) } } @@ -507,7 +489,7 @@ private class FakeRequestsApi( ArrayDeque(listOf(ApiResult.NetworkError(IllegalStateException("no detail fake")))), private val createResult: ApiResult<MediaRequest> = ApiResult.NetworkError(IllegalStateException("no create fake")), - private val mineResult: ApiResult<RequestsListResponse> = + var mineResult: ApiResult<RequestsListResponse> = ApiResult.Success(RequestsListResponse()), private val cancelResult: ApiResult<MediaRequest> = ApiResult.NetworkError(IllegalStateException("no cancel fake")), @@ -522,12 +504,15 @@ private class FakeRequestsApi( limit: Int?, offset: Int?, ) -> ApiResult<RequestsListResponse>)? = null, + private val adminHandler: (suspend (status: String?, outcome: String?) -> ApiResult<RequestsListResponse>)? = null, + private val adminActionResult: ApiResult<MediaRequest> = ApiResult.Error(403, "forbidden", ""), ) : RequestsApi { val calls = mutableListOf<String>() val searchCalls = mutableListOf<SearchCall>() val createRequests = mutableListOf<CreateMediaRequest>() val cancelCalls = mutableListOf<String>() + val adminActionCalls = mutableListOf<String>() override suspend fun status(): ApiResult<RequestsFeatureStatus> { calls += "status" @@ -579,6 +564,21 @@ private class FakeRequestsApi( cancelCalls += id return cancelResult } + + override suspend fun adminCapabilities(): ApiResult<AdminRequestCapabilities> = + ApiResult.Error(403, "forbidden", "") + + override suspend fun adminRequests( + status: String?, + outcome: String?, + mediaType: String?, + tmdbId: Int?, + ): ApiResult<RequestsListResponse> = adminHandler?.invoke(status, outcome) ?: ApiResult.Error(403, "forbidden", "") + + override suspend fun adminAction(id: String, action: AdminRequestAction, reason: String?): ApiResult<MediaRequest> { + adminActionCalls += id + return adminActionResult + } } private fun stubResult( @@ -609,15 +609,3 @@ private fun stubRequest( createdAt = "2026-06-01T00:00:00Z", updatedAt = "2026-06-01T00:00:00Z", ) - -private fun stubDetail(): RequestMediaDetail = RequestMediaDetail( - mediaType = RequestMediaType.Movie, - tmdbId = 77, - imdbId = "tt77", - title = "Deep Archive", - year = 2025, - overview = "Buried media returns.", - posterPath = "/poster.jpg", - backdropPath = "/backdrop.jpg", - request = RequestState(requestable = true), -) diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/WatchPartySpoilersTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/WatchPartySpoilersTest.kt new file mode 100644 index 0000000000..61fb21ed10 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/WatchPartySpoilersTest.kt @@ -0,0 +1,80 @@ +package org.prairieserver.prairie.viewmodel + +import org.prairieserver.prairie.model.catalog.BrowseItem +import org.prairieserver.prairie.model.catalog.ItemDetail +import org.prairieserver.prairie.model.catalog.LeafItemUserData +import org.prairieserver.prairie.model.catalog.MediaItemUserState +import org.prairieserver.prairie.model.settings.EpisodeSpoilerPrefs +import org.prairieserver.prairie.model.watchtogether.Suggestion +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WatchPartySpoilersTest { + private val enabled = EpisodeSpoilerPrefs(hideImages = true, hideOverviews = true) + private val episode = ItemDetail(contentId = "episode-1", type = "episode", title = "Pilot", backdropUrl = "still.jpg") + + @Test + fun anUnstartedEpisodeHidesItsArtworkAndOverview() { + val spoilers = WatchPartySpoilers.of(episode) + + assertTrue(spoilers.hidesPoster(enabled)) + assertTrue(spoilers.hidesBackdrop(enabled, episode.backdropUrl)) + assertTrue(spoilers.hidesOverview(enabled)) + assertFalse(spoilers.hidesPoster(EpisodeSpoilerPrefs.NONE)) + assertFalse(spoilers.hidesOverview(EpisodeSpoilerPrefs.NONE)) + } + + @Test + fun aStartedEpisodeStaysVisible() { + listOf( + LeafItemUserData(played = true), + LeafItemUserData(isInProgress = true), + LeafItemUserData(positionSeconds = 30.0), + ).forEach { state -> + val spoilers = WatchPartySpoilers.of(episode.copy(userData = state)) + assertFalse(spoilers.hidesPoster(enabled)) + assertFalse(spoilers.hidesBackdrop(enabled, episode.backdropUrl)) + assertFalse(spoilers.hidesOverview(enabled)) + } + } + + @Test + fun eachArtworkSlotUsesItsOwnProvenance() { + val spoilers = WatchPartySpoilers.of(episode.copy(posterIsEpisodeStill = false, backdropIsEpisodeStill = true)) + + assertFalse(spoilers.hidesPoster(enabled)) + assertTrue(spoilers.hidesBackdrop(enabled, "still.jpg")) + // Without a backdrop the slot shows the poster, series art here. + assertFalse(spoilers.hidesBackdrop(enabled, null)) + } + + @Test + fun aBrowseItemStartedByThisProfileStaysVisible() { + val item = BrowseItem(contentId = "episode-1", type = "episode", title = "Pilot") + + assertTrue(WatchPartySpoilers.of(item).hidesPoster(enabled)) + assertFalse(WatchPartySpoilers.of(item.copy(positionSeconds = 12.0)).hidesPoster(enabled)) + assertFalse(WatchPartySpoilers.of(item.copy(userState = MediaItemUserState(played = true))).hidesPoster(enabled)) + } + + @Test + fun onlyEpisodesAreEverProtected() { + val movie = ItemDetail(contentId = "movie-1", type = "movie", title = "Film") + + assertFalse(WatchPartySpoilers.of(movie).hidesPoster(enabled)) + assertFalse(WatchPartySpoilers.of(movie).hidesOverview(enabled)) + assertTrue(WatchPartySpoilers.of(suggestion("episode")).hidesPoster(enabled)) + assertFalse(WatchPartySpoilers.of(suggestion("movie")).hidesPoster(enabled)) + } + + @Test + fun anItemWithoutKnownStateIsProtectedByType() { + assertTrue(WatchPartyItem("episode-1", "episode", "Pilot").spoilers.hidesPoster(enabled)) + assertFalse(WatchPartyItem("movie-1", "movie", "Film").spoilers.hidesPoster(enabled)) + } + + private fun suggestion(type: String) = Suggestion( + id = type, roomId = "room", contentId = type, contentType = type, title = "Pick", createdAt = "2026-10-10T00:00:00Z", + ) +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/WatchPartyViewModelsTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/WatchPartyViewModelsTest.kt new file mode 100644 index 0000000000..7af8b3be93 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/WatchPartyViewModelsTest.kt @@ -0,0 +1,405 @@ +package org.prairieserver.prairie.viewmodel + +import org.prairieserver.prairie.model.catalog.BrowseItem +import org.prairieserver.prairie.model.watchtogether.AddSuggestionRequest +import org.prairieserver.prairie.model.watchtogether.CreateRoomRequest +import org.prairieserver.prairie.model.watchtogether.JoinRoomRequest +import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.model.watchtogether.MemberStateRequest +import org.prairieserver.prairie.model.watchtogether.MemberStateResponse +import org.prairieserver.prairie.model.watchtogether.PickerEntry +import org.prairieserver.prairie.model.watchtogether.PickerResponse +import org.prairieserver.prairie.model.watchtogether.PromoteSuggestionRequest +import org.prairieserver.prairie.model.watchtogether.RoomMember +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.RoomResponse +import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.SelectionModeRequest +import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest +import org.prairieserver.prairie.model.watchtogether.SourceFallbackRequest +import org.prairieserver.prairie.model.watchtogether.Suggestion +import org.prairieserver.prairie.model.watchtogether.SuggestionReceipt +import org.prairieserver.prairie.model.watchtogether.SuggestionsResponse +import org.prairieserver.prairie.model.watchtogether.UpdatePolicyRequest +import org.prairieserver.prairie.model.watchtogether.WatchTogetherCapabilitiesV2 +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.api.WatchTogetherApi +import org.prairieserver.prairie.network.apiv2.PlaybackCapabilitiesV2 +import org.prairieserver.prairie.repository.WatchPartyTiming +import org.prairieserver.prairie.repository.WatchTogetherRepository +import org.prairieserver.prairie.watchtogether.RecentPartyOwner +import org.prairieserver.prairie.watchtogether.RecentWatchParties +import org.prairieserver.prairie.watchtogether.RecentWatchPartyStorage +import org.prairieserver.prairie.watchtogether.RoomSession +import org.prairieserver.prairie.watchtogether.WatchPartyAvailabilityRepository +import org.prairieserver.prairie.watchtogether.WatchPartyDestination +import org.prairieserver.prairie.watchtogether.WatchPartyInvite +import org.prairieserver.prairie.watchtogether.WatchPartyPlaybackFeatures +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.withContext +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class WatchPartyViewModelsTest { + private val dispatcher = UnconfinedTestDispatcher() + + @BeforeTest + fun setUp() = Dispatchers.setMain(dispatcher) + + @AfterTest + fun tearDown() = Dispatchers.resetMain() + + private val scope = AuthScopeSnapshot("server", "profile", "https://prairie.example", profileToken = null, identityGeneration = 1) + + private class Api : WatchTogetherApi { + val creates = mutableListOf<CreateRoomRequest>() + val joins = mutableListOf<JoinRoomRequest>() + val calls = mutableListOf<String>() + var createResults = ArrayDeque<ApiResult<RoomResponse>>() + var joinResult: ApiResult<RoomResponse>? = null + var room = RoomSnapshot(roomId = "room-1", code = "K7PQ2M4X", phase = RoomPhase.Lobby, selectionMode = RoomSelectionMode.HostPick, selfRole = MemberRole.Host, selfCanManageRoom = true) + var createGate: CompletableDeferred<Unit>? = null + var pickerResponse = PickerResponse() + + private fun ok(snapshot: RoomSnapshot = room) = ApiResult.Success(RoomResponse(snapshot, "proof")) + + override suspend fun capabilities(scope: AuthScopeSnapshot) = ApiResult.Success(WatchTogetherCapabilitiesV2()) + override suspend fun createRoom(request: CreateRoomRequest, scope: AuthScopeSnapshot): ApiResult<RoomResponse> { + creates += request + createGate?.await() + return createResults.removeFirstOrNull() ?: ok(room.copy(roomId = request.roomId)) + } + override suspend fun joinRoom(request: JoinRoomRequest, scope: AuthScopeSnapshot): ApiResult<RoomResponse> { + joins += request + return joinResult ?: ok(room.copy(selfRole = MemberRole.Guest, selfCanManageRoom = false)) + } + override suspend fun getRoom(roomId: String, roomToken: String, scope: AuthScopeSnapshot) = ok() + override suspend fun updatePolicy(roomId: String, request: UpdatePolicyRequest, scope: AuthScopeSnapshot) = ok() + override suspend fun stageSelection(roomId: String, request: SetSelectionRequest, scope: AuthScopeSnapshot) = + ok(room.copy(roomId = roomId, selectedContentId = request.contentId, generation = room.generation + 1)).also { calls += "stage:${request.contentId}" } + override suspend fun startPlayback(roomId: String, scope: AuthScopeSnapshot) = ok().also { calls += "start" } + override suspend fun stopPlayback(roomId: String, scope: AuthScopeSnapshot) = ok() + override suspend fun setSelectionMode(roomId: String, request: SelectionModeRequest, scope: AuthScopeSnapshot) = ok() + override suspend fun setSelection(roomId: String, request: SetSelectionRequest, scope: AuthScopeSnapshot) = + ok().also { calls += "select" } + override suspend fun closeRoom(roomId: String, scope: AuthScopeSnapshot) = ApiResult.Success(Unit) + override suspend fun sourceFallback(roomId: String, roomToken: String, request: SourceFallbackRequest, scope: AuthScopeSnapshot) = ok() + override suspend fun listSuggestions(roomId: String, roomToken: String, scope: AuthScopeSnapshot, cursor: String?, limit: Int) = + ApiResult.Success(SuggestionsResponse()) + override suspend fun addSuggestion(roomId: String, roomToken: String, request: AddSuggestionRequest, scope: AuthScopeSnapshot) = + ApiResult.Success(SuggestionReceipt(request.suggestionId)) + override suspend fun deleteSuggestion(roomId: String, roomToken: String, suggestionId: String, scope: AuthScopeSnapshot) = ApiResult.Success(Unit) + override suspend fun vote(roomId: String, roomToken: String, suggestionId: String, scope: AuthScopeSnapshot) = ApiResult.Success(Unit) + override suspend fun unvote(roomId: String, roomToken: String, suggestionId: String, scope: AuthScopeSnapshot) = ApiResult.Success(Unit) + override suspend fun promoteSuggestion(roomId: String, roomToken: String, request: PromoteSuggestionRequest, scope: AuthScopeSnapshot) = + ok().also { calls += "promote" } + override suspend fun memberState(roomId: String, roomToken: String, request: MemberStateRequest, scope: AuthScopeSnapshot) = + ApiResult.Success(MemberStateResponse()) + override suspend fun picker(roomId: String, roomToken: String, scope: AuthScopeSnapshot) = ApiResult.Success(pickerResponse) + } + + private class Storage : RecentWatchPartyStorage { + var value: String? = null + override fun read() = value + override fun write(value: String?) { this.value = value } + } + + private inner class Fixture(testScope: TestScope) { + val api = Api() + val repository = WatchTogetherRepository( + api = api, + authScopeProvider = { scope }, + timing = WatchPartyTiming(backgroundWork = false), + ) + val session = RoomSession(repository, testScope.backgroundScope, DefaultIdentityTransitionBarrier()) + val availability = WatchPartyAvailabilityRepository( + roomCapabilities = { + ApiResult.Success( + WatchTogetherCapabilitiesV2( + state = "available", allowed = true, stagedSelection = true, connectionReplaced = true, + socketProtocol = "prairie.room.v2", stopPlayback = true, selectionModeSwitch = true, + voteHostOverride = true, picker = true, + ), + ) + }, + playbackCapabilities = { + ApiResult.Success( + PlaybackCapabilitiesV2( + state = "available", allowed = true, protocolVersions = listOf(3), + features = listOf(WatchPartyPlaybackFeatures.Coordinator, WatchPartyPlaybackFeatures.FixedMediaFile), + ), + ) + }, + authScopeProvider = { scope }, + ) + val recents = RecentWatchParties(Storage(), owner = { RecentPartyOwner("server", "login", "profile") }, nowEpochMs = { 1L }) + + fun hub() = WatchPartyHubViewModel(repository, session, availability, recents) { it == "https://prairie.example" } + } + + @Test + fun `hosting creates a host-picks room and opens its lobby`() = runTest(dispatcher) { + val f = Fixture(this) + val hub = f.hub() + runCurrent() + + hub.host() + runCurrent() + + assertEquals("host_pick", f.api.creates.single().selectionMode) + assertIs<WatchPartyDestination.Lobby>(hub.uiState.value.destination) + } + + @Test + fun `hosting from a title stages it without starting playback`() = runTest(dispatcher) { + val f = Fixture(this) + val hub = f.hub() + runCurrent() + + hub.hostWithItem(WatchPartyItem("movie:heat", "movie", "Heat", fileId = 42, libraryId = 7)) + runCurrent() + + assertEquals(listOf("stage:movie:heat"), f.api.calls) + assertIs<WatchPartyDestination.Lobby>(hub.uiState.value.destination) + } + + @Test + fun `an uncertain create is retried with the same room identity`() = runTest(dispatcher) { + val f = Fixture(this) + f.api.createResults.addLast(ApiResult.NetworkError(RuntimeException("reset"))) + val hub = f.hub() + runCurrent() + + hub.host() + runCurrent() + assertTrue(hub.uiState.value.createRetryable) + hub.retryCreate() + runCurrent() + + assertEquals(2, f.api.creates.size) + assertEquals(f.api.creates[0].roomId, f.api.creates[1].roomId) + assertNotNull(hub.uiState.value.destination) + } + + @Test + fun `codes are normalized and foreign-server links never send the token`() = runTest(dispatcher) { + val f = Fixture(this) + val hub = f.hub() + runCurrent() + + hub.join("k7pq-2m4x") + runCurrent() + assertEquals(JoinRoomRequest(code = "K7PQ2M4X"), f.api.joins.single()) + + f.repository.reset() + hub.joinInvite(WatchPartyInvite.Link("https://other.example", "secret-token")) + runCurrent() + assertEquals(1, f.api.joins.size) + assertTrue(hub.uiState.value.error!!.contains("different Prairie server")) + } + + @Test + fun `joining while already in a party asks to leave first`() = runTest(dispatcher) { + val f = Fixture(this) + val hub = f.hub() + runCurrent() + hub.join("K7PQ2M4X") + runCurrent() + + hub.join("ABCD2345") + runCurrent() + + assertEquals(1, f.api.joins.size) + assertTrue(hub.uiState.value.error!!.contains("Leave it first")) + } + + @Test + fun `an unknown code says so`() = runTest(dispatcher) { + val f = Fixture(this) + f.api.joinResult = ApiResult.Error(404, "not_found", "The room or suggestion was not found.") + val hub = f.hub() + runCurrent() + + hub.join("K7PQ2M4X") + runCurrent() + + assertEquals("No Watch Party uses that code.", hub.uiState.value.error) + assertNull(hub.uiState.value.destination) + } + + @Test + fun `the lobby opens the player only when the room plays`() = runTest(dispatcher) { + val f = Fixture(this) + f.api.room = f.api.room.copy(selectedContentId = "movie:heat") + f.repository.createRoom(CreateRoomRequest(roomId = "room-1")) + val lobby = WatchPartyLobbyViewModel("room-1", f.repository, f.session, f.availability) + runCurrent() + assertEquals(WatchPartyDestination.Lobby("room-1"), lobby.destination.value) + + f.api.room = f.api.room.copy(phase = RoomPhase.Playing, generation = 5) + lobby.start() + runCurrent() + assertEquals(WatchPartyDestination.Player("room-1"), lobby.destination.value) + } + + @Test + fun `queueing a suggestion stages it and never promotes`() = runTest(dispatcher) { + val f = Fixture(this) + f.repository.createRoom(CreateRoomRequest(roomId = "room-1")) + val lobby = WatchPartyLobbyViewModel("room-1", f.repository, f.session, f.availability) + runCurrent() + + lobby.queue(Suggestion(id = "s", roomId = "room-1", contentId = "movie:a", contentType = "movie", title = "A", createdAt = "")) + runCurrent() + + assertEquals(listOf("stage:movie:a"), f.api.calls) + } + + @Test + fun `lobby eligibility follows role and phase`() = runTest(dispatcher) { + val f = Fixture(this) + f.api.room = f.api.room.copy( + selectedContentId = "movie:a", + members = listOf(RoomMember(userId = "1", profileId = "profile", isHost = true, isSelf = true, connected = true)), + ) + f.repository.createRoom(CreateRoomRequest(roomId = "room-1")) + val lobby = WatchPartyLobbyViewModel("room-1", f.repository, f.session, f.availability) + runCurrent() + + val host = lobby.state.value.eligibility + assertTrue(host.canStart && host.canStage && host.canSwitchMode && host.canEnd) + assertTrue(!host.canLobbyReady && !host.canStop) + } + + @Test + fun `shared picker rows exclude nonvideo titles`() = runTest(dispatcher) { + val f = Fixture(this) + f.repository.createRoom(CreateRoomRequest(roomId = "room-1")) + val movie = PickerEntry(BrowseItem("movie:a", "movie", "Movie")) + val series = PickerEntry(BrowseItem("series:a", "series", "Series")) + val episode = PickerEntry(BrowseItem("episode:a", "episode", "Episode")) + val ebook = PickerEntry(BrowseItem("ebook:a", "ebook", "Book")) + val audiobook = PickerEntry(BrowseItem("audiobook:a", "audiobook", "Audiobook")) + f.api.pickerResponse = PickerResponse( + continueTogether = listOf(ebook, series, movie), + watchlistUnion = listOf(audiobook, movie, ebook, episode), + ) + val picker = WatchPartyPickerViewModel(f.repository, f.availability) { ApiResult.Success(emptyList()) } + + picker.loadRows() + runCurrent() + + val rows = assertNotNull(picker.state.value.rows) + assertEquals(listOf(series, movie), rows.continueTogether) + assertEquals(listOf(movie, episode), rows.watchlistUnion) + } + + @Test + fun `new picker input rejects the previous result before debounce expires`() = runTest(dispatcher) { + val f = Fixture(this) + val oldResult = CompletableDeferred<ApiResult<List<BrowseItem>>>() + val newItem = BrowseItem("movie:new", "movie", "New") + val calls = mutableListOf<String>() + val picker = WatchPartyPickerViewModel(f.repository, f.availability) { query -> + calls += query + if (query == "old") withContext(NonCancellable) { oldResult.await() } + else ApiResult.Success(listOf(newItem)) + } + picker.onQuery("old") + advanceTimeBy(301) + runCurrent() + assertEquals(listOf("old"), calls) + + picker.onQuery("new") + oldResult.complete(ApiResult.Success(listOf(BrowseItem("movie:old", "movie", "Old")))) + runCurrent() + assertTrue(picker.state.value.results.isEmpty()) + assertTrue(picker.state.value.searching) + + advanceTimeBy(301) + runCurrent() + assertEquals(listOf("old", "new"), calls) + assertEquals(listOf(newItem), picker.state.value.results) + assertFalse(picker.state.value.searching) + } + + @Test + fun `new picker input cancels an unfinished search`() = runTest(dispatcher) { + val f = Fixture(this) + var cancelled = false + val calls = mutableListOf<String>() + val picker = WatchPartyPickerViewModel(f.repository, f.availability) { query -> + calls += query + if (query == "old") { + try { + awaitCancellation() + } finally { + cancelled = true + } + } + ApiResult.Success(emptyList()) + } + picker.onQuery("old") + advanceTimeBy(301) + runCurrent() + assertEquals(listOf("old"), calls) + + picker.onQuery("new") + runCurrent() + assertTrue(cancelled) + advanceTimeBy(301) + runCurrent() + assertEquals(listOf("old", "new"), calls) + assertFalse(picker.state.value.searching) + } + + @Test + fun `clearing picker input removes results and ignores a pending failure`() = runTest(dispatcher) { + val f = Fixture(this) + val lateResult = CompletableDeferred<ApiResult<List<BrowseItem>>>() + val picker = WatchPartyPickerViewModel(f.repository, f.availability) { query -> + if (query == "first") ApiResult.Success(listOf(BrowseItem("movie:first", "movie", "First"))) + else withContext(NonCancellable) { lateResult.await() } + } + picker.onQuery("first") + advanceTimeBy(301) + runCurrent() + assertEquals(1, picker.state.value.results.size) + + picker.onQuery("pending") + advanceTimeBy(301) + runCurrent() + assertTrue(picker.state.value.searching) + + picker.onQuery("") + assertTrue(picker.state.value.results.isEmpty()) + assertFalse(picker.state.value.searching) + lateResult.complete(ApiResult.NetworkError(RuntimeException("late failure"))) + runCurrent() + assertNull(picker.state.value.error) + assertTrue(picker.state.value.results.isEmpty()) + assertFalse(picker.state.value.searching) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomCatchupTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomCatchupTest.kt new file mode 100644 index 0000000000..f561d8e940 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomCatchupTest.kt @@ -0,0 +1,79 @@ +package org.prairieserver.prairie.watchtogether + +import org.prairieserver.prairie.model.watchtogether.TransportAction +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class RoomCatchupTest { + private fun decide(action: TransportAction, target: Double, local: Double, reachable: Boolean) = + RoomCatchup.decide(action, target, local, reachable) + + @Test + fun `explicit seeks always seek`() { + assertEquals(RoomCatchup.Decision.Seek, decide(TransportAction.Seek, 10.0, 10.0, reachable = false)) + } + + @Test + fun `drift inside the deadband needs nothing`() { + assertEquals(RoomCatchup.Decision.None, decide(TransportAction.Play, 10.3, 10.0, reachable = false)) + } + + @Test + fun `a locally reachable target is a seek`() { + assertEquals(RoomCatchup.Decision.Seek, decide(TransportAction.Play, 11.5, 10.0, reachable = true)) + assertEquals(RoomCatchup.Decision.Seek, decide(TransportAction.Pause, 11.5, 10.0, reachable = true)) + } + + @Test + fun `an unreachable pause target is left for the next play`() { + assertEquals(RoomCatchup.Decision.None, decide(TransportAction.Pause, 15.0, 10.0, reachable = false)) + } + + @Test + fun `small unreachable drift converges by a bounded rate and large drift seeks`() { + assertEquals(RoomCatchup.Decision.Rate(1.125), decide(TransportAction.Play, 11.0, 10.0, reachable = false)) + assertEquals(RoomCatchup.Decision.Rate(0.9), decide(TransportAction.Play, 8.5, 10.0, reachable = false)) + assertEquals(RoomCatchup.Decision.Seek, decide(TransportAction.Play, 12.5, 10.0, reachable = false)) + assertEquals(RoomCatchup.MAX_RATE, RoomCatchup.rateFor(10.0)) + } + + @Test + fun `convergence tracks the advancing room position`() { + assertEquals(12.0, RoomCatchup.expectedPosition(10.0, executeAtMs = 1_000, nowMs = 3_000)) + assertTrue(RoomCatchup.converged(10.0, 1_000, localSeconds = 11.8, nowMs = 3_000)) + assertFalse(RoomCatchup.converged(10.0, 1_000, localSeconds = 11.0, nowMs = 3_000)) + } + + @Test + fun `one reload at a time with doubling backoff and a capped lead`() { + val budget = RoomReloadBudget() + assertTrue(budget.allowed(0)) + assertEquals(100.0, budget.begin(100.0, nowMs = 0, durationSeconds = 5_000.0)) + assertFalse(budget.allowed(1_000)) + budget.noteLoading() + assertTrue(budget.landed(100.5)) + budget.land(nowMs = 4_000) + assertFalse(budget.allowed(13_999)) + assertTrue(budget.allowed(14_000)) + + // The next reload aims ahead by the last load time, capped at the duration. + assertEquals(204.0, budget.begin(200.0, nowMs = 14_000, durationSeconds = 5_000.0)) + budget.abandon(nowMs = 15_000) + assertFalse(budget.allowed(34_999)) + assertTrue(budget.allowed(35_000)) + assertEquals(203.0, budget.begin(203.0 - 4.0 + 4.0, nowMs = 35_000, durationSeconds = 203.0)) + } + + @Test + fun `an unlanded reload stops blocking after thirty seconds and settling resets backoff`() { + val budget = RoomReloadBudget() + budget.begin(10.0, nowMs = 0) + assertFalse(budget.landed(10.0)) + assertTrue(budget.allowed(30_000)) + budget.abandon(30_000) + budget.settle() + assertTrue(budget.allowed(30_000)) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomPlaybackBindingTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomPlaybackBindingTest.kt new file mode 100644 index 0000000000..539b03b84e --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomPlaybackBindingTest.kt @@ -0,0 +1,694 @@ +package org.prairieserver.prairie.watchtogether + +import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.model.watchtogether.RoomMember +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.RoomPlaybackState +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.TransportAction +import org.prairieserver.prairie.model.watchtogether.TransportCommand +import org.prairieserver.prairie.repository.RoomTransportAuthorization +import org.prairieserver.prairie.repository.ScheduledTransportCommand +import org.prairieserver.prairie.repository.WatchTogetherConnectionState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class RoomPlaybackBindingTest { + + private val connection = WatchTogetherConnectionState(generation = 1, epoch = 1, writable = true) + + private fun playing( + state: RoomPlaybackState = RoomPlaybackState.Playing, + revision: Long = 3, + role: MemberRole = MemberRole.Guest, + selfReady: Boolean = false, + selfBuffering: Boolean = false, + ignoreWait: Boolean = false, + paused: Boolean = false, + anchor: Double = 100.0, + canControl: Boolean = false, + ) = RoomSnapshot( + roomId = "room-1", + phase = RoomPhase.Playing, + playbackState = state, + selectionRevision = revision, + selfRole = role, + isPaused = paused, + anchorPositionSeconds = anchor, + selfIgnoreWait = ignoreWait, + selfCanControlTransport = canControl || role == MemberRole.Host, + members = listOf(RoomMember(userId = "1", isSelf = true, isReady = selfReady, isBuffering = selfBuffering)), + ) + + private class Sent(val kind: String, val position: Double, val paused: Boolean, val commandId: String? = null, val ready: Boolean = false) + + private inner class FakeRoom : RoomPlaybackRoom { + override val roomSnapshot = MutableStateFlow<RoomSnapshot?>(playing()) + override val connectionState = MutableStateFlow(connection) + override val roomDeliveryEcho = MutableStateFlow<RoomDeliveryEcho?>(null) + override val latestTransportCommand = MutableStateFlow<ScheduledTransportCommand?>(null) + override val clock = MutableStateFlow(RoomClockEstimate(offsetMs = 0L, rttMs = 20L, generation = 0)) + val attaches = mutableListOf<String>() + val sent = mutableListOf<Sent>() + val requests = mutableListOf<Pair<String, Double?>>() + var echoAttaches = true + + override suspend fun attachSession(sessionId: String): Boolean { + attaches += sessionId + if (echoAttaches) { + val c = connectionState.value + roomDeliveryEcho.value = RoomDeliveryEcho(c.generation, c.epoch, sessionId) + } + return true + } + override suspend fun stateReport(sessionId: String, positionSeconds: Double, isPaused: Boolean, commandId: String?, isReady: Boolean) = + true.also { sent += Sent(if (isReady) "report_ready" else "report", positionSeconds, isPaused, commandId, isReady) } + override suspend fun ready(sessionId: String, positionSeconds: Double, isPaused: Boolean, commandId: String?) = + true.also { sent += Sent("ready", positionSeconds, isPaused, commandId) } + override suspend fun buffering(sessionId: String, positionSeconds: Double, isPaused: Boolean) = + true.also { sent += Sent("buffering", positionSeconds, isPaused) } + override suspend fun checkClockContinuity() = clock.value + override fun currentTransportAuthorization(): RoomTransportAuthorization? = + roomSnapshot.value?.let { RoomTransportAuthorization("room-1", 1, 1, 1, it) } + override suspend fun transportRequestForAuthorization( + authorization: RoomTransportAuthorization, + intent: RoomTransportIntent, + action: String, + positionSeconds: Double?, + isPaused: Boolean, + ) = true.also { requests += action to positionSeconds } + + fun kinds() = sent.map { it.kind } + } + + private class FakePlayer : RoomPlayerPort { + override val observations = MutableStateFlow( + RoomPlayerObservation( + sessionId = "ps-1", + selectionRevision = 3, + sourcePositionSeconds = 100.0, + durationSeconds = 3_000.0, + playWhenReady = true, + isPlaying = true, + state = RoomPlayerState.Ready, + ), + ) + val seeks = mutableListOf<Double>() + val playing = mutableListOf<Boolean>() + val rates = mutableListOf<Double?>() + var buffered: (Double) -> Boolean = { true } + + override fun seekTo(sourceSeconds: Double) { + seeks += sourceSeconds + } + override fun setPlaying(playing: Boolean) { + this.playing += playing + } + override fun setCorrectionRate(rate: Double?) { + rates += rate + } + override fun isBuffered(sourceSeconds: Double) = buffered(sourceSeconds) + + fun update(block: RoomPlayerObservation.() -> RoomPlayerObservation) { + observations.value = observations.value.block() + } + } + + private fun TestScope.bind(room: FakeRoom, player: FakePlayer): RoomPlaybackBinding = + RoomPlaybackBinding( + room = room, + player = player, + parentScope = backgroundScope, + monotonicNowMs = { testScheduler.currentTime }, + wallNowMs = { WALL_ORIGIN + testScheduler.currentTime }, + ).also { + it.start() + runCurrent() + } + + private fun TestScope.command( + room: FakeRoom, + id: String, + action: TransportAction, + position: Double, + state: RoomPlaybackState, + executeInMs: Long = 0L, + revision: Long = 3, + sessionId: String = "", + ) { + room.latestTransportCommand.value = ScheduledTransportCommand( + command = TransportCommand( + commandId = id, + sessionId = sessionId, + selectionRevision = revision, + action = action, + positionSeconds = position, + executeAt = "", + playbackState = state, + ), + executeAtMs = WALL_ORIGIN + testScheduler.currentTime + executeInMs, + connection = room.connectionState.value, + ) + } + + // ---- attach and reports ------------------------------------------------------ + + @Test + fun `reports start only after the server echoes the attached session`() = runTest { + val room = FakeRoom().apply { echoAttaches = false } + val player = FakePlayer() + bind(room, player) + advanceTimeBy(3_000) + assertEquals(listOf("ps-1"), room.attaches.distinct()) + assertTrue(room.sent.isEmpty()) + + room.roomDeliveryEcho.value = RoomDeliveryEcho(1, 1, "ps-1") + advanceTimeBy(300) + assertEquals("report", room.sent.single().kind) + assertEquals(100.0, room.sent.single().position) + } + + @Test + fun `a missing echo resends the attach`() = runTest { + val room = FakeRoom().apply { echoAttaches = false } + bind(room, FakePlayer()) + advanceTimeBy(4_600) + assertTrue(room.attaches.size >= 2) + } + + @Test + fun `a new socket epoch attaches again`() = runTest { + val room = FakeRoom() + bind(room, FakePlayer()) + room.connectionState.value = connection.copy(epoch = 2) + advanceTimeBy(600) + assertEquals(2, room.attaches.size) + } + + @Test + fun `reports observed state every one and a half seconds`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + bind(room, player) + advanceTimeBy(1_000) + player.update { copy(sourcePositionSeconds = 101.0, playWhenReady = false) } + advanceTimeBy(1_000) + + val reports = room.sent.filter { it.kind == "report" } + assertEquals(2, reports.size) + assertEquals(101.0, reports.last().position) + assertTrue(reports.last().paused) + } + + @Test + fun `no reports while stalled, seeking, or suspended`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + bind(room, player) + room.sent.clear() + for (change in listOf<RoomPlayerObservation.() -> RoomPlayerObservation>( + { copy(state = RoomPlayerState.Buffering) }, + { copy(state = RoomPlayerState.Ready, seekPending = true) }, + { copy(seekPending = false, suspended = true) }, + )) { + player.update(change) + advanceTimeBy(1_600) + assertTrue(room.sent.none { it.kind == "report" }, "reported during $change") + room.sent.clear() + } + } + + // ---- commands and readiness -------------------------------------------------------- + + @Test + fun `a waiting seek is applied at its execute time and acknowledged once landed`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + bind(room, player) + room.roomSnapshot.value = playing(state = RoomPlaybackState.Waiting) + command(room, "cmd-1", TransportAction.Seek, 500.0, RoomPlaybackState.Waiting, executeInMs = 400) + advanceTimeBy(300) + assertTrue(player.seeks.isEmpty()) + + advanceTimeBy(200) + assertEquals(listOf(500.0), player.seeks) + assertEquals(false, player.playing.last()) + + // Still landing: no acknowledgement for the old position. + player.update { copy(seekPending = true) } + advanceTimeBy(600) + assertTrue(room.sent.none { it.kind == "ready" }) + + player.update { copy(seekPending = false, sourcePositionSeconds = 499.6, playWhenReady = false) } + advanceTimeBy(300) + val ready = room.sent.first { it.kind == "ready" } + assertEquals("cmd-1", ready.commandId) + assertEquals(499.6, ready.position) + assertTrue(room.sent.any { it.kind == "report_ready" && it.commandId == "cmd-1" }) + } + + @Test + fun `readiness retries until a fresh snapshot confirms it`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + bind(room, player) + // The snapshot current when the command arrives still says ready from an earlier cycle. + room.roomSnapshot.value = playing(state = RoomPlaybackState.Waiting, selfReady = true) + command(room, "cmd-2", TransportAction.Pause, 100.0, RoomPlaybackState.Waiting) + player.update { copy(playWhenReady = false) } + advanceTimeBy(1_600) + assertTrue(room.sent.count { it.kind == "ready" } >= 3) + + // The server's acknowledgement arrives as a new snapshot. + room.roomSnapshot.value = playing(state = RoomPlaybackState.Waiting, selfReady = true, anchor = 100.5) + advanceTimeBy(300) + room.sent.clear() + advanceTimeBy(1_000) + assertTrue(room.sent.none { it.kind == "ready" }) + } + + @Test + fun `a second seek on the same session re-arms readiness`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + bind(room, player) + room.roomSnapshot.value = playing(state = RoomPlaybackState.Waiting) + command(room, "cmd-a", TransportAction.Seek, 200.0, RoomPlaybackState.Waiting) + player.update { copy(sourcePositionSeconds = 200.0) } + advanceTimeBy(300) + room.roomSnapshot.value = playing(state = RoomPlaybackState.Waiting, selfReady = true) + advanceTimeBy(300) + + command(room, "cmd-b", TransportAction.Seek, 200.0, RoomPlaybackState.Waiting) + advanceTimeBy(300) + + assertEquals(listOf("cmd-a", "cmd-b"), room.sent.filter { it.kind == "ready" }.map { it.commandId }.distinct()) + } + + @Test + fun `a guest outside the seek tolerance does not acknowledge but a host within fifteen seconds does`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + bind(room, player) + room.roomSnapshot.value = playing(state = RoomPlaybackState.Waiting) + command(room, "cmd-g", TransportAction.Seek, 300.0, RoomPlaybackState.Waiting) + player.update { copy(sourcePositionSeconds = 294.0) } + advanceTimeBy(1_000) + assertTrue(room.sent.none { it.kind == "ready" }) + + room.roomSnapshot.value = playing(state = RoomPlaybackState.Waiting, role = MemberRole.Host) + advanceTimeBy(300) + assertEquals("cmd-g", room.sent.first { it.kind == "ready" }.commandId) + } + + @Test + fun `a superseded delayed command never executes`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + bind(room, player) + command(room, "old", TransportAction.Seek, 50.0, RoomPlaybackState.Playing, executeInMs = 2_000) + advanceTimeBy(100) + command(room, "new", TransportAction.Seek, 70.0, RoomPlaybackState.Playing, executeInMs = 100) + advanceTimeBy(3_000) + assertEquals(listOf(70.0), player.seeks) + } + + @Test + fun `commands for another session, revision, or epoch are ignored`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + bind(room, player) + command(room, "c1", TransportAction.Seek, 10.0, RoomPlaybackState.Playing, sessionId = "someone-else") + advanceTimeBy(100) + command(room, "c2", TransportAction.Seek, 20.0, RoomPlaybackState.Playing, revision = 2) + advanceTimeBy(100) + room.latestTransportCommand.value = room.latestTransportCommand.value!!.copy( + command = room.latestTransportCommand.value!!.command.copy(commandId = "c3", selectionRevision = 3), + connection = connection.copy(epoch = 9), + ) + advanceTimeBy(100) + assertTrue(player.seeks.isEmpty()) + } + + @Test + fun `a waiting command replayed after reconnect executes on the new socket`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + bind(room, player) + room.roomSnapshot.value = playing(state = RoomPlaybackState.Waiting) + command(room, "replayed", TransportAction.Seek, 250.0, RoomPlaybackState.Waiting, executeInMs = 400) + advanceTimeBy(100) + room.connectionState.value = connection.copy(epoch = 2) + advanceTimeBy(500) + assertTrue(player.seeks.isEmpty()) + + command(room, "replayed", TransportAction.Seek, 250.0, RoomPlaybackState.Waiting) + advanceTimeBy(100) + assertEquals(listOf(250.0), player.seeks) + player.update { copy(sourcePositionSeconds = 250.0, playWhenReady = false) } + advanceTimeBy(600) + assertTrue(room.sent.any { it.kind == "ready" && it.commandId == "replayed" }) + } + + @Test + fun `a command waits for clock synchronization without using device clock skew`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + val binding = bind(room, player) + val notices = mutableListOf<RoomPlaybackNotice>() + backgroundScope.launch { binding.notices.toList(notices) } + runCurrent() + room.clock.value = RoomClockEstimate(offsetMs = null, rttMs = null, generation = 1) + command(room, "no-clock", TransportAction.Play, 200.0, RoomPlaybackState.Playing, executeInMs = -60_000) + advanceTimeBy(4_000) + assertTrue(player.seeks.isEmpty(), "a command without a clock sample must not seek using the device time") + assertTrue(player.playing.none { it }, "playback must wait for a clock sample") + assertEquals("no-clock", binding.debug.value.pendingCommandId) + assertEquals(listOf<RoomPlaybackNotice>(RoomPlaybackNotice.ClockUnavailable), notices) + + room.clock.value = RoomClockEstimate(offsetMs = -60_000, rttMs = 20, generation = 1) + advanceTimeBy(300) + assertEquals(204.0, player.seeks.single(), 0.4) + assertEquals(true, player.playing.last()) + } + + @Test + fun `a stale socket command cannot suppress the current socket replay`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + bind(room, player) + room.connectionState.value = connection.copy(epoch = 2) + command(room, "same-id", TransportAction.Seek, 250.0, RoomPlaybackState.Playing) + room.latestTransportCommand.value = room.latestTransportCommand.value!!.copy(connection = connection) + advanceTimeBy(100) + assertTrue(player.seeks.isEmpty()) + + command(room, "same-id", TransportAction.Seek, 250.0, RoomPlaybackState.Playing) + advanceTimeBy(100) + assertEquals(listOf(250.0), player.seeks) + } + + @Test + fun `clock invalidation during a scheduled delay holds the command until synchronization returns`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + bind(room, player) + command(room, "clock-reset", TransportAction.Play, 200.0, RoomPlaybackState.Playing, executeInMs = 400) + advanceTimeBy(100) + room.clock.value = RoomClockEstimate(offsetMs = null, rttMs = null, generation = 1) + advanceTimeBy(4_000) + assertTrue(player.seeks.isEmpty()) + assertTrue(player.playing.none { it }) + + room.clock.value = RoomClockEstimate(offsetMs = 0, rttMs = 20, generation = 1) + advanceTimeBy(300) + assertEquals(204.0, player.seeks.single(), 0.4) + assertEquals(true, player.playing.last()) + } + + @Test + fun `a late play advances its target by the elapsed server time`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + bind(room, player) + player.update { copy(sourcePositionSeconds = 90.0) } + command(room, "late", TransportAction.Play, 100.0, RoomPlaybackState.Playing, executeInMs = -4_000) + advanceTimeBy(100) + assertEquals(104.0, player.seeks.single(), 0.2) + assertEquals(true, player.playing.last()) + } + + // ---- corrections ----------------------------------------------------------------- + + @Test + fun `small unbuffered drift converges by rate and returns to exactly 1x`() = runTest { + val room = FakeRoom() + val player = FakePlayer().apply { buffered = { false } } + bind(room, player) + player.update { copy(sourcePositionSeconds = 99.0) } + command(room, "drift", TransportAction.Play, 100.0, RoomPlaybackState.Playing) + advanceTimeBy(50) + assertEquals(1.125, player.rates.last()!!, 0.001) + assertTrue(player.seeks.isEmpty()) + + // 300 ms later the room is at about 100.3. + player.update { copy(sourcePositionSeconds = 100.3) } + advanceTimeBy(300) + assertNull(player.rates.last()) + } + + @Test + fun `an unbuffered pause correction never reloads media`() = runTest { + val room = FakeRoom() + val player = FakePlayer().apply { buffered = { false } } + bind(room, player) + command(room, "p", TransportAction.Pause, 140.0, RoomPlaybackState.Paused) + advanceTimeBy(100) + assertTrue(player.seeks.isEmpty()) + assertEquals(false, player.playing.last()) + } + + @Test + fun `unbuffered play corrections are paced by the reload budget`() = runTest { + val room = FakeRoom() + val player = FakePlayer().apply { buffered = { false } } + bind(room, player) + player.update { copy(sourcePositionSeconds = 50.0) } + command(room, "r1", TransportAction.Play, 100.0, RoomPlaybackState.Playing) + advanceTimeBy(100) + assertEquals(1, player.seeks.size) + + command(room, "r2", TransportAction.Play, 110.0, RoomPlaybackState.Playing) + advanceTimeBy(100) + assertEquals(1, player.seeks.size, "a second reload started before the first could land") + + // An explicit seek is never delayed by the budget. + command(room, "s", TransportAction.Seek, 400.0, RoomPlaybackState.Playing) + advanceTimeBy(100) + assertEquals(400.0, player.seeks.last()) + } + + @Test + fun `a play correction held by the reload budget runs once the budget allows`() = runTest { + val room = FakeRoom() + val player = FakePlayer().apply { buffered = { false } } + bind(room, player) + player.update { copy(sourcePositionSeconds = 50.0) } + command(room, "r1", TransportAction.Play, 100.0, RoomPlaybackState.Playing) + advanceTimeBy(100) + command(room, "r2", TransportAction.Play, 110.0, RoomPlaybackState.Playing) + advanceTimeBy(9_000) + assertEquals(1, player.seeks.size) + + // The superseded reload backs off for ten seconds; then the held + // correction reloads toward where the room is now. + advanceTimeBy(1_500) + assertEquals(2, player.seeks.size) + assertTrue(player.seeks.last() >= 119.0, "aimed at ${player.seeks.last()}") + } + + @Test + fun `a reconnect returns a correction rate to exactly 1x`() = runTest { + val room = FakeRoom() + val player = FakePlayer().apply { buffered = { false } } + bind(room, player) + player.update { copy(sourcePositionSeconds = 99.0) } + command(room, "drift", TransportAction.Play, 100.0, RoomPlaybackState.Playing) + advanceTimeBy(50) + assertEquals(1.125, player.rates.last()!!, 0.001) + + room.connectionState.value = connection.copy(writable = false) + advanceTimeBy(300) + assertNull(player.rates.last()) + } + + // ---- buffering and catching up ------------------------------------------------------ + + @Test + fun `a stall under two seconds stays local and a longer one is reported once`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + bind(room, player) + player.update { copy(state = RoomPlayerState.Buffering) } + advanceTimeBy(1_500) + player.update { copy(state = RoomPlayerState.Ready) } + advanceTimeBy(300) + assertTrue(room.sent.none { it.kind == "buffering" }) + + player.update { copy(state = RoomPlayerState.Buffering) } + advanceTimeBy(5_000) + assertEquals(1, room.sent.count { it.kind == "buffering" }) + } + + @Test + fun `two sustained stalls within five minutes offer a lower quality`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + val binding = bind(room, player) + repeat(2) { + player.update { copy(state = RoomPlayerState.Buffering) } + advanceTimeBy(2_300) + player.update { copy(state = RoomPlayerState.Ready) } + advanceTimeBy(300) + } + assertTrue(binding.offerLowerQuality.value) + binding.onQualityChanged() + assertFalse(binding.offerLowerQuality.value) + } + + @Test + fun `a new title starts with no stall history`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + val binding = bind(room, player) + repeat(2) { + player.update { copy(state = RoomPlayerState.Buffering) } + advanceTimeBy(2_300) + player.update { copy(state = RoomPlayerState.Ready) } + advanceTimeBy(300) + } + assertTrue(binding.offerLowerQuality.value) + room.roomSnapshot.value = playing(revision = 4) + advanceTimeBy(300) + assertFalse(binding.offerLowerQuality.value) + } + + @Test + fun `a viewer the room left behind acknowledges recovery until it is ready`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + val binding = bind(room, player) + room.roomSnapshot.value = playing(ignoreWait = true) + player.update { copy(state = RoomPlayerState.Buffering) } + advanceTimeBy(600) + assertTrue(binding.catchingUp.value) + assertTrue(room.sent.none { it.kind == "ready" }) + + player.update { copy(state = RoomPlayerState.Ready) } + advanceTimeBy(1_100) + assertTrue(room.sent.count { it.kind == "ready" } >= 2) + + room.roomSnapshot.value = playing(selfReady = true) + advanceTimeBy(300) + room.sent.clear() + advanceTimeBy(1_000) + assertTrue(room.sent.none { it.kind == "ready" }) + assertFalse(binding.catchingUp.value) + } + + @Test + fun `ending a local suspension re-attaches so the room resends its position`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + bind(room, player) + player.update { copy(suspended = true, playWhenReady = false) } + advanceTimeBy(600) + player.update { copy(suspended = false) } + advanceTimeBy(600) + assertEquals(2, room.attaches.size) + } + + // ---- user intents ----------------------------------------------------------------- + + @Test + fun `a guest seek is denied locally and never sent`() = runTest { + val room = FakeRoom() + val binding = bind(room, FakePlayer()) + val notices = mutableListOf<RoomPlaybackNotice>() + backgroundScope.launch { binding.notices.toList(notices) } + runCurrent() + + assertEquals(RoomTransportResult.Denied, binding.requestSeek(20.0)) + runCurrent() + assertTrue(room.requests.isEmpty()) + assertEquals(listOf<RoomPlaybackNotice>(RoomPlaybackNotice.Denied(RoomTransportIntent.Seek)), notices) + } + + @Test + fun `a tap during a reconnect says so instead of dropping silently`() = runTest { + val room = FakeRoom() + val binding = bind(room, FakePlayer()) + room.roomSnapshot.value = playing(canControl = true) + room.connectionState.value = connection.copy(writable = false) + assertEquals(RoomTransportResult.Reconnecting, binding.requestPlayPause(pause = true)) + assertTrue(room.requests.isEmpty()) + } + + @Test + fun `permitted requests go to the room and never touch the player`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + val binding = bind(room, player) + room.roomSnapshot.value = playing(role = MemberRole.Host) + assertEquals(RoomTransportResult.Sent, binding.requestSeek(250.0)) + assertEquals(RoomTransportResult.Sent, binding.requestPlayPause(pause = true)) + runCurrent() + assertEquals(listOf<Pair<String, Double?>>("seek" to 250.0, "pause" to 100.0), room.requests) + assertTrue(player.seeks.isEmpty()) + assertTrue(player.playing.isEmpty()) + } + + @Test + fun `play from the end is not sent to the room`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + val binding = bind(room, player) + room.roomSnapshot.value = playing(role = MemberRole.Host) + player.update { copy(state = RoomPlayerState.Ended, playWhenReady = false) } + assertEquals(RoomTransportResult.Ignored, binding.requestPlayPause(pause = false)) + } + + @Test + fun `leaving the playing phase stops corrections at 1x`() = runTest { + val room = FakeRoom() + val player = FakePlayer().apply { buffered = { false } } + bind(room, player) + player.update { copy(sourcePositionSeconds = 99.0) } + command(room, "d", TransportAction.Play, 100.0, RoomPlaybackState.Playing) + advanceTimeBy(50) + room.roomSnapshot.value = playing().copy(phase = RoomPhase.Lobby) + advanceTimeBy(300) + assertNull(player.rates.last()) + } + + @Test + fun `a room play never resumes a locally suspended player`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + bind(room, player) + player.update { copy(suspended = true, playWhenReady = false) } + command(room, "play", TransportAction.Play, 100.0, RoomPlaybackState.Playing) + advanceTimeBy(100) + assertTrue(player.playing.none { it }) + } + + @Test + fun `a command during a mount waits for it and still advances a late play`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + bind(room, player) + player.update { copy(seekPending = true, sourcePositionSeconds = 20.0) } + command(room, "after-mount", TransportAction.Play, 100.0, RoomPlaybackState.Playing) + advanceTimeBy(1_000) + assertTrue(player.seeks.isEmpty()) + + player.update { copy(seekPending = false) } + advanceTimeBy(300) + assertEquals(101.0, player.seeks.single(), 0.4) + assertEquals(true, player.playing.last()) + } + + private companion object { + const val WALL_ORIGIN = 1_790_000_000_000L + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomSessionTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomSessionTest.kt index 77154587f6..021eafb313 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomSessionTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomSessionTest.kt @@ -45,6 +45,7 @@ class RoomSessionTest { session.enter("room-a") runCurrent() + repository.membership = 2L val replacement = launch { session.enter("room-b") } runCurrent() assertEquals(listOf("room-a"), repository.started) @@ -64,6 +65,8 @@ class RoomSessionTest { val session = RoomSession(repository, backgroundScope, DefaultIdentityTransitionBarrier()) val first = launch { session.enter("room-a") } + runCurrent() + repository.membership = 2L val second = launch { session.enter("room-b") } first.join() second.join() @@ -118,6 +121,36 @@ class RoomSessionTest { assertTrue(!session.isActive()) } + @Test + fun `explicit rejoin of the same room replaces the obsolete connection`() = runTest { + val repository = FakeRoomSessionRepository() + val session = RoomSession(repository, backgroundScope, DefaultIdentityTransitionBarrier()) + session.adopt("room-a").join() + runCurrent() + + // A fresh join of the same room installs a new membership. + repository.membership = 2L + session.adopt("room-a").join() + runCurrent() + + assertEquals(listOf("room-a", "room-a"), repository.started) + assertEquals(listOf("room-a"), repository.canceling) + assertTrue(session.isActive()) + session.leave() + } + + @Test + fun `enter without a live membership opens no socket`() = runTest { + val repository = FakeRoomSessionRepository().apply { membership = null } + val session = RoomSession(repository, backgroundScope, DefaultIdentityTransitionBarrier()) + + session.enter("room-a") + runCurrent() + + assertTrue(repository.started.isEmpty()) + assertTrue(!session.isActive()) + } + @Test fun `identity WILL_CHANGE cancels joins and resets before mutation`() = runTest { val repository = FakeRoomSessionRepository() @@ -220,6 +253,10 @@ class RoomSessionTest { val canceling = mutableListOf<String>() val finalizerGates = mutableMapOf<String, CompletableDeferred<Unit>>() var resetCount = 0 + /** The live membership; a join bumps it. Null means no membership. */ + var membership: Long? = 1L + + override suspend fun membershipGeneration(): Long? = membership override suspend fun connect(roomId: String) { started += roomId diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyAvailabilityAndRecentsTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyAvailabilityAndRecentsTest.kt new file mode 100644 index 0000000000..ab4c763d1a --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyAvailabilityAndRecentsTest.kt @@ -0,0 +1,195 @@ +package org.prairieserver.prairie.watchtogether + +import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.WatchTogetherCapabilitiesV2 +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionKind +import org.prairieserver.prairie.network.apiv2.PlaybackCapabilitiesV2 +import org.prairieserver.prairie.repository.WatchPartyEndReason +import org.prairieserver.prairie.repository.WatchPartyEnded +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class WatchPartyAvailabilityAndRecentsTest { + private val scopeA = AuthScopeSnapshot(serverId = "s", profileId = "p1", serverUrl = "https://a", profileToken = null, identityGeneration = 1) + private val roomCaps = WatchTogetherCapabilitiesV2( + state = "available", allowed = true, stagedSelection = true, lobbyReady = true, connectionReplaced = true, + socketProtocol = "prairie.room.v2", + ) + private val playbackCaps = PlaybackCapabilitiesV2( + state = "available", allowed = true, protocolVersions = listOf(3), + features = listOf(WatchPartyPlaybackFeatures.Coordinator, WatchPartyPlaybackFeatures.FixedMediaFile), + ) + + private class Probe(var room: ApiResult<WatchTogetherCapabilitiesV2>, var playback: ApiResult<PlaybackCapabilitiesV2>) { + var calls = 0 + } + + private fun availability(probe: Probe, scope: () -> AuthScopeSnapshot?) = WatchPartyAvailabilityRepository( + roomCapabilities = { probe.calls++; probe.room }, + playbackCapabilities = { probe.playback }, + authScopeProvider = { scope() }, + ) + + @Test + fun `a definite answer is cached for the authority`() = runTest { + val probe = Probe(ApiResult.Success(roomCaps), ApiResult.Success(playbackCaps)) + val repo = availability(probe) { scopeA } + + assertIs<WatchPartyAvailability.Available>(repo.refresh()) + assertIs<WatchPartyAvailability.Available>(repo.refresh()) + assertEquals(1, probe.calls) + assertTrue(repo.features!!.stagedSelection) + } + + @Test + fun `a failed probe keeps a known answer and reports failure only when nothing is known`() = runTest { + val probe = Probe(ApiResult.NetworkError(RuntimeException("offline")), ApiResult.Success(playbackCaps)) + val repo = availability(probe) { scopeA } + + assertIs<WatchPartyAvailability.ProbeFailed>(repo.refresh()) + probe.room = ApiResult.Success(roomCaps) + assertIs<WatchPartyAvailability.Available>(repo.refresh()) + probe.room = ApiResult.NetworkError(RuntimeException("offline")) + assertIs<WatchPartyAvailability.Available>(repo.refresh(force = true)) + } + + @Test + fun `a profile change discards the cached answer`() = runTest { + var scope = scopeA + val probe = Probe(ApiResult.Success(roomCaps), ApiResult.Success(playbackCaps)) + val repo = availability(probe) { scope } + repo.refresh() + + scope = scopeA.copy(profileId = "p2") + probe.playback = ApiResult.Success(playbackCaps.copy(allowed = false)) + + assertEquals(WatchPartyAvailability.NotAllowed, repo.refresh()) + assertEquals(2, probe.calls) + } + + @Test + fun `a server without rooms is unsupported without reading playback`() = runTest { + val probe = Probe( + ApiResult.Success(WatchTogetherCapabilitiesV2(state = "not_configured")), + ApiResult.NetworkError(RuntimeException("must not be read")), + ) + assertIs<WatchPartyAvailability.Unsupported>(availability(probe) { scopeA }.refresh()) + } + + // ---- recent party ------------------------------------------------------------ + + private class MemoryStorage : RecentWatchPartyStorage { + var value: String? = null + override fun read() = value + override fun write(value: String?) { this.value = value } + } + + private val ownerA = RecentPartyOwner("server", "login-1", "profile-1") + + @Test + fun `recent party is shown only to its owner and only for a day`() = runTest { + var owner: RecentPartyOwner? = ownerA + var now = 1_000L + val storage = MemoryStorage() + val recents = RecentWatchParties(storage, owner = { owner }, nowEpochMs = { now }) + + recents.remember("room-1", "K7PQ2M4X", wasHost = true) + assertEquals("K7PQ2M4X", recents.current()?.code) + assertFalse(storage.value!!.contains("token")) + + owner = ownerA.copy(profileId = "profile-2") + assertNull(recents.current()) + owner = ownerA + now += 24 * 60 * 60_000L + 1 + assertNull(recents.current()) + assertNull(storage.value) + } + + @Test + fun `recorder keeps a replaced party and forgets an ended one`() = runTest { + val storage = MemoryStorage() + val recents = RecentWatchParties(storage, owner = { ownerA }, nowEpochMs = { 5L }) + val room = MutableStateFlow<RoomSnapshot?>(null) + val ended = MutableStateFlow<WatchPartyEnded?>(null) + val barrier = DefaultIdentityTransitionBarrier() + WatchPartyRecentsRecorder(recents, room, ended, backgroundScope, barrier) + + room.value = RoomSnapshot(roomId = "room-1", code = "K7PQ2M4X", selfRole = MemberRole.Guest) + runCurrent() + assertEquals("room-1", recents.current()?.roomId) + + room.value = null + ended.value = WatchPartyEnded("room-1", "K7PQ2M4X", WatchPartyEndReason.Replaced, wasHost = false) + runCurrent() + assertEquals("room-1", recents.current()?.roomId) + + ended.value = WatchPartyEnded("room-1", "K7PQ2M4X", WatchPartyEndReason.HostLeft, wasHost = false) + runCurrent() + assertNull(recents.current()) + } + + @Test + fun `recorder forgets a room that ends while its snapshot is still live`() = runTest { + val storage = MemoryStorage() + val recents = RecentWatchParties(storage, owner = { ownerA }, nowEpochMs = { 5L }) + val room = MutableStateFlow<RoomSnapshot?>(null) + val ended = MutableStateFlow<WatchPartyEnded?>(null) + WatchPartyRecentsRecorder(recents, room, ended, backgroundScope, DefaultIdentityTransitionBarrier()) + + room.value = RoomSnapshot(roomId = "room-1", code = "K7PQ2M4X", selfRole = MemberRole.Host) + runCurrent() + // The repository's order: the end first, then the snapshot and end clear. + ended.value = WatchPartyEnded("room-1", "K7PQ2M4X", WatchPartyEndReason.Ended, wasHost = true) + runCurrent() + room.value = null + ended.value = null + runCurrent() + assertNull(recents.current()) + } + + @Test + fun `recorder forgets a party this device ended but keeps one it left`() = runTest { + val storage = MemoryStorage() + val recents = RecentWatchParties(storage, owner = { ownerA }, nowEpochMs = { 5L }) + val room = MutableStateFlow<RoomSnapshot?>(null) + val closed = MutableSharedFlow<String>(extraBufferCapacity = 1) + val barrier = DefaultIdentityTransitionBarrier() + WatchPartyRecentsRecorder(recents, room, MutableStateFlow(null), backgroundScope, barrier, closed) + + room.value = RoomSnapshot(roomId = "room-1", code = "K7PQ2M4X", selfRole = MemberRole.Host) + runCurrent() + // Leave clears the engagement without an end reason; Rejoin stays. + room.value = null + runCurrent() + assertEquals("room-1", recents.current()?.roomId) + + closed.emit("room-1") + runCurrent() + assertNull(recents.current()) + } + + @Test + fun `sign out clears the recent party before the identity changes`() = runTest { + val storage = MemoryStorage() + val recents = RecentWatchParties(storage, owner = { ownerA }, nowEpochMs = { 5L }) + val barrier = DefaultIdentityTransitionBarrier() + WatchPartyRecentsRecorder(recents, MutableStateFlow(null), MutableStateFlow(null), backgroundScope, barrier) + recents.remember("room-1", "K7PQ2M4X", wasHost = false) + + barrier.changing(IdentityTransitionKind.PROFILE_SWITCH) {} + assertEquals("room-1", recents.current()?.roomId) + barrier.changing(IdentityTransitionKind.SIGN_OUT) { assertNull(storage.value) } + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyEntryRulesTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyEntryRulesTest.kt new file mode 100644 index 0000000000..56884a93fd --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyEntryRulesTest.kt @@ -0,0 +1,184 @@ +package org.prairieserver.prairie.watchtogether + +import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.model.watchtogether.RoomMember +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.Suggestion +import org.prairieserver.prairie.network.ApiResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class WatchPartyEntryRulesTest { + private val features = WatchPartyFeatures( + stagedSelection = true, stopPlayback = true, selectionModeSwitch = true, lobbyReady = true, picker = true, + memberState = true, maxMemberStateIds = 200, voteHostOverride = true, sourceFallback = true, + ) + + private fun room( + phase: RoomPhase = RoomPhase.Lobby, + role: MemberRole = MemberRole.Guest, + mode: RoomSelectionMode = RoomSelectionMode.HostPick, + staged: Boolean = false, + ) = RoomSnapshot( + roomId = "r", + phase = phase, + selfRole = role, + selfCanManageRoom = role == MemberRole.Host, + selectionMode = mode, + selectedContentId = "movie:a".takeIf { staged }, + members = listOf(RoomMember(userId = "7", profileId = "p", isSelf = true, isHost = role == MemberRole.Host, connected = true)), + ) + + @Test + fun `the phase alone decides lobby or player`() { + assertEquals(WatchPartyDestination.Lobby("r"), watchPartyDestination(room(staged = true))) + // A host alone in a playing room plays. + assertEquals(WatchPartyDestination.Player("r"), watchPartyDestination(room(phase = RoomPhase.Playing, role = MemberRole.Host))) + assertNull(watchPartyDestination(room(phase = RoomPhase.Ended))) + } + + @Test + fun `guests can suggest and mark ready but not manage`() { + val e = watchPartyEligibility(room(staged = true), features, busy = false, personalVotesKnown = true) + assertTrue(e.canSuggest && e.canLobbyReady) + assertFalse(e.canStage || e.canStart || e.canSwitchMode || e.canSetPolicy || e.canEnd || e.canQueue) + } + + @Test + fun `host start needs a staged item and lobby ready is advisory for guests only`() { + assertFalse(watchPartyEligibility(room(role = MemberRole.Host), features, false, true).canStart) + val staged = watchPartyEligibility(room(role = MemberRole.Host, staged = true), features, false, true) + assertTrue(staged.canStart) + assertFalse(staged.canLobbyReady) + } + + @Test + fun `an action in flight disables the others`() { + val e = watchPartyEligibility(room(role = MemberRole.Host, staged = true), features, busy = true, personalVotesKnown = true) + assertFalse(e.canStart || e.canStage || e.canSuggest) + assertTrue(e.busy) + } + + @Test + fun `votes wait for personal vote state and promotion needs the override capability`() { + val vote = room(role = MemberRole.Host, mode = RoomSelectionMode.Vote) + assertFalse(watchPartyEligibility(vote, features, false, personalVotesKnown = false).canVote) + assertTrue(watchPartyEligibility(vote, features, false, personalVotesKnown = true).canVote) + assertFalse(watchPartyEligibility(vote, features.copy(voteHostOverride = false), false, true).canPromote) + } + + @Test + fun `missing capabilities hide their controls`() { + val host = room(role = MemberRole.Host, staged = true) + val e = watchPartyEligibility(host, features.copy(stagedSelection = false, selectionModeSwitch = false), false, true) + assertFalse(e.canStage || e.canStart || e.canSwitchMode || e.canQueue) + assertFalse(watchPartyEligibility(room(phase = RoomPhase.Playing, role = MemberRole.Host), features.copy(stopPlayback = false), false, true).canStop) + } + + @Test + fun `hosting is offered unless the server is known not to host`() { + assertTrue(watchPartyHostingOffered(WatchPartyAvailability.Available(features))) + assertFalse(watchPartyHostingOffered(WatchPartyAvailability.Available(features.copy(stagedSelection = false)))) + assertFalse(watchPartyHostingOffered(WatchPartyAvailability.NotAllowed)) + // Unknown yet: the hub explains whatever the probe finds. + assertTrue(watchPartyHostingOffered(null)) + } + + @Test + fun `unknown roles grant nothing`() { + val e = watchPartyEligibility(room(role = MemberRole.Unknown), features, false, true) + assertFalse(e.canSuggest || e.canLobbyReady || e.canVote) + } + + @Test + fun `suggestions are removable by the host or the matching suggester only`() { + val mine = Suggestion(id = "s", roomId = "r", suggesterUserId = "7", suggesterProfileId = "p", contentId = "c", contentType = "movie", title = "t", createdAt = "") + assertTrue(canRemoveSuggestion(room(), mine)) + assertFalse(canRemoveSuggestion(room(), mine.copy(suggesterProfileId = "other"))) + assertTrue(canRemoveSuggestion(room(role = MemberRole.Host), mine.copy(suggesterUserId = "9"))) + } + + @Test + fun `lobby readiness counts connected guests`() { + val r = room(role = MemberRole.Host).copy( + members = listOf( + RoomMember(userId = "1", isHost = true, connected = true, lobbyReady = true), + RoomMember(userId = "2", connected = true, lobbyReady = true), + RoomMember(userId = "3", connected = true), + RoomMember(userId = "4", connected = false, lobbyReady = true), + ), + ) + assertEquals(LobbyReadiness(ready = 1, guests = 2), lobbyReadiness(r)) + } + + // ---- invitations --------------------------------------------------------------- + + @Test + fun `codes normalize case, spaces, and dashes`() { + assertEquals("K7PQ2M4X", normalizeWatchPartyCode(" k7pq-2m4x ")) + assertNull(normalizeWatchPartyCode("K7PQ2M4")) + assertNull(normalizeWatchPartyCode("K7PQ2M4O")) + } + + @Test + fun `web invitation links keep the base path`() { + assertEquals( + WatchPartyInvite.Link("https://media.example/prairie", "Zq8vN3kR5tW1yB6cD9fG2hJ4"), + parseWatchPartyInvite("https://media.example/prairie/rooms/join?token=Zq8vN3kR5tW1yB6cD9fG2hJ4"), + ) + assertEquals( + WatchPartyInvite.Link("http://192.168.1.4:8096", "t"), + parseWatchPartyInvite("http://192.168.1.4:8096/rooms/join?token=t"), + ) + } + + @Test + fun `app links match the Apple format and reject bad shapes`() { + assertEquals( + WatchPartyInvite.Link("https://media.example/prairie", "tok"), + parseWatchPartyInvite("prairie://watch-party?server=https%3A%2F%2Fmedia.example%2Fprairie&token=tok"), + ) + assertEquals( + WatchPartyInvite.Link("https://media.example", "tok"), + parseWatchPartyInvite("PRAIRIE://watch-party?server=https://media.example&token=tok"), + ) + for (bad in listOf( + "prairie://watch-party?server=https://a&token=t&token=u", + "prairie://watch-party?token=t", + "prairie://watch-party?server=ftp://a&token=t", + "prairie://watch-party?server=https://user@a&token=t", + "prairie://invite?server=https://a&token=t", + "prairie://watch-party?server=https://a&token=", + "https://a/rooms/join?code=ABCD1234", + "https://a/rooms/join", + "hello", + )) { + assertNull(parseWatchPartyInvite(bad), bad) + } + } + + @Test + fun `the invitation link resolves against the server base path`() { + assertEquals( + "https://media.example/prairie/rooms/join?token=t", + watchPartyInviteUrl("https://media.example/prairie/", "/rooms/join?token=t"), + ) + assertNull(watchPartyInviteUrl("https://a", "https://evil/rooms/join?token=t")) + assertEquals("https://a/rooms/join?token=t", parseWatchPartyInvite("https://a/rooms/join?token=t")?.let { "https://a/rooms/join?token=t" }) + } + + @Test + fun `error text is plain and specific`() { + assertEquals("Only the host can do that.", watchPartyErrorMessage(ApiResult.Error(403, "permission_denied", ""), "x")) + assertEquals( + "Couldn't reach the server. Check your connection and try again.", + watchPartyErrorMessage(ApiResult.NetworkError(RuntimeException()), "x"), + ) + assertEquals("fallback", watchPartyErrorMessage(ApiResult.Error(500, "internal", ""), "fallback")) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyLobbyPresentationTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyLobbyPresentationTest.kt new file mode 100644 index 0000000000..9e30d6a576 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyLobbyPresentationTest.kt @@ -0,0 +1,120 @@ +package org.prairieserver.prairie.watchtogether + +import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.model.watchtogether.RoomMember +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.Suggestion +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class WatchPartyLobbyPresentationTest { + private val features = WatchPartyFeatures( + stagedSelection = true, stopPlayback = true, selectionModeSwitch = true, lobbyReady = true, picker = true, + memberState = true, maxMemberStateIds = 200, voteHostOverride = false, sourceFallback = true, + ) + + private val host = RoomMember(userId = "1", profileId = "a", displayName = "Ana", isHost = true, connected = true) + private val guest = RoomMember(userId = "2", profileId = "b", displayName = "Ben", connected = true) + + private fun room( + role: MemberRole, + phase: RoomPhase = RoomPhase.Lobby, + mode: RoomSelectionMode = RoomSelectionMode.HostPick, + staged: Boolean = true, + guestReady: Boolean = false, + hostConnected: Boolean = true, + ) = RoomSnapshot( + roomId = "r", + phase = phase, + selfRole = role, + selfCanManageRoom = role == MemberRole.Host, + selectionMode = mode, + selectedContentId = "movie:a".takeIf { staged }, + hostConnected = hostConnected, + members = listOf( + host.copy(isSelf = role == MemberRole.Host, connected = hostConnected), + guest.copy(isSelf = role == MemberRole.Guest, lobbyReady = guestReady), + ), + ) + + private fun suggestion(id: String, votes: Int) = Suggestion( + id = id, roomId = "r", contentId = "movie:$id", contentType = "movie", title = id.uppercase(), + voteCount = votes, createdAt = "2026-09-25T10:00:0${id.length}Z", + ) + + @Test + fun `host pick lobby foregrounds choose, then start, and guests ready up`() { + val none = emptyList<Suggestion>() + assertEquals(WatchPartyPrimaryAction.ChooseTitle, watchPartyPrimaryAction(room(MemberRole.Host, staged = false), features, none)) + assertEquals(WatchPartyPrimaryAction.Start(null), watchPartyPrimaryAction(room(MemberRole.Host), features, none)) + assertEquals( + WatchPartySecondaryAction.ChangeTitle, + watchPartySecondaryAction(room(MemberRole.Host), WatchPartyPrimaryAction.Start(null)), + ) + assertEquals(WatchPartyPrimaryAction.Ready(false), watchPartyPrimaryAction(room(MemberRole.Guest), features, none)) + assertEquals(WatchPartyPrimaryAction.Ready(true), watchPartyPrimaryAction(room(MemberRole.Guest, guestReady = true), features, none)) + // Ready is advisory, so guests can mark it before the host picks, as on Apple. + assertEquals(WatchPartyPrimaryAction.Ready(false), watchPartyPrimaryAction(room(MemberRole.Guest, staged = false), features, none)) + } + + @Test + fun `a voting room starts its leader and waits while nobody has voted`() { + val voting = room(MemberRole.Host, mode = RoomSelectionMode.Vote, staged = false) + val promotable = features.copy(voteHostOverride = true) + assertEquals(WatchPartyPrimaryAction.WaitingForVotes, watchPartyPrimaryAction(voting, promotable, listOf(suggestion("a", 0)))) + assertEquals( + WatchPartyPrimaryAction.Start("B"), + watchPartyPrimaryAction(voting, promotable, listOf(suggestion("a", 1), suggestion("b", 2))), + ) + // Starting promotes the leader, which servers without the promote route can't do. + assertEquals( + WatchPartyPrimaryAction.WaitingForVotes, + watchPartyPrimaryAction(voting, features, listOf(suggestion("a", 1))), + ) + assertEquals("Nobody has voted yet.", watchPartyLobbyHint(voting, listOf(suggestion("a", 0)))) + assertEquals("Suggest a title to get the vote going.", watchPartyLobbyHint(voting, emptyList())) + } + + @Test + fun `playing rooms offer return to playback unless the guest cannot see the title`() { + assertEquals( + WatchPartyPrimaryAction.ReturnToPlayback, + watchPartyPrimaryAction(room(MemberRole.Guest, phase = RoomPhase.Playing), features, emptyList()), + ) + assertEquals( + WatchPartyPrimaryAction.None, + watchPartyPrimaryAction(room(MemberRole.Guest, phase = RoomPhase.Playing), features, emptyList(), selectionUnavailable = true), + ) + } + + @Test + fun `seats and presence read like Apple`() { + assertEquals(WatchPartySeatState.Ready, watchPartySeatState(host, RoomPhase.Lobby)) + assertEquals(WatchPartySeatState.NotReady, watchPartySeatState(guest, RoomPhase.Lobby)) + assertEquals(WatchPartySeatState.Away, watchPartySeatState(guest.copy(connected = false), RoomPhase.Lobby)) + assertEquals(WatchPartySeatState.Watching, watchPartySeatState(guest.copy(isReady = true), RoomPhase.Playing)) + assertEquals("1 of 2 ready", watchPartyPresenceSummary(listOf(host, guest), RoomPhase.Lobby)) + assertEquals("only you so far", watchPartyPresenceSummary(listOf(host), RoomPhase.Lobby)) + assertEquals( + listOf("Ana", "Ben", "Cal"), + watchPartySeatOrder(listOf(guest.copy(displayName = "Cal"), guest, host)).map { it.displayName }, + ) + } + + @Test + fun `hints and banners name the people involved`() { + assertEquals("Ben hasn't marked ready. You can still start.", watchPartyLobbyHint(room(MemberRole.Host), emptyList())) + assertEquals("Everyone's ready.", watchPartyLobbyHint(room(MemberRole.Host, guestReady = true), emptyList())) + assertEquals("Ana starts the movie when everyone's set.", watchPartyLobbyHint(room(MemberRole.Guest), emptyList())) + assertEquals( + "Ana lost connection. The party holds for two minutes while they come back.", + watchPartyLobbyBanner(room(MemberRole.Guest, hostConnected = false), selectionUnavailable = false, reconnecting = false), + ) + assertNull(watchPartyLobbyBanner(room(MemberRole.Host), selectionUnavailable = false, reconnecting = false)) + assertEquals("Waiting for\nAna", watchPartyLobbyHero(room(MemberRole.Guest, staged = false), emptyList()).title) + assertEquals("Ana chose this", watchPartyLobbyHero(room(MemberRole.Guest), emptyList()).supporting) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyPresentationCoverageTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyPresentationCoverageTest.kt new file mode 100644 index 0000000000..811bd323b0 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyPresentationCoverageTest.kt @@ -0,0 +1,167 @@ +package org.prairieserver.prairie.watchtogether + +import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.model.watchtogether.RoomMember +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.RoomPlaybackState +import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.Suggestion +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class WatchPartyPresentationCoverageTest { + private fun playing( + content: String? = "movie:a", + file: Int? = 7, + position: Double = 12.5, + paused: Boolean = false, + state: RoomPlaybackState = RoomPlaybackState.Playing, + phase: RoomPhase = RoomPhase.Playing, + ) = RoomSnapshot( + roomId = "r", + phase = phase, + playbackState = state, + selectionRevision = 3L, + selectedContentId = content, + selectedFileId = file, + selectedLibraryId = 9, + isPaused = paused, + anchorPositionSeconds = position, + ) + + @Test + fun playbackContextCarriesTheRoomsExactSelection() { + val context = assertNotNull(playing().playbackContext()) + assertEquals("r", context.roomId) + assertEquals(3L, context.selectionRevision) + assertEquals("movie:a", context.contentId) + assertEquals(7, context.fileId) + assertEquals(9, context.libraryId) + assertEquals(12.5, context.positionSeconds) + assertEquals(false, context.paused) + } + + @Test + fun playbackContextIsNullUntilTheRoomIsPlayingWithAFile() { + assertNull(playing(phase = RoomPhase.Lobby).playbackContext()) + assertNull(playing(content = null).playbackContext()) + assertNull(playing(content = " ").playbackContext()) + assertNull(playing(file = null).playbackContext()) + assertNull(playing(file = 0).playbackContext()) + } + + @Test + fun playbackContextStartsPausedUnlessTheRoomIsPlaying() { + assertTrue(assertNotNull(playing(paused = true).playbackContext()).paused) + assertTrue(assertNotNull(playing(state = RoomPlaybackState.Paused).playbackContext()).paused) + assertEquals(0.0, assertNotNull(playing(position = -1.0).playbackContext()).positionSeconds) + assertEquals(0.0, assertNotNull(playing(position = Double.NaN).playbackContext()).positionSeconds) + } + + private val host = RoomMember(userId = "1", profileId = "a", displayName = "Ana", isHost = true, connected = true) + private val guest = RoomMember(userId = "2", profileId = "b", displayName = "ben", connected = true) + + @Test + fun seatStatesAndStatusWords() { + val phase = RoomPhase.Playing + assertEquals(WatchPartySeatState.Away, watchPartySeatState(guest.copy(connected = false), phase)) + assertEquals(WatchPartySeatState.Buffering, watchPartySeatState(guest.copy(isBuffering = true), phase)) + assertEquals(WatchPartySeatState.Syncing, watchPartySeatState(guest.copy(isSyncing = true), phase)) + assertEquals(WatchPartySeatState.Watching, watchPartySeatState(guest.copy(isReady = true), phase)) + assertEquals(WatchPartySeatState.Joining, watchPartySeatState(guest, phase)) + assertEquals(WatchPartySeatState.Ready, watchPartySeatState(host, RoomPhase.Lobby)) + assertEquals(WatchPartySeatState.NotReady, watchPartySeatState(guest, RoomPhase.Lobby)) + + assertNull(watchPartySeatStatus(WatchPartySeatState.Ready)) + assertNull(watchPartySeatStatus(WatchPartySeatState.Watching)) + assertEquals("not ready", watchPartySeatStatus(WatchPartySeatState.NotReady)) + assertEquals("reconnecting", watchPartySeatStatus(WatchPartySeatState.Away)) + assertEquals("syncing", watchPartySeatStatus(WatchPartySeatState.Syncing)) + assertEquals("buffering", watchPartySeatStatus(WatchPartySeatState.Buffering)) + assertEquals("joining", watchPartySeatStatus(WatchPartySeatState.Joining)) + } + + @Test + fun seatOrderAndPresenceSummary() { + val self = guest.copy(userId = "3", displayName = "Zed", isSelf = true) + val other = guest.copy(userId = "4", displayName = "Cy") + assertEquals(listOf("Ana", "Zed", "Cy"), watchPartySeatOrder(listOf(other, self, host)).map { it.displayName }) + + assertEquals("nobody connected", watchPartyPresenceSummary(emptyList(), RoomPhase.Lobby)) + assertEquals("only you so far", watchPartyPresenceSummary(listOf(host), RoomPhase.Lobby)) + assertEquals("1 of 2 ready", watchPartyPresenceSummary(listOf(host, guest), RoomPhase.Lobby)) + assertEquals("1 of 2 watching", watchPartyPresenceSummary(listOf(host.copy(isReady = true), guest), RoomPhase.Playing)) + } + + private fun lobby( + role: MemberRole, + mode: RoomSelectionMode = RoomSelectionMode.HostPick, + content: String? = null, + phase: RoomPhase = RoomPhase.Lobby, + hostConnected: Boolean = true, + ) = RoomSnapshot( + roomId = "r", + phase = phase, + selfRole = role, + selfCanManageRoom = role == MemberRole.Host, + selectionMode = mode, + selectedContentId = content, + hostConnected = hostConnected, + members = listOf(host.copy(isSelf = role == MemberRole.Host), guest.copy(isSelf = role == MemberRole.Guest)), + ) + + private fun suggestion(id: String) = Suggestion( + id = id, roomId = "r", contentId = "movie:$id", contentType = "movie", title = id, + voteCount = 0, createdAt = "2026-09-25T10:00:00Z", + ) + + @Test + fun lobbyHeroForVotingChosenAndOpenRooms() { + val vote = lobby(MemberRole.Host, mode = RoomSelectionMode.Vote) + assertTrue(watchPartyLobbyHero(vote, listOf(suggestion("a"))).isVoteQuestion) + val one = watchPartyLobbyHero(vote, listOf(suggestion("a"))).eyebrow + val two = watchPartyLobbyHero(vote, listOf(suggestion("a"), suggestion("b"))).eyebrow + assertTrue(one.contains("1 title") && !one.contains("titles")) + assertTrue(two.contains("2 titles")) + assertTrue(watchPartyLobbyHero(lobby(MemberRole.Guest, mode = RoomSelectionMode.Vote), emptyList()).supporting!!.contains("vote")) + + val chosenByYou = watchPartyLobbyHero(lobby(MemberRole.Host, content = "movie:a"), emptyList(), selectionUnavailable = true) + assertEquals("You chose this", chosenByYou.supporting) + assertEquals("A title you can't see", chosenByYou.title) + assertTrue(watchPartyLobbyHero(lobby(MemberRole.Guest, content = "movie:a"), emptyList()).supporting!!.endsWith("chose this")) + assertTrue(watchPartyLobbyHero(lobby(MemberRole.Guest, content = "movie:a", phase = RoomPhase.Playing), emptyList()).eyebrow.startsWith("Now watching")) + + assertEquals("Pick something\nto watch", watchPartyLobbyHero(lobby(MemberRole.Host), emptyList()).title) + assertTrue(watchPartyLobbyHero(lobby(MemberRole.Guest), emptyList()).title!!.startsWith("Waiting for")) + } + + @Test + fun connectionLabelAndBanner() { + val open = lobby(MemberRole.Host) + assertEquals(WatchPartyConnectionTone.Good to "2 here", watchPartyConnectionLabel(open, connected = true, reconnecting = false)) + val solo = open.copy(members = listOf(host)) + assertEquals(WatchPartyConnectionTone.Good to "Open", watchPartyConnectionLabel(solo, connected = true, reconnecting = false)) + val hostGone = lobby(MemberRole.Guest, hostConnected = false) + assertEquals(WatchPartyConnectionTone.Warning to "Host away", watchPartyConnectionLabel(hostGone, connected = true, reconnecting = false)) + assertEquals(WatchPartyConnectionTone.Warning to "Reconnecting", watchPartyConnectionLabel(open, connected = false, reconnecting = true)) + assertEquals(WatchPartyConnectionTone.Neutral to "Connecting", watchPartyConnectionLabel(open, connected = false, reconnecting = false)) + + assertTrue(watchPartyLobbyBanner(open, selectionUnavailable = true, reconnecting = false)!!.contains("Choose something else")) + assertTrue(watchPartyLobbyBanner(lobby(MemberRole.Guest), selectionUnavailable = true, reconnecting = false)!!.contains("pick something else")) + assertTrue(watchPartyLobbyBanner(hostGone, selectionUnavailable = false, reconnecting = false)!!.contains("lost connection")) + assertEquals("Reconnecting to the party…", watchPartyLobbyBanner(open, selectionUnavailable = false, reconnecting = true)) + assertNull(watchPartyLobbyBanner(open, selectionUnavailable = false, reconnecting = false)) + } + + @Test + fun avatarIndexIsStableAndBounded() { + assertEquals(0, watchPartyAvatarIndex(guest, 0)) + val index = watchPartyAvatarIndex(guest, 6) + assertTrue(index in 0..5) + assertEquals(index, watchPartyAvatarIndex(guest, 6)) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyWireTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyWireTest.kt new file mode 100644 index 0000000000..1d9d8a34fd --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyWireTest.kt @@ -0,0 +1,253 @@ +package org.prairieserver.prairie.watchtogether + +import org.prairieserver.prairie.model.notifications.WsTicketResponse +import org.prairieserver.prairie.model.watchtogether.RoomPlaybackState +import org.prairieserver.prairie.model.watchtogether.Suggestion +import org.prairieserver.prairie.model.watchtogether.TransportAction +import org.prairieserver.prairie.model.watchtogether.WatchTogetherCapabilitiesV2 +import org.prairieserver.prairie.model.watchtogether.WsStateReport +import org.prairieserver.prairie.model.watchtogether.readWatchPartyFixture +import org.prairieserver.prairie.network.RoomRealtimeEvent +import org.prairieserver.prairie.network.PrairieJson +import org.prairieserver.prairie.network.apiv2.PlaybackCapabilitiesV2 +import org.prairieserver.prairie.network.decodeRoomFrame +import org.prairieserver.prairie.network.validRoomTicket +import org.prairieserver.prairie.util.formatEpochMillisRfc3339 +import org.prairieserver.prairie.util.parseRfc3339ToEpochMillis +import kotlinx.serialization.json.jsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class WatchPartyWireTest { + + private fun frame(name: String, receivedAtMs: Long? = null) = + decodeRoomFrame(PrairieJson, readWatchPartyFixture(name), receivedAtMs) + + // ---- socket frames ----------------------------------------------------------- + + @Test + fun `socket snapshot decodes numeric ids and catching-up state`() { + val room = assertIs<RoomRealtimeEvent.SnapshotEvent>(frame("socket_snapshot.json")).room + assertEquals(57, room.selectedFileId) + assertEquals("12", room.members.first().userId) + assertFalse(room.hostConnected) + assertTrue(room.selfIgnoreWait) + assertTrue(room.selfMember!!.isBuffering) + assertTrue(room.selfCatchingUp) + } + + @Test + fun `catching up requires a playing or paused room`() { + val room = assertIs<RoomRealtimeEvent.SnapshotEvent>(frame("socket_snapshot.json")).room + assertFalse(room.copy(playbackState = RoomPlaybackState.Waiting).selfCatchingUp) + assertFalse(room.copy(selfIgnoreWait = false, members = room.members.map { it.copy(isBuffering = false) }).selfCatchingUp) + } + + @Test + fun `transport command decodes its command id and schedule`() { + val command = assertIs<RoomRealtimeEvent.TransportCommandEvent>(frame("socket_transport_command.json")).command + assertEquals("cmd_5b2e", command.commandId) + assertEquals(TransportAction.Seek, command.action) + assertEquals(RoomPlaybackState.Waiting, command.playbackState) + assertEquals(parseRfc3339ToEpochMillis("2026-09-24T18:11:02.250Z"), parseRfc3339ToEpochMillis(command.executeAt)) + } + + @Test + fun `suggestion broadcast decodes numeric suggester ids`() { + val list = assertIs<RoomRealtimeEvent.SuggestionsEvent>(frame("socket_suggestions_update.json")).suggestions + assertEquals(listOf("31", "12"), list.map { it.suggesterUserId }) + assertTrue(list.none { it.votedByMe }) + } + + @Test + fun `connection replaced and room closed are distinct terminal frames`() { + val replaced = assertIs<RoomRealtimeEvent.ConnectionReplaced>(frame("socket_connection_replaced.json")) + assertEquals("This profile joined the Watch Party on another device.", replaced.reason) + assertEquals("host_left", assertIs<RoomRealtimeEvent.Closed>(frame("socket_room_closed.json")).reason) + } + + @Test + fun `pong carries the receipt time stamped at the socket`() { + val pong = assertIs<RoomRealtimeEvent.Pong>(frame("socket_pong.json", receivedAtMs = 42L)) + assertEquals(42L, pong.clientReceivedMs) + assertEquals("2026-09-24T18:00:00.240Z", pong.serverReceivedAt) + } + + @Test + fun `unknown frames are ignored and malformed known frames are reported`() { + assertNull(decodeRoomFrame(PrairieJson, """{"type":"reaction","emoji":"x"}""")) + assertNull(decodeRoomFrame(PrairieJson, "not json")) + assertIs<RoomRealtimeEvent.Malformed>(decodeRoomFrame(PrairieJson, """{"type":"snapshot","room":"nope"}""")) + assertIs<RoomRealtimeEvent.Malformed>(decodeRoomFrame(PrairieJson, """{"type":"snapshot","room":{"room_id":""}}""")) + assertIs<RoomRealtimeEvent.Malformed>(decodeRoomFrame(PrairieJson, """{"type":"transport_command"}""")) + assertIs<RoomRealtimeEvent.Malformed>(decodeRoomFrame(PrairieJson, """{"type":"suggestions_update","suggestions":[{}]}""")) + } + + @Test + fun `state reports carry a command only when they acknowledge readiness`() { + fun keys(report: WsStateReport) = + PrairieJson.parseToJsonElement(PrairieJson.encodeToString(WsStateReport.serializer(), report)).jsonObject.keys + assertEquals( + setOf("type", "session_id", "position_seconds", "is_paused"), + keys(WsStateReport(sessionId = "ps", positionSeconds = 1.0, isPaused = false)), + ) + assertEquals( + setOf("type", "session_id", "position_seconds", "is_paused", "command_id", "is_ready"), + keys(WsStateReport(sessionId = "ps", positionSeconds = 1.0, isPaused = false, commandId = "c", isReady = true)), + ) + } + + @Test + fun `room tickets must fit the server bounds and a header`() { + val ok = WsTicketResponse(ticket = "abc_DEF-1.2", expiresIn = 30, maxConnectionSeconds = 300, protocol = "prairie.room.v2") + assertTrue(validRoomTicket(ok)) + assertTrue(validRoomTicket(ok.copy(expiresIn = 0))) + assertFalse(validRoomTicket(ok.copy(expiresIn = 31))) + assertFalse(validRoomTicket(ok.copy(maxConnectionSeconds = 0))) + assertFalse(validRoomTicket(ok.copy(maxConnectionSeconds = 301))) + assertFalse(validRoomTicket(ok.copy(protocol = "prairie.events.v2"))) + assertFalse(validRoomTicket(ok.copy(ticket = ""))) + assertFalse(validRoomTicket(ok.copy(ticket = "a b"))) + } + + // ---- capabilities ------------------------------------------------------------ + + private val roomCaps = PrairieJson.decodeFromString( + WatchTogetherCapabilitiesV2.serializer(), + readWatchPartyFixture("capabilities.json"), + ).copy(allowed = true) + private val playbackCaps = PlaybackCapabilitiesV2( + state = "available", + allowed = true, + protocolVersions = listOf(3), + features = listOf( + WatchPartyPlaybackFeatures.Coordinator, + WatchPartyPlaybackFeatures.FixedMediaFile, + WatchPartyPlaybackFeatures.SourceFallback, + ), + ) + + @Test + fun `a fully capable server makes every optional feature available`() { + val features = assertIs<WatchPartyAvailability.Available>(watchPartyAvailability(roomCaps, playbackCaps)).features + assertTrue(features.stagedSelection && features.stopPlayback && features.selectionModeSwitch) + assertTrue(features.lobbyReady && features.picker && features.memberState && features.voteHostOverride) + assertTrue(features.sourceFallback) + assertEquals(200, features.maxMemberStateIds) + } + + @Test + fun `entry requires the v2 socket, replacement signal, coordinator and fixed file`() { + fun missing(room: WatchTogetherCapabilitiesV2 = roomCaps, playback: PlaybackCapabilitiesV2 = playbackCaps) = + assertIs<WatchPartyAvailability.Unsupported>(watchPartyAvailability(room, playback)).missing + assertEquals(listOf("room_socket"), missing(room = roomCaps.copy(socketProtocol = ""))) + assertEquals(listOf("connection_replaced"), missing(room = roomCaps.copy(connectionReplaced = false))) + assertEquals(listOf("rooms"), missing(room = roomCaps.copy(state = "not_configured"))) + assertEquals( + listOf(WatchPartyPlaybackFeatures.Coordinator), + missing(playback = playbackCaps.copy(features = listOf(WatchPartyPlaybackFeatures.FixedMediaFile))), + ) + assertEquals( + listOf(WatchPartyPlaybackFeatures.FixedMediaFile), + missing(playback = playbackCaps.copy(features = listOf(WatchPartyPlaybackFeatures.Coordinator))), + ) + } + + @Test + fun `an account that may not use rooms is not allowed rather than unsupported`() { + assertEquals(WatchPartyAvailability.NotAllowed, watchPartyAvailability(roomCaps.copy(allowed = false), playbackCaps)) + assertEquals(WatchPartyAvailability.NotAllowed, watchPartyAvailability(roomCaps, playbackCaps.copy(allowed = false))) + } + + @Test + fun `optional flags gate independently`() { + val features = assertIs<WatchPartyAvailability.Available>( + watchPartyAvailability( + roomCaps.copy(stopPlayback = false, voteHostOverride = false, memberState = false, maxMemberStateIds = 0), + playbackCaps.copy(features = playbackCaps.features - WatchPartyPlaybackFeatures.SourceFallback), + ), + ).features + assertFalse(features.stopPlayback) + assertFalse(features.voteHostOverride) + assertFalse(features.memberState) + assertEquals(0, features.maxMemberStateIds) + assertFalse(features.sourceFallback) + assertTrue(features.stagedSelection) + } + + // ---- clock ------------------------------------------------------------------- + + @Test + fun `clock keeps the lowest round trip sample`() { + val clock = RoomClockEstimator() + // Offset 200, round trip 300 (queued). + assertTrue(clock.record(0, 350, 350, 300, monotonicNowMs = 300)) + // Offset 200, round trip 40. + assertTrue(clock.record(1_000, 1_220, 1_220, 1_040, monotonicNowMs = 1_040)) + assertEquals(200L, clock.estimate.offsetMs) + assertEquals(40L, clock.estimate.rttMs) + } + + @Test + fun `clock rejects impossible or implausible samples`() { + val clock = RoomClockEstimator() + assertFalse(clock.record(100, 0, 0, 50, monotonicNowMs = 50)) + assertFalse(clock.record(0, 10, 5, 20, monotonicNowMs = 20)) + assertFalse(clock.record(0, 3_000, 3_000, 6_000, monotonicNowMs = 6_000)) + assertNull(clock.estimate.offsetMs) + } + + @Test + fun `a wall clock jump discards earlier samples`() { + val clock = RoomClockEstimator() + clock.record(0, 220, 220, 40, monotonicNowMs = 40) + val generation = clock.estimate.generation + + assertTrue(clock.checkContinuity(wallNowMs = 60_000, monotonicNowMs = 1_000)) + + assertNull(clock.estimate.offsetMs) + assertTrue(clock.estimate.generation > generation) + assertFalse(clock.checkContinuity(wallNowMs = 61_000, monotonicNowMs = 2_000)) + } + + // ---- proof, time, ranking ----------------------------------------------------- + + @Test + fun `room proof expiry is read from the token`() { + val token = PrairieJson.parseToJsonElement(readWatchPartyFixture("room_http.json")).jsonObject["room_access_token"]!! + assertEquals(1_790_461_331_000L, roomProofExpiryMs(token.toString().trim('"'))) + assertNull(roomProofExpiryMs("jwt-room")) + assertNull(roomProofExpiryMs("a.%%%.c")) + assertEquals("RoomProof(token=<redacted>, expiresAtEpochMs=null)", RoomProof("secret", null).toString()) + } + + @Test + fun `timestamps round trip through the formatter`() { + for (ms in listOf(0L, 1_790_461_331_123L, 951_782_400_000L, -1L)) { + assertEquals(ms, parseRfc3339ToEpochMillis(formatEpochMillisRfc3339(ms))) + } + assertEquals("2026-09-24T18:00:00.240Z", formatEpochMillisRfc3339(parseRfc3339ToEpochMillis("2026-09-24T18:00:00.240Z")!!)) + } + + private fun suggestion(id: String, votes: Int, createdAt: String) = Suggestion( + id = id, roomId = "r", contentId = id, contentType = "movie", title = id, voteCount = votes, createdAt = createdAt, + ) + + @Test + fun `suggestions rank by votes then age regardless of arrival order`() { + val ranked = rankSuggestions( + listOf( + suggestion("new-tie", 2, "2026-09-24T18:00:00Z"), + suggestion("unvoted", 0, "2026-09-24T17:00:00Z"), + suggestion("old-tie", 2, "2026-09-24T17:30:00Z"), + ), + ) + assertEquals(listOf("old-tie", "new-tie", "unvoted"), ranked.map { it.id }) + assertEquals("old-tie", roomVoteWinner(ranked.reversed())?.id) + assertNull(roomVoteWinner(listOf(suggestion("unvoted", 0, "2026-09-24T17:00:00Z")))) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryPolicyTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryPolicyTest.kt deleted file mode 100644 index d132f04747..0000000000 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryPolicyTest.kt +++ /dev/null @@ -1,57 +0,0 @@ -package org.prairieserver.prairie.watchtogether - -import org.prairieserver.prairie.model.watchtogether.MemberRole -import org.prairieserver.prairie.model.watchtogether.RoomPhase -import org.prairieserver.prairie.model.watchtogether.RoomSnapshot -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull - -class WatchTogetherEntryPolicyTest { - @Test - fun selectedGuestRoutesToPlayer() { - val room = RoomSnapshot( - roomId = "room-1", - selectedContentId = "movie-1", - selfRole = MemberRole.Guest, - memberCount = 2, - ) - assertEquals(WatchTogetherEntryTarget.Player, watchTogetherEntryTarget(room)) - } - - @Test - fun emptyRoomAndSoloHostRouteToLobby() { - assertEquals( - WatchTogetherEntryTarget.Lobby, - watchTogetherEntryTarget(RoomSnapshot(roomId = "room-1")), - ) - assertEquals( - WatchTogetherEntryTarget.Lobby, - watchTogetherEntryTarget( - RoomSnapshot( - roomId = "room-1", - selectedContentId = "movie-1", - selfRole = MemberRole.Host, - memberCount = 1, - ), - ), - ) - } - - @Test - fun onlyNonTerminalNonBlankRoomIsResumable() { - assertNull(resumableWatchTogetherRoom(null)) - assertNull(resumableWatchTogetherRoom(RoomSnapshot(roomId = ""))) - assertNull( - resumableWatchTogetherRoom( - RoomSnapshot(roomId = "room-1", phase = RoomPhase.Ended), - ), - ) - assertEquals( - "room-1", - resumableWatchTogetherRoom( - RoomSnapshot(roomId = "room-1", phase = RoomPhase.Lobby), - )?.roomId, - ) - } -} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/SOURCE b/shared/src/commonTest/resources/api/v2/fixtures/SOURCE index 07a62d13da..7ad3821194 100644 --- a/shared/src/commonTest/resources/api/v2/fixtures/SOURCE +++ b/shared/src/commonTest/resources/api/v2/fixtures/SOURCE @@ -1,9 +1,15 @@ repository=https://github.com/Silo-Server/silo-server path=contracts/api/v2/fixtures -commit=83ac7a9b3da25914a04176185fe4f3a0a98eab45 +commit=51953b6c138a45aaca05a81b3234ef12d4bdd9c8 ref=HEAD +snapshot=committed api_major=2 +snapshot=uncommitted means the bodies were copied from a server checkout with +uncommitted fixture changes on top of that commit, so the commit alone does +not reproduce them. Re-vendor from a server commit that contains them before +relying on this record. + Byte-identical copies of a subset of the server's native API v2 contract fixtures; do not hand-edit any of them. The server generates them through its real v2 router (make apiv2-fixtures) and validates the bodies against the diff --git a/shared/src/commonTest/resources/api/v2/fixtures/cancel_device_login_ok.json b/shared/src/commonTest/resources/api/v2/fixtures/cancel_device_login_ok.json new file mode 100644 index 0000000000..9e3a84b7d6 --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/cancel_device_login_ok.json @@ -0,0 +1,3 @@ +{ + "status": "canceled" +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/complete_account_identity_link_invalid_grant.json b/shared/src/commonTest/resources/api/v2/fixtures/complete_account_identity_link_invalid_grant.json new file mode 100644 index 0000000000..781a7cb406 --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/complete_account_identity_link_invalid_grant.json @@ -0,0 +1,7 @@ +{ + "type": "https://prairieserver.org/docs/api/v2/problems/invalid_grant", + "title": "Invalid grant", + "status": 400, + "detail": "The code_verifier does not fit this link code.", + "instance": "urn:prairie:request:0000000000000000000002c7" +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/complete_oauth_login_invalid_grant.json b/shared/src/commonTest/resources/api/v2/fixtures/complete_oauth_login_invalid_grant.json new file mode 100644 index 0000000000..47254bb2e4 --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/complete_oauth_login_invalid_grant.json @@ -0,0 +1,7 @@ +{ + "type": "https://prairieserver.org/docs/api/v2/problems/invalid_grant", + "title": "Invalid grant", + "status": 400, + "detail": "The code_verifier does not fit this completion code.", + "instance": "urn:prairie:request:0000000000000000000002c1" +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/complete_oauth_login_native_ok.json b/shared/src/commonTest/resources/api/v2/fixtures/complete_oauth_login_native_ok.json new file mode 100644 index 0000000000..bc9e2164bb --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/complete_oauth_login_native_ok.json @@ -0,0 +1,17 @@ +{ + "access_token": "acc", + "refresh_token": "ref", + "expires_in": 3600, + "next": "/", + "user": { + "id": "1", + "username": "laura", + "email": "laura@example.test", + "role": "user", + "permissions": [ + "marker_edit" + ], + "download_allowed": true, + "password_change_required": false + } +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/create_account_identity_link_ticket_ok.json b/shared/src/commonTest/resources/api/v2/fixtures/create_account_identity_link_ticket_ok.json new file mode 100644 index 0000000000..c123915d87 --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/create_account_identity_link_ticket_ok.json @@ -0,0 +1,4 @@ +{ + "ticket": "9d2c5f0e8b7a41c3a6e5d4c3b2a19087f6e5d4c3b2a1908f7e6d5c4b3a291807", + "expires_at": "2026-01-02T03:09:05.678Z" +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/create_account_identity_link_ticket_wrong_password.json b/shared/src/commonTest/resources/api/v2/fixtures/create_account_identity_link_ticket_wrong_password.json new file mode 100644 index 0000000000..5e69811ed5 --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/create_account_identity_link_ticket_wrong_password.json @@ -0,0 +1,14 @@ +{ + "type": "https://prairieserver.org/docs/api/v2/problems/validation_failed", + "title": "Validation failed", + "status": 422, + "detail": "The request did not pass validation; see errors.", + "instance": "urn:prairie:request:0000000000000000000002c3", + "errors": [ + { + "location": "body.password", + "code": "invalid", + "detail": "The password is incorrect." + } + ] +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/delete_account_identity_last_sign_in_method.json b/shared/src/commonTest/resources/api/v2/fixtures/delete_account_identity_last_sign_in_method.json new file mode 100644 index 0000000000..d2de86fcff --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/delete_account_identity_last_sign_in_method.json @@ -0,0 +1,7 @@ +{ + "type": "https://prairieserver.org/docs/api/v2/problems/last_sign_in_method", + "title": "Last sign-in method", + "status": 409, + "detail": "The account has no other way to sign in; ask an administrator to set a password for it first", + "instance": "urn:prairie:request:0000000000000000000002b8" +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/get_device_login_capability_ok.json b/shared/src/commonTest/resources/api/v2/fixtures/get_device_login_capability_ok.json new file mode 100644 index 0000000000..7e1e93602e --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/get_device_login_capability_ok.json @@ -0,0 +1,10 @@ +{ + "revision": "620a144f15ea20c4b95fc4a79b8bb310bc35997a9a18d070f7d708f1cc0aab2f", + "state": "available", + "remote_playback_handoff": true, + "protocol_versions": [ + 2 + ], + "cancel": true, + "opened_signal": true +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/get_device_login_ok.json b/shared/src/commonTest/resources/api/v2/fixtures/get_device_login_ok.json new file mode 100644 index 0000000000..74aeff3317 --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/get_device_login_ok.json @@ -0,0 +1,14 @@ +{ + "status": "pending", + "user_code": "4821-7730", + "match_code": "warm pony", + "device_name": "Living room TV", + "device_platform": "tvos", + "ip_address_hint": "192.168.1.x", + "expires_at": "2026-01-02T03:14:05.678Z", + "requested_at": "2026-01-02T03:04:05.678Z", + "client_purpose": "device_login", + "temporary": false, + "server_id": "3f2a9d5e-6b1c-4c7e-9a0d-2f4b8c1e7a35", + "server_name": "Prairie" +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/get_external_sign_in_capabilities_ok.json b/shared/src/commonTest/resources/api/v2/fixtures/get_external_sign_in_capabilities_ok.json new file mode 100644 index 0000000000..9279d622e3 --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/get_external_sign_in_capabilities_ok.json @@ -0,0 +1,14 @@ +{ + "revision": "7adbf3cc98d9654da106868d0c95ee6205d4a822876cd50b9d27015adad0f691", + "state": "available", + "available": true, + "identities": true, + "admin_identities": true, + "break_glass": true, + "connection_test": true, + "live_provider_changes": true, + "provider_recheck": true, + "credentials_linking": true, + "network_sign_in": true, + "network_link_keeps_password": true +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/get_oauth_handshake_capabilities_ok.json b/shared/src/commonTest/resources/api/v2/fixtures/get_oauth_handshake_capabilities_ok.json new file mode 100644 index 0000000000..77c35a38cf --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/get_oauth_handshake_capabilities_ok.json @@ -0,0 +1,9 @@ +{ + "revision": "536468a609e2f1c6316fa61622c8d9fad4b447d965efcf21e5d7c979dfdbd605", + "state": "available", + "available": true, + "native": true, + "linking": true, + "provider_logout": true, + "select_account": true +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/get_system_info_ok.json b/shared/src/commonTest/resources/api/v2/fixtures/get_system_info_ok.json index 1b092beaaf..ca103e93ab 100644 --- a/shared/src/commonTest/resources/api/v2/fixtures/get_system_info_ok.json +++ b/shared/src/commonTest/resources/api/v2/fixtures/get_system_info_ok.json @@ -1,7 +1,7 @@ { "server_version": "unavailable", "api_major": 2, - "contract_digest": "b0aa2de57df576605f4fa662ab418004140bca91b115c8ce6aadea60c12518bd", + "contract_digest": "0000000000000000000000000000000000000000000000000000000000000000", "links": { "openapi": "/api/v2/openapi.json", "capabilities": "/api/v2/capabilities", diff --git a/shared/src/commonTest/resources/api/v2/fixtures/index.json b/shared/src/commonTest/resources/api/v2/fixtures/index.json index 81face8fa0..f968903c1d 100644 --- a/shared/src/commonTest/resources/api/v2/fixtures/index.json +++ b/shared/src/commonTest/resources/api/v2/fixtures/index.json @@ -18,6 +18,143 @@ "schema": "#/components/schemas/Problem", "body_file": "authentication_required.json" }, + { + "name": "cancel_device_login_ok", + "operation_id": "cancelDeviceLogin", + "scenario": "The device leaves its sign-in screen and withdraws its request, so the code can no longer be approved.", + "request": { + "method": "POST", + "path": "/api/v2/auth/device/cancel", + "body": "{\"device_code\":\"dev-pending\"}" + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/DeviceLoginCancel", + "body_file": "cancel_device_login_ok.json" + }, + { + "name": "complete_account_identity_link_invalid_grant", + "operation_id": "completeAccountIdentityLink", + "scenario": "A link code redeemed with a verifier that does not fit is refused and stays redeemable.", + "request": { + "method": "POST", + "path": "/api/v2/account/identities/link-complete", + "headers": { + "Authorization": "Bearer tok-member" + }, + "body": "{\"code\":\"l1nk\",\"code_verifier\":\"wrong-verifier-wrong-verifier-wrong-verifier-x\"}" + }, + "expected_status": 400, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/problem+json" + }, + "response_media_type": "application/problem+json", + "schema": "#/components/schemas/Problem", + "body_file": "complete_account_identity_link_invalid_grant.json" + }, + { + "name": "complete_oauth_login_invalid_grant", + "operation_id": "completeOAuthLogin", + "scenario": "A native code redeemed without its code verifier is refused and stays redeemable.", + "request": { + "method": "POST", + "path": "/api/v2/auth/oauth/complete", + "body": "{\"code\":\"n4tive\"}" + }, + "expected_status": 400, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/problem+json" + }, + "response_media_type": "application/problem+json", + "schema": "#/components/schemas/Problem", + "body_file": "complete_oauth_login_invalid_grant.json" + }, + { + "name": "complete_oauth_login_native_ok", + "operation_id": "completeOAuthLogin", + "scenario": "A native app redeems the code the app redirect carried with its PKCE code verifier.", + "request": { + "method": "POST", + "path": "/api/v2/auth/oauth/complete", + "body": "{\"code\":\"n4tive\",\"code_verifier\":\"dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk\"}" + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/OAuthCompletion", + "body_file": "complete_oauth_login_native_ok.json" + }, + { + "name": "create_account_identity_link_ticket_ok", + "operation_id": "createAccountIdentityLinkTicket", + "scenario": "The account confirms its password and gets a ticket that starts a provider linking flow.", + "request": { + "method": "POST", + "path": "/api/v2/account/identities/link-ticket", + "headers": { + "Authorization": "Bearer tok-member" + }, + "body": "{\"installation_id\":\"3\",\"password\":\"right password\"}" + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/AccountIdentityLinkTicket", + "body_file": "create_account_identity_link_ticket_ok.json" + }, + { + "name": "create_account_identity_link_ticket_wrong_password", + "operation_id": "createAccountIdentityLinkTicket", + "scenario": "A wrong password is a validation problem at the password member.", + "request": { + "method": "POST", + "path": "/api/v2/account/identities/link-ticket", + "headers": { + "Authorization": "Bearer tok-member" + }, + "body": "{\"installation_id\":\"3\",\"password\":\"wrong password\"}" + }, + "expected_status": 422, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/problem+json" + }, + "response_media_type": "application/problem+json", + "schema": "#/components/schemas/Problem", + "body_file": "create_account_identity_link_ticket_wrong_password.json" + }, + { + "name": "delete_account_identity_last_sign_in_method", + "operation_id": "deleteAccountIdentity", + "scenario": "Disconnecting the only way the account can sign in is refused.", + "request": { + "method": "DELETE", + "path": "/api/v2/account/identities/4", + "headers": { + "Authorization": "Bearer tok-member" + } + }, + "expected_status": 409, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/problem+json" + }, + "response_media_type": "application/problem+json", + "schema": "#/components/schemas/Problem", + "body_file": "delete_account_identity_last_sign_in_method.json" + }, { "name": "get_current_user_ok", "operation_id": "getCurrentUser", @@ -38,6 +175,74 @@ "schema": "#/components/schemas/Account", "body_file": "get_current_user_ok.json" }, + { + "name": "get_device_login_capability_ok", + "operation_id": "getDeviceLoginCapability", + "scenario": "Device pairing support before login.", + "request": { + "method": "GET", + "path": "/api/v2/auth/device/capability" + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "private, no-cache", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/DeviceLoginCapability", + "body_file": "get_device_login_capability_ok.json" + }, + { + "name": "get_device_login_ok", + "operation_id": "getDeviceLogin", + "scenario": "A browser looks a pairing request up by its user code before deciding.", + "request": { + "method": "GET", + "path": "/api/v2/auth/device?code=4821-7730" + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/DeviceLogin", + "body_file": "get_device_login_ok.json" + }, + { + "name": "get_external_sign_in_capabilities_ok", + "operation_id": "getExternalSignInCapabilities", + "scenario": "An app discovers that the server links external sign-in identities and tests auth plugins.", + "request": { + "method": "GET", + "path": "/api/v2/auth/external-sign-in/capabilities" + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "private, no-cache", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/ExternalSignInCapabilities", + "body_file": "get_external_sign_in_capabilities_ok.json" + }, + { + "name": "get_oauth_handshake_capabilities_ok", + "operation_id": "getOAuthHandshakeCapabilities", + "scenario": "An app discovers that the server hands OAuth sign-in off to native apps and links identities.", + "request": { + "method": "GET", + "path": "/api/v2/auth/oauth/capabilities" + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "private, no-cache", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/OAuthHandshakeCapabilitiesOutputBody", + "body_file": "get_oauth_handshake_capabilities_ok.json" + }, { "name": "get_setup_status_ok", "operation_id": "getSetupStatus", @@ -76,6 +281,65 @@ "schema": "#/components/schemas/SystemInfo", "body_file": "get_system_info_ok.json" }, + { + "name": "link_account_identity_with_network_ok", + "operation_id": "linkAccountIdentityWithNetwork", + "scenario": "The account re-enters its password and links the Tailscale identity of the device it is using.", + "request": { + "method": "POST", + "path": "/api/v2/account/identities/link-network", + "headers": { + "Authorization": "Bearer tok-member" + }, + "body": "{\"installation_id\":\"5\",\"password\":\"right password\"}" + }, + "expected_status": 201, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/json", + "Location": "/api/v2/account/identities" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/AccountIdentity", + "body_file": "link_account_identity_with_network_ok.json" + }, + { + "name": "list_account_identities_ok", + "operation_id": "listAccountIdentities", + "scenario": "The account's Sign-in section lists the provider identity it is linked to.", + "request": { + "method": "GET", + "path": "/api/v2/account/identities", + "headers": { + "Authorization": "Bearer tok-member" + } + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/AccountIdentityCollection", + "body_file": "list_account_identities_ok.json" + }, + { + "name": "list_auth_providers_ok", + "operation_id": "listAuthProviders", + "scenario": "The sign-in providers a client may offer before login: the built-in one and a plugin-backed OAuth provider.", + "request": { + "method": "GET", + "path": "/api/v2/auth/providers" + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/AuthProviderCollection", + "body_file": "list_auth_providers_ok.json" + }, { "name": "list_progress_offset_rejected", "operation_id": "listProgress", @@ -175,6 +439,42 @@ "schema": "#/components/schemas/Problem", "body_file": "not_found.json" }, + { + "name": "poll_device_login_ok", + "operation_id": "pollDeviceLogin", + "scenario": "The device polls an approved request and collects its token pair under tokens.", + "request": { + "method": "POST", + "path": "/api/v2/auth/device/poll", + "body": "{\"device_code\":\"dev-approved\"}" + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/DeviceLoginPoll", + "body_file": "poll_device_login_ok.json" + }, + { + "name": "poll_device_login_opened", + "operation_id": "pollDeviceLogin", + "scenario": "The device polls a pending request an approver has open: it keeps its code and says to continue on the phone.", + "request": { + "method": "POST", + "path": "/api/v2/auth/device/poll", + "body": "{\"device_code\":\"dev-opened\"}" + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/DeviceLoginPoll", + "body_file": "poll_device_login_opened.json" + }, { "name": "profile_verification_required", "operation_id": null, @@ -219,6 +519,120 @@ "schema": "#/components/schemas/Problem", "body_file": "rate_limited.json" }, + { + "name": "refresh_session_provider_unavailable", + "operation_id": "refreshSession", + "scenario": "A session opened through the external sign-in provider is due for its provider re-check, the provider cannot be reached, and the outage policy is fail_closed: the session stays valid and the client retries later.", + "request": { + "method": "POST", + "path": "/api/v2/auth/refresh", + "body": "{\"refresh_token\":\"provider-down\"}" + }, + "expected_status": 503, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/problem+json" + }, + "response_media_type": "application/problem+json", + "schema": "#/components/schemas/Problem", + "body_file": "refresh_session_provider_unavailable.json" + }, + { + "name": "server_connections_ok", + "operation_id": "getServerConnections", + "scenario": "A signed-in client reads the addresses the deployment offers: the public URL, a connected provider with its overlay origin, and an installed provider whose plugin is not running.", + "request": { + "method": "GET", + "path": "/api/v2/system/connections", + "headers": { + "Authorization": "Bearer tok-member" + } + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "private, no-cache", + "Content-Type": "application/json", + "ETag": "\"251d40c6578ba4f568f974b66faed17c5fd6ea82251fdc08ebc30e12c2be477f\"" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/ServerConnectionsDocument", + "body_file": "server_connections_ok.json" + }, + { + "name": "server_identity_ok", + "operation_id": "getServerIdentity", + "scenario": "Discovery before login: the deployment's stable server identity, the same at every address.", + "request": { + "method": "GET", + "path": "/api/v2/system/identity" + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "no-cache", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/ServerIdentity", + "body_file": "server_identity_ok.json" + }, + { + "name": "sign_in_with_network_identity_off_overlay", + "operation_id": "signInWithNetworkIdentity", + "scenario": "A request that did not come through the provider's network has no device owner to sign in.", + "request": { + "method": "POST", + "path": "/api/v2/auth/network/7/sign-in", + "body": "{}" + }, + "expected_status": 403, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/problem+json" + }, + "response_media_type": "application/problem+json", + "schema": "#/components/schemas/Problem", + "body_file": "sign_in_with_network_identity_off_overlay.json" + }, + { + "name": "sign_in_with_network_identity_ok", + "operation_id": "signInWithNetworkIdentity", + "scenario": "A TV on the server's Tailscale network signs its owner in with no password: the body is an empty JSON object.", + "request": { + "method": "POST", + "path": "/api/v2/auth/network/5/sign-in", + "body": "{}" + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/TokenPair", + "body_file": "sign_in_with_network_identity_ok.json" + }, + { + "name": "start_device_login_ok", + "operation_id": "startDeviceLogin", + "scenario": "A device opens a pairing request: the codes it shows and polls with, and the request's expiry.", + "request": { + "method": "POST", + "path": "/api/v2/auth/device/start", + "headers": { + "X-Forwarded-Host": "prairie.example.test", + "X-Forwarded-Proto": "https" + }, + "body": "{\"device_name\":\"Living room TV\",\"device_platform\":\"tvos\"}" + }, + "expected_status": 201, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/DeviceLoginStart", + "body_file": "start_device_login_ok.json" + }, { "name": "update_profile_null_not_clearable", "operation_id": "updateProfile", diff --git a/shared/src/commonTest/resources/api/v2/fixtures/link_account_identity_with_network_ok.json b/shared/src/commonTest/resources/api/v2/fixtures/link_account_identity_with_network_ok.json new file mode 100644 index 0000000000..d02a3c2b39 --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/link_account_identity_with_network_ok.json @@ -0,0 +1,12 @@ +{ + "id": "8", + "installation_id": "5", + "provider_id": "plugin:5:tailscale", + "provider_name": "Tailscale", + "username": "alice@example.test", + "email": "alice@example.test", + "display_name": "Alice Example", + "linked_at": "2026-01-02T03:04:05.000Z", + "last_sign_in_at": null, + "last_checked_at": "2026-01-03T16:07:08.000Z" +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/list_account_identities_ok.json b/shared/src/commonTest/resources/api/v2/fixtures/list_account_identities_ok.json new file mode 100644 index 0000000000..0b490f5996 --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/list_account_identities_ok.json @@ -0,0 +1,17 @@ +{ + "items": [ + { + "id": "4", + "installation_id": "3", + "provider_id": "plugin:3:oidc", + "provider_name": "Company SSO", + "username": "alice", + "email": "alice@example.test", + "display_name": "Alice Example", + "linked_at": "2026-01-02T03:04:05.000Z", + "last_sign_in_at": "2026-01-03T04:05:06.000Z", + "last_checked_at": "2026-01-03T16:07:08.000Z" + } + ], + "can_unlink": false +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/list_auth_providers_ok.json b/shared/src/commonTest/resources/api/v2/fixtures/list_auth_providers_ok.json new file mode 100644 index 0000000000..bd821c1a83 --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/list_auth_providers_ok.json @@ -0,0 +1,20 @@ +{ + "items": [ + { + "id": "local", + "display_name": "Prairie account", + "mode": "credentials", + "default": true + }, + { + "id": "plugin-3", + "display_name": "Example SSO", + "mode": "oauth", + "default": false, + "icon_url": "https://plugins.example.test/icon.svg", + "installation_id": "3", + "native_start_path": "/api/v2/auth/oauth/3/native/start" + } + ], + "password_login": true +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/poll_device_login_ok.json b/shared/src/commonTest/resources/api/v2/fixtures/poll_device_login_ok.json new file mode 100644 index 0000000000..f056d6feaf --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/poll_device_login_ok.json @@ -0,0 +1,22 @@ +{ + "status": "approved", + "poll_after": 5, + "opened": false, + "tokens": { + "access_token": "acc", + "refresh_token": "ref", + "expires_in": 3600, + "user": { + "id": "1", + "username": "laura", + "email": "laura@example.test", + "role": "user", + "permissions": [], + "download_allowed": true, + "password_change_required": false + } + }, + "profile_id": "", + "profile_token": "", + "temporary": false +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/poll_device_login_opened.json b/shared/src/commonTest/resources/api/v2/fixtures/poll_device_login_opened.json new file mode 100644 index 0000000000..ef3337d606 --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/poll_device_login_opened.json @@ -0,0 +1,9 @@ +{ + "status": "pending", + "poll_after": 5, + "opened": true, + "expires_at": "2026-01-02T03:14:05.678Z", + "profile_id": "", + "profile_token": "", + "temporary": false +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/refresh_session_provider_unavailable.json b/shared/src/commonTest/resources/api/v2/fixtures/refresh_session_provider_unavailable.json new file mode 100644 index 0000000000..aef698aaed --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/refresh_session_provider_unavailable.json @@ -0,0 +1,7 @@ +{ + "type": "https://prairieserver.org/docs/api/v2/problems/provider_unavailable", + "title": "Provider unavailable", + "status": 503, + "detail": "The sign-in provider could not confirm the account. Try again later; the session stays valid.", + "instance": "urn:prairie:request:0000000000000000000002c9" +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/server_connections_ok.json b/shared/src/commonTest/resources/api/v2/fixtures/server_connections_ok.json new file mode 100644 index 0000000000..b72ecb09ea --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/server_connections_ok.json @@ -0,0 +1,28 @@ +{ + "revision": "82278f19ff5dc619091bee7b0efe5f375a0b25ec07002a424a5d15efbdc66900", + "state": "available", + "allowed": true, + "server_id": "3f2a9d5e-6b1c-4c7e-9a0d-2f4b8c1e7a35", + "current": { + "kind": "default" + }, + "endpoints": [ + { + "kind": "public", + "url": "https://prairie.example.test" + }, + { + "kind": "provider", + "provider": "down", + "display_name": "Down Overlay", + "state": "unavailable" + }, + { + "kind": "provider", + "url": "https://prairie.overlay.example.test", + "provider": "stub", + "display_name": "Stub Overlay", + "state": "connected" + } + ] +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/server_identity_ok.json b/shared/src/commonTest/resources/api/v2/fixtures/server_identity_ok.json new file mode 100644 index 0000000000..9ea4b63399 --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/server_identity_ok.json @@ -0,0 +1,3 @@ +{ + "server_id": "3f2a9d5e-6b1c-4c7e-9a0d-2f4b8c1e7a35" +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/sign_in_with_network_identity_off_overlay.json b/shared/src/commonTest/resources/api/v2/fixtures/sign_in_with_network_identity_off_overlay.json new file mode 100644 index 0000000000..f51a9806da --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/sign_in_with_network_identity_off_overlay.json @@ -0,0 +1,7 @@ +{ + "type": "https://prairieserver.org/docs/api/v2/problems/network_identity_required", + "title": "Network identity required", + "status": 403, + "detail": "Open this server through the provider's network address to sign in this way", + "instance": "urn:prairie:request:0000000000000000000002d2" +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/sign_in_with_network_identity_ok.json b/shared/src/commonTest/resources/api/v2/fixtures/sign_in_with_network_identity_ok.json new file mode 100644 index 0000000000..cde55f2cca --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/sign_in_with_network_identity_ok.json @@ -0,0 +1,16 @@ +{ + "access_token": "acc", + "refresh_token": "ref", + "expires_in": 3600, + "user": { + "id": "1", + "username": "laura", + "email": "laura@example.test", + "role": "user", + "permissions": [ + "marker_edit" + ], + "download_allowed": true, + "password_change_required": false + } +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/start_device_login_ok.json b/shared/src/commonTest/resources/api/v2/fixtures/start_device_login_ok.json new file mode 100644 index 0000000000..b1966fb7d5 --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/start_device_login_ok.json @@ -0,0 +1,14 @@ +{ + "device_code": "dev-1", + "user_code": "4821-7730", + "match_code": "warm pony", + "verification_uri": "https://prairie.example.test/activate", + "verification_uri_complete": "https://prairie.example.test/activate?code=48217730", + "expires_at": "2026-01-02T03:19:05.678Z", + "expires_in": 900, + "interval": 5, + "device_name": "Living room TV", + "device_platform": "tvos", + "client_purpose": "device_login", + "temporary": false +} diff --git a/shared/src/commonTest/resources/settings/v1/SOURCE b/shared/src/commonTest/resources/settings/v1/SOURCE index 2be9199c50..26b0cbbb43 100644 --- a/shared/src/commonTest/resources/settings/v1/SOURCE +++ b/shared/src/commonTest/resources/settings/v1/SOURCE @@ -1,6 +1,6 @@ repository=https://github.com/Silo-Server/silo-server path=contracts/settings/v1 -manifest_revision=9 +manifest_revision=17 fixture_version=1 Both files are byte-identical copies of the server's canonical contract; do not @@ -10,9 +10,9 @@ the fixture's manifest_revision, the vendored manifest's revision, and the generated SettingKeys.REVISION stop agreeing, which is the whole point of carrying the pair rather than the fixture alone. -conformance.json commit=a015631ba22e48e64d8efd4830177687610c6a50 -manifest.json commit=a015631ba22e48e64d8efd4830177687610c6a50 -copied from commit=a015631ba22e48e64d8efd4830177687610c6a50 +conformance.json commit=51953b6c138a45aaca05a81b3234ef12d4bdd9c8 +manifest.json commit=51953b6c138a45aaca05a81b3234ef12d4bdd9c8 +copied from commit=51953b6c138a45aaca05a81b3234ef12d4bdd9c8 manifest.json is vendored whole, including the maintainer `notes` the server strips before serving /api/v1/settings/contract. Keeping it byte-identical is diff --git a/shared/src/commonTest/resources/settings/v1/conformance.json b/shared/src/commonTest/resources/settings/v1/conformance.json index 0c4fd8f945..0729c73154 100644 --- a/shared/src/commonTest/resources/settings/v1/conformance.json +++ b/shared/src/commonTest/resources/settings/v1/conformance.json @@ -1,6 +1,6 @@ { "fixture_version": 1, - "manifest_revision": 9, + "manifest_revision": 17, "description": "Cross-platform conformance cases for settings resolution. Every case runs against the shipped manifest in this directory: definitions are referenced by key, never restated, so an expectation can only be satisfied by resolving the real contract. Each platform's resolver (Go in internal/settingsresolve, TypeScript in web/src/lib/settingsResolve.ts, Kotlin and Swift in the client repos) runs every case through a hand-written runner; a runner must fail on any fixture field it does not know, because schema drift in the fixture itself is drift. A case's constraint_bindings attach a constraint to a copy of a real definition so constraint semantics stay testable even while no shipped definition carries that constraint kind. In expected entries, constrained:true requires stored_value and constraint_kind to be present, and stored_value may be null to mean the authored value was JSON null.", "cases": [ { @@ -849,6 +849,7 @@ "fontSize": "large", "fontFamily": "sans-serif", "fontColor": "#ffffff", + "textOpacity": 100, "backgroundColor": "#000000", "backgroundStyle": "box", "backgroundOpacity": 75, @@ -1002,6 +1003,90 @@ "source": "default" } ] + }, + { + "name": "advisory_age_badge_is_off_until_a_profile_asks", + "description": "The advisory-age badge is per profile and off unless that profile turned it on. p1 opted in, so it resolves true from the profile; p2 stored nothing and falls to the contract default false. A profile row must never leak across profiles here, because the setting decides what a household parent sees about a child's viewing.", + "keys": ["catalog.show_advisory_age"], + "context": { "profile_id": "p2", "device_id": "d1" }, + "stored": [ + { + "key": "catalog.show_advisory_age", + "scope": "profile", + "profile_id": "p1", + "value": true + } + ], + "expected": [ + { + "key": "catalog.show_advisory_age", + "value": false, + "source": "default" + } + ] + }, + { + "name": "advisory_age_badge_resolves_from_the_opted_in_profile", + "description": "The same stored row resolves true for the profile that owns it, with source \"profile\" rather than the default.", + "keys": ["catalog.show_advisory_age"], + "context": { "profile_id": "p1", "device_id": "d1" }, + "stored": [ + { + "key": "catalog.show_advisory_age", + "scope": "profile", + "profile_id": "p1", + "value": true + } + ], + "expected": [ + { + "key": "catalog.show_advisory_age", + "value": true, + "source": "profile" + } + ] + }, + { + "name": "theme_music_is_opt_in", + "keys": ["ui.theme_music_enabled", "ui.theme_music_loop"], + "context": {"profile_id": "p1", "device_id": "browser"}, + "stored": [], + "expected": [ + {"key": "ui.theme_music_enabled", "value": false, "source": "default"}, + {"key": "ui.theme_music_loop", "value": false, "source": "default"} + ] + }, + { + "name": "theme_music_device_overrides_profile", + "keys": ["ui.theme_music_enabled"], + "context": {"profile_id": "p1", "device_id": "browser"}, + "stored": [ + {"key": "ui.theme_music_enabled", "scope": "profile", "profile_id": "p1", "value": true}, + {"key": "ui.theme_music_enabled", "scope": "profile_device", "profile_id": "p1", "device_id": "browser", "value": false} + ], + "expected": [{"key": "ui.theme_music_enabled", "value": false, "source": "profile_device"}] + }, + { + "name": "title_art_follows_each_device", + "description": "With no profile value, every device follows its own choice; a device that never chose shows title art.", + "keys": ["ui.title_art"], + "context": {"profile_id": "p1", "device_id": "phone"}, + "stored": [ + {"key": "ui.title_art", "scope": "profile_device", "profile_id": "p1", "device_id": "phone", "value": false}, + {"key": "ui.title_art", "scope": "profile_device", "profile_id": "p1", "device_id": "living-room", "value": true} + ], + "expected": [{"key": "ui.title_art", "value": false, "source": "profile_device"}] + }, + { + "name": "title_art_profile_value_applies_to_all_devices", + "description": "A profile value is the apply-to-all-devices choice, so it wins over the device's own value instead of being the fallback.", + "keys": ["ui.title_art"], + "context": {"profile_id": "p1", "device_id": "phone"}, + "stored": [ + {"key": "ui.title_art", "scope": "profile", "profile_id": "p1", "value": true}, + {"key": "ui.title_art", "scope": "profile_device", "profile_id": "p1", "device_id": "phone", "value": false} + ], + "expected": [{"key": "ui.title_art", "value": true, "source": "profile"}] } ] } diff --git a/shared/src/commonTest/resources/settings/v1/manifest.json b/shared/src/commonTest/resources/settings/v1/manifest.json index 09ef683ac8..a796f33b62 100644 --- a/shared/src/commonTest/resources/settings/v1/manifest.json +++ b/shared/src/commonTest/resources/settings/v1/manifest.json @@ -1,6 +1,6 @@ { "api_version": 1, - "revision": 9, + "revision": 17, "option_sets": { "playback_audio_languages": { "type": "language_tag", @@ -365,7 +365,7 @@ "recommended_control": "select", "suggested_options": "playback_audio_languages", "unset_label": "No preference", - "notes": "Migrates user_profiles.language as the roaming fallback. Existing user_device_settings values become real overrides, and the per-series value comes from AudioPreference.audio_language. AudioPreference.audio_track_index and track_signature stay specialized: they identify a concrete track, not a default. The legacy string-only endpoint has no way to send null, so it spells \"no preference\" as the empty string and both Android and web send that to clear the choice; its validator accepts \"\" and otherwise requires a well-formed tag via settingscontract.NormalizeLanguageTag. Migration maps \"\" to no stored row, the same way playback.subtitle_mode handles it." + "notes": "Migrates user_profiles.language as the roaming fallback. Existing user_device_settings values become real overrides, and the per-series value comes from AudioPreference.audio_language. AudioPreference.audio_track_index and track_signature stay specialized: they identify a concrete track, not a default. The legacy string-only endpoint has no way to send null, so it spells \"no preference\" as the empty string and both Android and web send that to clear the choice; its validator accepts \"\" and otherwise requires a well-formed tag via settingscontract.NormalizeLanguageTag. Migration maps \"\" to no stored row, the same way playback.subtitle_mode handles it. From revision 9, the private-use tag x-silo-original means play each item in its original language: the server resolves it against the item's original_language (falling back to the profile choice when that is unknown), as it already did for the legacy \"original\" value. It is a valid value of the existing language_tag schema, so this is additive; clients that do not recognize it should label it \"Original language\" rather than as a language. The Jellyfin-compatible API maps Jellyfin's AudioLanguagePreference \"OriginalLanguage\" to it." }, { "key": "playback.subtitle_language", @@ -450,6 +450,7 @@ "backgroundColor": "#000000", "backgroundStyle": "box", "backgroundOpacity": 75, + "textOpacity": 100, "textOutline": false, "textOutlineColor": "#000000", "position": "bottom" @@ -458,7 +459,7 @@ "label": "Subtitle appearance", "description": "How subtitles are drawn during playback.", "recommended_control": "panel", - "notes": "Renamed from the unprefixed legacy key \"subtitle_appearance\". Every other canonical key carries a domain prefix, and preserving accidental key names is an explicit non-goal of the design. The rename touches three URL paths in internal/api/router.go, the admin device-settings routes, and the key constant in every client, so it cannot land without them. Migration copies the account-level legacy fallback to every existing profile and leaves device overrides unchanged, rewriting the key on each row. Revision 6 changes the fallback background from shadow to a 75% opaque box, matching the previous intended web default and the current Apple fallback. Connected clients receive the complete effective default from the server; clients should adopt revision 6 so their local and sparse-object decoding fallbacks match it. Stored profile and device overrides remain authoritative." + "notes": "Renamed from the unprefixed legacy key \"subtitle_appearance\". Every other canonical key carries a domain prefix, and preserving accidental key names is an explicit non-goal of the design. The rename touches three URL paths in internal/api/router.go, the admin device-settings routes, and the key constant in every client, so it cannot land without them. Migration copies the account-level legacy fallback to every existing profile and leaves device overrides unchanged, rewriting the key on each row. Revision 6 changes the fallback background from shadow to a 75% opaque box, matching the previous intended web default and the current Apple fallback. Connected clients receive the complete effective default from the server; clients should adopt revision 6 so their local and sparse-object decoding fallbacks match it. Stored profile and device overrides remain authoritative. Revision 14 adds the optional textOpacity property (1-100, default 100) controlling the opacity of the subtitle text itself, independent of backgroundOpacity; a value stored without it resolves to fully opaque text, matching pre-14 rendering." }, { "key": "playback.preferred_quality", @@ -645,6 +646,50 @@ "recommended_control": "panel", "notes": "Keys are canonical catalog original_language codes. Values are target BCP 47 language tags; x-silo-original means retain that source language. This key is separate from catalog.metadata_language so existing clients can continue changing the fallback without rewriting or discarding exceptions." }, + { + "key": "catalog.show_advisory_age", + "introduced_in": 10, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { "type": "boolean" }, + "default_value": false, + "category": "catalog", + "label": "Show advisory age", + "description": "Show a recommended minimum viewer age from an advisory service, such as Common Sense Media, on item detail.", + "recommended_control": "switch", + "notes": "Profile scope because the reader is a household parent deciding what a child may watch, and they browse on their own profile. Default false: the advisory is extra chrome most viewers do not want, and only the profiles that ask for it should carry it. This setting only controls whether the badge is shown: turning it on or off never changes what a profile is allowed to watch. Restricting a profile by advisory age is a separate access field, the profile's max_advisory_age, which a household manager sets on the profile itself. Coverage is partial by nature: the provider that supplies the advisory is rate limited per day, so on a large library some titles carry one and others do not." + }, + { + "key": "catalog.hide_unwatched_episode_images", + "introduced_in": 17, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { "type": "boolean" }, + "default_value": false, + "platforms": ["web", "ios", "tvos", "macos", "android", "android_tv"], + "category": "catalog", + "label": "Blur unwatched episode images", + "description": "Blur an episode's thumbnail until you start watching it, so the image does not give away the story.", + "recommended_control": "switch", + "notes": "Clients apply this; the server never changes catalog responses, so cached catalog data stays shared across profiles. An episode counts as unwatched while it is neither played nor in progress (user_data.played is false and it has no saved position). Covers episode stills wherever a client shows one for a specific episode: season episode lists, other-episodes rows, Next Up and Continue Watching cards, and the next-episode screen in the player. Series and season artwork is not affected. Profile scope so the choice follows the viewer across devices; default false because most viewers expect to see episode stills." + }, + { + "key": "catalog.hide_unwatched_episode_overviews", + "introduced_in": 17, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { "type": "boolean" }, + "default_value": false, + "platforms": ["web", "ios", "tvos", "macos", "android", "android_tv"], + "category": "catalog", + "label": "Hide unwatched episode descriptions", + "description": "Hide an episode's description until you start watching it.", + "recommended_control": "switch", + "notes": "Clients apply this with the same unwatched rule as catalog.hide_unwatched_episode_images: neither played nor in progress. Covers episode overviews in episode lists, on episode detail, and on the next-episode screen in the player. Series and season overviews are not affected. Separate from the image setting because some viewers want to hide only one of the two." + }, { "key": "player.hdr_enabled", "introduced_in": 1, @@ -653,6 +698,7 @@ "resolution_order": ["profile_device", "default"], "value_schema": { "type": "boolean" }, "default_value": true, + "platforms": ["ios", "tvos", "macos", "android", "android_tv"], "category": "player", "label": "HDR", "description": "Allow HDR output on this device.", @@ -724,6 +770,7 @@ "value_schema": { "type": "number", "minimum": 0.25, "maximum": 3.0, "step": 0.05 }, "default_value": 1.0, "unit": "x", + "platforms": ["ios", "tvos", "macos", "android", "android_tv"], "category": "player", "label": "Playback speed", "description": "Default playback speed on this device.", @@ -754,6 +801,7 @@ "value_schema": { "type": "integer", "minimum": -10000, "maximum": 10000 }, "default_value": 0, "unit": "milliseconds", + "platforms": ["ios", "tvos", "macos", "android", "android_tv"], "category": "player", "label": "Subtitle sync offset", "description": "Shift subtitles earlier or later on this device.", @@ -836,7 +884,8 @@ "label": "Theme", "description": "Colour theme for the Silo interface.", "recommended_control": "select", - "notes": "Renamed from the unregistered legacy key \"ui_theme\", which the extension bag accepted without validation. Moved from account to profile scope: appearance is per household member, and the account row is copied to every profile during migration. Carries a device override because the right theme is partly a function of the screen and the room — a light theme on a phone in daylight, a dark one on a TV at night — which is the same reasoning that gives ui.text_scale one. Note that ui.custom_theme_vars and ui.custom_css stay profile-wide, so a profile's custom styling still applies on top of a device's theme override. Adding a theme is an additive enum widening. The admin-set default theme stays in server_settings and is not a user setting. Migration must also update internal/plugins/user_theme_lookup.go, which reads this value with raw SQL bound to both the old name and the account scope (SELECT value FROM user_settings WHERE user_id = $1 AND key = 'ui_theme') and feeds the X-Prairie-Theme header on every plugin request. Left alone, that query matches nothing after the rename and every plugin UI silently falls back to its own theme, with no error to notice." + "deprecated": true, + "notes": "Deprecated in revision 12: the web client has one theme, Cinema Dark, which only the admin customizes (accent color plus the ui.admin_theme_vars and ui.admin_custom_css server settings), and it no longer reads or writes this key. Web-only (platforms: web). Stored values were deleted by migration 20260926233851_retire_profile_themes (SQLite user store schema v29). The definition stays published so a stale cached web bundle's write is accepted rather than failing; nothing reads what it stores. Renamed from the unregistered legacy key \"ui_theme\" at the settings cutover." }, { "key": "ui.text_scale", @@ -914,7 +963,8 @@ "label": "Custom theme variables", "description": "Per-token overrides applied on top of the selected theme.", "recommended_control": "panel", - "notes": "Renamed from the unregistered legacy key \"ui_custom_theme_vars\", which stored arbitrary unvalidated JSON." + "deprecated": true, + "notes": "Deprecated in revision 12: the web client has one theme, Cinema Dark, which only the admin customizes (accent color plus the ui.admin_theme_vars and ui.admin_custom_css server settings), and it no longer reads or writes this key. Web-only (platforms: web). Stored values were deleted by migration 20260926233851_retire_profile_themes (SQLite user store schema v29). The definition stays published so a stale cached web bundle's write is accepted rather than failing; nothing reads what it stores. Renamed from the unregistered legacy key \"ui_custom_theme_vars\", which stored arbitrary unvalidated JSON." }, { "key": "ui.custom_css", @@ -929,7 +979,8 @@ "label": "Custom CSS", "description": "Raw CSS applied on top of the selected theme.", "recommended_control": "text", - "notes": "Renamed from the unregistered legacy key \"ui_custom_css\". Sanitization stays in the web client (web/src/lib/cssSanitizer.ts); the contract only bounds length. This value is per-profile and is never applied to another profile's session." + "deprecated": true, + "notes": "Deprecated in revision 12: the web client has one theme, Cinema Dark, which only the admin customizes (accent color plus the ui.admin_theme_vars and ui.admin_custom_css server settings), and it no longer reads or writes this key. Web-only (platforms: web). Stored values were deleted by migration 20260926233851_retire_profile_themes (SQLite user store schema v29). The definition stays published so a stale cached web bundle's write is accepted rather than failing; nothing reads what it stores. Renamed from the unregistered legacy key \"ui_custom_css\". The contract only bounds length." }, { "key": "ui.date_format", @@ -974,6 +1025,20 @@ "recommended_control": "select", "notes": "Moved from account to profile scope; the account row is copied to every profile during migration." }, + { + "key": "home.hide_watched_items", + "introduced_in": 12, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { "type": "boolean" }, + "default_value": false, + "platforms": ["web", "ios", "tvos", "macos", "android", "android_tv"], + "category": "navigation", + "label": "Hide watched items from Home", + "description": "Remove watched items from ordinary Home sections while keeping Featured and watch-history sections unchanged.", + "recommended_control": "switch" + }, { "key": "ui.library_page_state", "introduced_in": 1, @@ -1043,7 +1108,7 @@ "category": "appearance", "label": "Poster badges", "description": "Which badges appear on poster cards, and where.", - "notes": "Registered from the legacy unprefixed key card_overlays, which reached the server only through the unknown-key extension bag — stored as an arbitrary string with no validation. null means the user has expressed no preference, which is what lets the server-wide admin default in the overlay-config endpoint apply; writing a resolved-but-unchosen value would silently pin them. The server-wide default configuration stays in server_settings and is not a user setting. This key describes which badges to show, not whether to show them — that is ui.card_overlays_enabled." + "notes": "Registered from the legacy unprefixed key card_overlays, which reached the server only through the unknown-key extension bag — stored as an arbitrary string with no validation. null means the user has expressed no preference, which is what lets the server-wide admin default in the overlay-config endpoint apply; writing a resolved-but-unchosen value would silently pin them. The server-wide default configuration stays in server_settings and is not a user setting. This key describes which badges to show, not whether to show them — that is ui.card_overlays_enabled. Revision 13 adds the advisory_age overlay id; a server below revision 13 rejects a value that contains it, so a client offers that badge only when manifest_revision is at least 13. Revision 15 adds the request_status overlay id (a watchlist title's request status on its poster) under the same rule: a client offers it only when manifest_revision is at least 15." }, { "key": "ui.card_overlays_enabled", @@ -1053,7 +1118,7 @@ "resolution_order": ["profile", "default"], "value_schema": { "type": "boolean", "nullable": true }, "default_value": null, - "platforms": ["web"], + "platforms": ["web", "ios", "tvos", "macos", "android", "android_tv"], "category": "appearance", "label": "Card overlays enabled", "description": "Show overlay badges on media cards.", @@ -1098,6 +1163,56 @@ "recommended_control": "switch", "notes": "null means the profile has not chosen whether quick actions are enabled, so the server-wide default from the overlay-config endpoint applies. An explicit profile choice overrides the server-wide default in either direction." }, + { + "key": "ui.theme_music_enabled", + "introduced_in": 11, + "persistence": "remote", + "allowed_scopes": [ + "profile", + "profile_device" + ], + "resolution_order": [ + "profile_device", + "profile", + "default" + ], + "value_schema": { + "type": "boolean" + }, + "default_value": false, + "platforms": [ + "web" + ], + "category": "playback", + "label": "Theme music", + "description": "Play local theme music while browsing movie and show detail pages.", + "recommended_control": "switch" + }, + { + "key": "ui.theme_music_loop", + "introduced_in": 11, + "persistence": "remote", + "allowed_scopes": [ + "profile", + "profile_device" + ], + "resolution_order": [ + "profile_device", + "profile", + "default" + ], + "value_schema": { + "type": "boolean" + }, + "default_value": false, + "platforms": [ + "web" + ], + "category": "playback", + "label": "Loop theme music", + "description": "Repeat theme music while the detail page stays open.", + "recommended_control": "switch" + }, { "key": "ui.next_up_mode", "introduced_in": 1, @@ -1355,6 +1470,35 @@ "description": "Show the Audiobooks section in navigation.", "recommended_control": "switch", "notes": "Contract-known local: an opt-in navigation surface, hidden by default, with existing Apple (AppNavPreferences.showAudiobooks) and Android parity. Android stores it locally at nav.show_audiobooks.local." + }, + { + "key": "requests.watchlist_auto_request", + "introduced_in": 15, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { "type": "boolean" }, + "default_value": true, + "category": "requests", + "label": "Request titles I add to my watchlist", + "description": "When you add a title that is not in the library to your watchlist, also request it.", + "recommended_control": "switch", + "notes": "Profile scope because each household member decides whether their own watchlist adds become requests. It applies only while the server-wide watchlist_requests request setting is on and the profile may request at all; a client shows it only then (GET /api/v2/requests/status reports the effective value as watchlist_requests). Request limits, approval and routing apply exactly as for the Request button. Turning it off never cancels an existing request." + }, + { + "key": "ui.title_art", + "introduced_in": 16, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device"], + "resolution_order": ["profile", "profile_device", "default"], + "value_schema": { "type": "boolean" }, + "default_value": true, + "platforms": ["web", "ios", "tvos", "macos", "android", "android_tv"], + "category": "appearance", + "label": "Show title art", + "description": "Use a title's logo artwork as its name on detail pages when one is available. When off, the name is always shown as text.", + "recommended_control": "switch", + "notes": "The profile scope deliberately resolves before profile_device: a profile value is the profile's \"apply to all devices\" choice and wins on every device, while with no profile value each device follows its own profile_device value. Clients show a second \"Apply to all devices\" switch whose state is the effective source being profile. Turning it on writes the current value at profile scope; while on, the main switch writes profile scope. Turning it off writes the current value at profile_device for the device doing it and then clears the profile value, so that device keeps its look and every other device returns to its own value or the default. Device values written earlier are kept while the profile value is set and apply again once it is cleared. Covers every surface that names a title with its logo (detail heroes, TV marquees); a client that turns it off also has no reason to prefetch logos." } ] } diff --git a/shared/src/commonTest/resources/watchtogether/v2/SOURCE b/shared/src/commonTest/resources/watchtogether/v2/SOURCE new file mode 100644 index 0000000000..2114ef8abe --- /dev/null +++ b/shared/src/commonTest/resources/watchtogether/v2/SOURCE @@ -0,0 +1,13 @@ +server_repository=Prairie-Server/prairie-server +server_commit=2812ac390829bec2b64328dcee29f0eb15a9b808 +vendored=capabilities.json (contracts/api/v2/fixtures/watch_together_capabilities.json, verbatim) +derived=every other file, written from the server's Go wire types at the same commit + +Derived fixtures follow internal/apiv2/watch_together_room_read.go (HTTP v2 +snapshot, string ids), internal/watchtogether/types.go (socket Snapshot, +MemberSummary, TransportCommand, Suggestion with numeric ids), +internal/apiv2/watch_together_suggestions.go (suggestion page), +internal/apiv2/watch_together_member_state.go, internal/apiv2/watch_together_picker.go, +and internal/api/handlers/watch_together.go (connection_replaced text). +Re-derive them when the server wire types change; the server does not +publish contract fixtures for these operations yet. diff --git a/shared/src/commonTest/resources/watchtogether/v2/capabilities.json b/shared/src/commonTest/resources/watchtogether/v2/capabilities.json new file mode 100644 index 0000000000..dfe7b84bd9 --- /dev/null +++ b/shared/src/commonTest/resources/watchtogether/v2/capabilities.json @@ -0,0 +1,15 @@ +{ + "revision": "efa2c8c12c9ace1fbcf4c6e3e159a3f97ee59741f73d3ca9e303b0b1e0179cf2", + "state": "available", + "allowed": false, + "staged_selection": true, + "lobby_ready": true, + "connection_replaced": true, + "selection_mode_switch": true, + "member_state": true, + "picker": true, + "vote_host_override": true, + "stop_playback": true, + "max_member_state_ids": 200, + "socket_protocol": "prairie.room.v2" +} diff --git a/shared/src/commonTest/resources/watchtogether/v2/member_state.json b/shared/src/commonTest/resources/watchtogether/v2/member_state.json new file mode 100644 index 0000000000..1680db40e0 --- /dev/null +++ b/shared/src/commonTest/resources/watchtogether/v2/member_state.json @@ -0,0 +1 @@ +{"members":[{"user_id":"12","profile_id":"p-host","display_name":"Alex","is_host":true,"is_self":true,"connected":true,"lobby_ready":false}],"items":[{"content_id":"movie:heat-1995","members":[{"user_id":"12","profile_id":"p-host","state":"in_progress","position_seconds":812.0,"duration_seconds":10200.0,"on_watchlist":true}]}]} diff --git a/shared/src/commonTest/resources/watchtogether/v2/picker.json b/shared/src/commonTest/resources/watchtogether/v2/picker.json new file mode 100644 index 0000000000..99524edada --- /dev/null +++ b/shared/src/commonTest/resources/watchtogether/v2/picker.json @@ -0,0 +1 @@ +{"members":[{"user_id":"12","profile_id":"p-host","display_name":"Alex","is_host":true,"is_self":true,"connected":true,"lobby_ready":false}],"continue_together":[],"watchlist_union":[{"item":{"content_id":"series:the-bear","type":"series","title":"The Bear","genres":[],"keywords":[],"status":"matched"},"members":[{"user_id":"12","profile_id":"p-host","display_name":"Alex"}],"next_up":{"content_id":"episode:the-bear-s02e06","season_number":2,"episode_number":6,"title":"Fishes","member_count":1}}]} diff --git a/shared/src/commonTest/resources/watchtogether/v2/room_http.json b/shared/src/commonTest/resources/watchtogether/v2/room_http.json new file mode 100644 index 0000000000..4b6f174d3d --- /dev/null +++ b/shared/src/commonTest/resources/watchtogether/v2/room_http.json @@ -0,0 +1,49 @@ +{ + "room": { + "room_id": "9f3c1a52-3c4e-4f7b-9a0e-6b2d8c1e7a10", + "phase": "playing", + "playback_state": "playing", + "selection_mode": "host_pick", + "selection_revision": 3, + "selected_content_id": "movie:heat-1995", + "selected_file_id": "42", + "selected_library_id": "7", + "code": "K7PQ2M4X", + "guest_control_policy": "guest_play_pause", + "is_paused": false, + "anchor_position_seconds": 812.25, + "anchor_updated_at": "2026-09-24T18:02:11.482Z", + "generation": 11, + "member_count": 2, + "host_connected": true, + "self_role": "host", + "self_can_control_transport": true, + "self_can_manage_room": true, + "self_ignore_wait": false, + "attached_session_id": "ps_7d1f0c", + "invite_path": "/rooms/join?token=Zq8vN3kR5tW1yB6cD9fG2hJ4", + "members": [ + { + "user_id": "12", + "profile_id": "p-host", + "display_name": "Alex", + "is_host": true, + "is_self": true, + "connected": true, + "is_ready": true, + "lobby_ready": false + }, + { + "user_id": "31", + "profile_id": "p-guest", + "display_name": "Sam", + "is_host": false, + "is_self": false, + "connected": true, + "is_syncing": true, + "lobby_ready": false + } + ] + }, + "room_access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb29tX2lkIjoiOWYzYzFhNTItM2M0ZS00ZjdiLTlhMGUtNmIyZDhjMWU3YTEwIiwidXNlcl9pZCI6MTIsInByb2ZpbGVfaWQiOiJwLWhvc3QiLCJleHAiOjE3OTA0NjEzMzF9.c2lnbmF0dXJl" +} diff --git a/shared/src/commonTest/resources/watchtogether/v2/socket_connection_replaced.json b/shared/src/commonTest/resources/watchtogether/v2/socket_connection_replaced.json new file mode 100644 index 0000000000..9acc9cb13d --- /dev/null +++ b/shared/src/commonTest/resources/watchtogether/v2/socket_connection_replaced.json @@ -0,0 +1 @@ +{"type":"connection_replaced","reason":"This profile joined the Watch Party on another device."} diff --git a/shared/src/commonTest/resources/watchtogether/v2/socket_pong.json b/shared/src/commonTest/resources/watchtogether/v2/socket_pong.json new file mode 100644 index 0000000000..20537b8ac8 --- /dev/null +++ b/shared/src/commonTest/resources/watchtogether/v2/socket_pong.json @@ -0,0 +1 @@ +{"type":"pong","client_sent_at":"2026-09-24T18:00:00.000Z","server_received_at":"2026-09-24T18:00:00.240Z","server_sent_at":"2026-09-24T18:00:00.241Z"} diff --git a/shared/src/commonTest/resources/watchtogether/v2/socket_room_closed.json b/shared/src/commonTest/resources/watchtogether/v2/socket_room_closed.json new file mode 100644 index 0000000000..facd0f9ec6 --- /dev/null +++ b/shared/src/commonTest/resources/watchtogether/v2/socket_room_closed.json @@ -0,0 +1 @@ +{"type":"room_closed","reason":"host_left"} diff --git a/shared/src/commonTest/resources/watchtogether/v2/socket_snapshot.json b/shared/src/commonTest/resources/watchtogether/v2/socket_snapshot.json new file mode 100644 index 0000000000..a940036a21 --- /dev/null +++ b/shared/src/commonTest/resources/watchtogether/v2/socket_snapshot.json @@ -0,0 +1 @@ +{"type":"snapshot","room":{"room_id":"9f3c1a52-3c4e-4f7b-9a0e-6b2d8c1e7a10","phase":"playing","playback_state":"paused","selection_mode":"vote","selection_revision":4,"selected_content_id":"episode:the-bear-s02e06","selected_file_id":57,"selected_library_id":7,"code":"K7PQ2M4X","guest_control_policy":"host_only","is_paused":true,"anchor_position_seconds":95.5,"anchor_updated_at":"2026-09-24T18:10:00.000000001Z","generation":14,"member_count":2,"host_connected":false,"self_role":"guest","self_can_control_transport":false,"self_can_manage_room":false,"self_ignore_wait":true,"attached_session_id":"ps_0a9b","members":[{"user_id":12,"profile_id":"p-host","display_name":"Alex","is_host":true,"is_self":false,"connected":false,"lobby_ready":false},{"user_id":31,"profile_id":"p-guest","display_name":"Sam","is_host":false,"is_self":true,"connected":true,"is_buffering":true,"lobby_ready":false}]}} diff --git a/shared/src/commonTest/resources/watchtogether/v2/socket_suggestions_update.json b/shared/src/commonTest/resources/watchtogether/v2/socket_suggestions_update.json new file mode 100644 index 0000000000..131b278340 --- /dev/null +++ b/shared/src/commonTest/resources/watchtogether/v2/socket_suggestions_update.json @@ -0,0 +1 @@ +{"type":"suggestions_update","suggestions":[{"id":"b2a1","room_id":"9f3c1a52-3c4e-4f7b-9a0e-6b2d8c1e7a10","suggester_user_id":31,"suggester_profile_id":"p-guest","content_id":"movie:collateral-2004","content_type":"movie","title":"Collateral","subtitle":"2004","poster_url":"/img/c.jpg","note":"","vote_count":2,"voted_by_me":false,"created_at":"2026-09-24T17:58:00Z"},{"id":"a9f0","room_id":"9f3c1a52-3c4e-4f7b-9a0e-6b2d8c1e7a10","suggester_user_id":12,"suggester_profile_id":"p-host","content_id":"movie:heat-1995","content_type":"movie","title":"Heat","subtitle":"1995","poster_url":"","note":"classic","vote_count":1,"voted_by_me":false,"created_at":"2026-09-24T17:55:00Z"}]} diff --git a/shared/src/commonTest/resources/watchtogether/v2/socket_transport_command.json b/shared/src/commonTest/resources/watchtogether/v2/socket_transport_command.json new file mode 100644 index 0000000000..5ca2c88a85 --- /dev/null +++ b/shared/src/commonTest/resources/watchtogether/v2/socket_transport_command.json @@ -0,0 +1 @@ +{"type":"transport_command","command":{"command_id":"cmd_5b2e","session_id":"","selection_revision":4,"action":"seek","position_seconds":1203.75,"execute_at":"2026-09-24T18:11:02.250000000Z","issued_at":"2026-09-24T18:11:01.750000000Z","playback_state":"waiting"}} diff --git a/shared/src/commonTest/resources/watchtogether/v2/suggestions_page.json b/shared/src/commonTest/resources/watchtogether/v2/suggestions_page.json new file mode 100644 index 0000000000..765197c3cf --- /dev/null +++ b/shared/src/commonTest/resources/watchtogether/v2/suggestions_page.json @@ -0,0 +1 @@ +{"items":[{"id":"a9f0","room_id":"9f3c1a52-3c4e-4f7b-9a0e-6b2d8c1e7a10","suggester_user_id":"12","suggester_profile_id":"p-host","content_id":"movie:heat-1995","content_type":"movie","title":"Heat","subtitle":"1995","poster_url":"","note":"classic","vote_count":1,"voted_by_me":true,"created_at":"2026-09-24T17:55:00Z"}],"page":{"has_more":true,"next_cursor":"c2VlZDoy"}}